From 2bb3f2063d642960abb88058559cc76610ca7768 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 31 May 2026 15:45:01 +0100 Subject: [PATCH] feat(launchpad): add Apps section + record docker service runtime location Installed docker services (e.g. SearxNG) never surfaced a Launchpad shortcut: the docker branch of _legacy_install fell through to a block that only recorded a runtime location for remote installs, and with port=0. A local docker install therefore succeeded but recorded no runtime location, so it never appeared in /api/apps/installed and the service proxy at /apps/{app_id}/ returned 503. Record a runtime location for docker services from their declared host port (compose maps {p}:{p}), using 127.0.0.1 locally or the resolved incus host for remotes. Rename the Launchpad 'Services' section to 'Apps' so user-installed services read as apps; launching one opens its proxied web UI (the searx page for SearxNG) via ServiceAppWindow. Add frontend test for the Apps section (renders shortcuts, empty when none, launches the proxied URL) and a backend test that a local docker install records a runtime location and appears in /api/apps/installed. --- desktop/src/components/Launchpad.tsx | 2 +- .../components/__tests__/Launchpad.test.tsx | 91 +++++++++++++++++++ desktop/tsconfig.tsbuildinfo | 2 +- tests/test_routes_store.py | 82 +++++++++++++++++ tinyagentos/routes/store_install.py | 59 ++++++++++-- 5 files changed, 228 insertions(+), 8 deletions(-) create mode 100644 desktop/src/components/__tests__/Launchpad.test.tsx diff --git a/desktop/src/components/Launchpad.tsx b/desktop/src/components/Launchpad.tsx index 66e11816..4d1db96b 100644 --- a/desktop/src/components/Launchpad.tsx +++ b/desktop/src/components/Launchpad.tsx @@ -124,7 +124,7 @@ export function Launchpad({ open, onClose, onOpenApp }: Props) { {filteredServices.length > 0 && (

- Services + Apps

{filteredServices.map((svc) => ( diff --git a/desktop/src/components/__tests__/Launchpad.test.tsx b/desktop/src/components/__tests__/Launchpad.test.tsx new file mode 100644 index 00000000..ffe0c726 --- /dev/null +++ b/desktop/src/components/__tests__/Launchpad.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import type { InstalledService } from "@/hooks/use-installed-services"; + +// Mockable list of installed services returned by the hook. +let mockServices: InstalledService[] = []; + +vi.mock("@/hooks/use-installed-services", () => ({ + useInstalledServices: () => mockServices, +})); + +// Shortcut registry is a no-op in tests. +vi.mock("@/hooks/use-shortcut-registry", () => ({ + useShortcut: () => {}, +})); + +// Capture window-open calls so we can assert the launch URL. +const openWindow = vi.fn(() => "wid-1"); +vi.mock("@/stores/process-store", () => ({ + useProcessStore: () => ({ openWindow }), +})); + +// Registry: getAllApps returns no core apps so the test isolates the Apps +// section; getApp/getOrRegisterServiceApp echo a minimal manifest. +vi.mock("@/registry/app-registry", () => ({ + getAllApps: () => [], + getApp: (id: string) => ({ id, defaultSize: { w: 100, h: 100 } }), + getOrRegisterServiceApp: (appId: string, displayName: string) => ({ + id: `service:${appId}`, + name: displayName, + defaultSize: { w: 1100, h: 750 }, + }), +})); + +import { Launchpad } from "../Launchpad"; + +const searxng: InstalledService = { + app_id: "searxng", + display_name: "SearXNG", + icon: null, + url: "/apps/searxng/", + category: "infrastructure", + backend: "docker", + status: "running", +}; + +const gitea: InstalledService = { + app_id: "gitea-lxc", + display_name: "Gitea", + icon: "/static/app-icons/gitea.svg", + url: "/apps/gitea-lxc/", + category: "dev-tool", + backend: "lxc", + status: "running", +}; + +describe("Launchpad Apps section", () => { + beforeEach(() => { + mockServices = []; + openWindow.mockClear(); + }); + + it("does not render an Apps section when no apps are installed", () => { + mockServices = []; + render( {}} />); + expect(screen.queryByText("Apps")).toBeNull(); + }); + + it("renders an Apps section with a shortcut per installed app/service", () => { + mockServices = [searxng, gitea]; + render( {}} />); + + expect(screen.getByText("Apps")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Open SearXNG" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Open Gitea" })).toBeTruthy(); + }); + + it("opens the proxied service URL when an app shortcut is launched", () => { + mockServices = [searxng]; + render( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "Open SearXNG" })); + + // ServiceAppWindow receives the proxied URL so SearXNG renders its search page. + expect(openWindow).toHaveBeenCalledWith( + "service:searxng", + { w: 1100, h: 750 }, + { url: "/apps/searxng/", displayName: "SearXNG" }, + ); + }); +}); diff --git a/desktop/tsconfig.tsbuildinfo b/desktop/tsconfig.tsbuildinfo index f370350a..48726eff 100644 --- a/desktop/tsconfig.tsbuildinfo +++ b/desktop/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/chatstandalone.tsx","./src/chat-main.tsx","./src/main.tsx","./src/sw.ts","./src/apps/activityapp.tsx","./src/apps/agentbrowsersapp.tsx","./src/apps/agentmessagespanel.tsx","./src/apps/agentskillspanel.tsx","./src/apps/agentsapp.tsx","./src/apps/calculatorapp.tsx","./src/apps/calendarapp.tsx","./src/apps/channelsapp.tsx","./src/apps/chessapp.tsx","./src/apps/clusterapp.tsx","./src/apps/contactsapp.tsx","./src/apps/crosswordsapp.tsx","./src/apps/filesapp.tsx","./src/apps/githubapp.tsx","./src/apps/imageviewerapp.tsx","./src/apps/imagesapp.tsx","./src/apps/importapp.tsx","./src/apps/libraryapp.tsx","./src/apps/mcpapp.tsx","./src/apps/mediaplayerapp.tsx","./src/apps/memoryapp.tsx","./src/apps/messagesapp.a2aselection.ts","./src/apps/messagesapp.tsx","./src/apps/modelsapp.tsx","./src/apps/placeholderapp.tsx","./src/apps/providersapp.tsx","./src/apps/redditapp.tsx","./src/apps/secretsapp.tsx","./src/apps/serviceappwindow.tsx","./src/apps/settingsapp.tsx","./src/apps/tasksapp.tsx","./src/apps/terminalapp.tsx","./src/apps/texteditorapp.tsx","./src/apps/weatherapp.tsx","./src/apps/wordleapp.tsx","./src/apps/xapp.tsx","./src/apps/youtubeapp.tsx","./src/apps/browserapp/addressbar.tsx","./src/apps/browserapp/addresssuggest.tsx","./src/apps/browserapp/agentcapabilitiespanel.tsx","./src/apps/browserapp/agentpanel.tsx","./src/apps/browserapp/agentpickerpopover.tsx","./src/apps/browserapp/agentpresencepill.tsx","./src/apps/browserapp/annotationlayer.tsx","./src/apps/browserapp/bookmarksbar.tsx","./src/apps/browserapp/browserapp.tsx","./src/apps/browserapp/capabilitypromptmodal.tsx","./src/apps/browserapp/chrome.tsx","./src/apps/browserapp/copilotbanner.tsx","./src/apps/browserapp/findinpage.tsx","./src/apps/browserapp/movetabmenu.tsx","./src/apps/browserapp/pagecontextmenu.tsx","./src/apps/browserapp/profilemanager.tsx","./src/apps/browserapp/profileswitcher.tsx","./src/apps/browserapp/readermode.tsx","./src/apps/browserapp/settingspanel.tsx","./src/apps/browserapp/sitepermissionspanel.tsx","./src/apps/browserapp/taboverview.tsx","./src/apps/browserapp/tabrenderer.tsx","./src/apps/browserapp/tabstrip.tsx","./src/apps/browserapp/windowchooser.tsx","./src/apps/browserapp/agent-ws-bridge.ts","./src/apps/browserapp/index.ts","./src/apps/browserapp/keyboard.ts","./src/apps/browserapp/live-exclusion.ts","./src/apps/browserapp/types.ts","./src/apps/projectsapp/addagentdialog.tsx","./src/apps/projectsapp/createprojectdialog.tsx","./src/apps/projectsapp/projectactivity.tsx","./src/apps/projectsapp/projectlist.tsx","./src/apps/projectsapp/projectmembers.tsx","./src/apps/projectsapp/projecttasklist.tsx","./src/apps/projectsapp/projectworkspace.tsx","./src/apps/projectsapp/index.tsx","./src/apps/projectsapp/board/boardcolumn.tsx","./src/apps/projectsapp/board/boardfilters.tsx","./src/apps/projectsapp/board/boardlane.tsx","./src/apps/projectsapp/board/boardtoolbar.tsx","./src/apps/projectsapp/board/projectboard.tsx","./src/apps/projectsapp/board/taskcard.tsx","./src/apps/projectsapp/board/taskcardcover.tsx","./src/apps/projectsapp/board/taskmodal.tsx","./src/apps/projectsapp/board/boarddnd.ts","./src/apps/projectsapp/board/boardfiltering.ts","./src/apps/projectsapp/board/boardgrouping.ts","./src/apps/projectsapp/board/types.ts","./src/apps/projectsapp/board/useboarddata.ts","./src/apps/projectsapp/board/useboardlive.ts","./src/apps/projectsapp/board/modal/activity.tsx","./src/apps/projectsapp/board/modal/hero.tsx","./src/apps/projectsapp/board/modal/metadatapane.tsx","./src/apps/projectsapp/board/modal/relationships.tsx","./src/apps/projectsapp/board/modal/subtasks.tsx","./src/apps/projectsapp/canvas/canvasboard.tsx","./src/apps/projectsapp/canvas/canvasview.tsx","./src/apps/projectsapp/canvas/canvas-api.ts","./src/apps/projectsapp/canvas/canvas-sse.ts","./src/apps/projectsapp/canvas/canvas-store.ts","./src/apps/projectsapp/canvas/shapes/imageshape.tsx","./src/apps/projectsapp/canvas/shapes/linkshape.tsx","./src/apps/projectsapp/canvas/shapes/noteshape.tsx","./src/apps/projectsapp/mobile/mobileboardcarousel.tsx","./src/apps/projectsapp/mobile/mobiletaskmodal.tsx","./src/apps/projectsapp/mobile/projectfab.tsx","./src/apps/projectsapp/mobile/taskcreatesheet.tsx","./src/apps/storeapp/backendpillbar.tsx","./src/apps/storeapp/devicepillbar.tsx","./src/apps/storeapp/incompatibletoggle.tsx","./src/apps/storeapp/backends.ts","./src/apps/storeapp/compat-visuals.ts","./src/apps/storeapp/filter.ts","./src/apps/storeapp/index.tsx","./src/apps/storeapp/resolver-types.ts","./src/apps/storeapp/storage.ts","./src/apps/storeapp/types.ts","./src/apps/chat/agentcontextmenu.tsx","./src/apps/chat/allthreadslist.tsx","./src/apps/chat/attachmentgallery.tsx","./src/apps/chat/attachmentlightbox.tsx","./src/apps/chat/attachmentsbar.tsx","./src/apps/chat/channelsettingspanel.tsx","./src/apps/chat/helppanel.tsx","./src/apps/chat/messageeditor.tsx","./src/apps/chat/messagehoveractions.tsx","./src/apps/chat/messageoverflowmenu.tsx","./src/apps/chat/messagetombstone.tsx","./src/apps/chat/pinbadge.tsx","./src/apps/chat/pinrequestaffordance.tsx","./src/apps/chat/pinnedmessagespopover.tsx","./src/apps/chat/slashmenu.tsx","./src/apps/chat/threadindicator.tsx","./src/apps/chat/threadpanel.tsx","./src/apps/chat/typingfooter.tsx","./src/apps/chat/format-author.ts","./src/components/agentshortcutrow.tsx","./src/components/apperrorboundary.tsx","./src/components/appshell.tsx","./src/components/backendbanner.tsx","./src/components/contextmenu.tsx","./src/components/desktop.tsx","./src/components/dock.tsx","./src/components/dockicon.tsx","./src/components/emojipicker.tsx","./src/components/launchpad.tsx","./src/components/launchpadicon.tsx","./src/components/logingate.tsx","./src/components/loginscreen.tsx","./src/components/migrationbanner.tsx","./src/components/modelbrowser.tsx","./src/components/modelpickerflow.tsx","./src/components/modelpickermodal.tsx","./src/components/notificationcentre.tsx","./src/components/notificationtoast.tsx","./src/components/onboardingscreen.tsx","./src/components/searchpalette.tsx","./src/components/serviceicon.tsx","./src/components/snapoverlay.tsx","./src/components/statusindicators.tsx","./src/components/taosassistantpanel.tsx","./src/components/taosassistantsettings.tsx","./src/components/topbar.tsx","./src/components/updateavailabletoast.tsx","./src/components/wallpaperpicker.tsx","./src/components/widgetlayer.tsx","./src/components/window.tsx","./src/components/windowcontent.tsx","./src/components/agent-settings/frameworktab.tsx","./src/components/agent-settings/memorytab.tsx","./src/components/agent-settings/personatab.tsx","./src/components/memory/agentmemorytable.tsx","./src/components/memory/dashboard.tsx","./src/components/memory/memorysettings.tsx","./src/components/memory/pipelinecontrol.tsx","./src/components/memory/schemaformrenderer.tsx","./src/components/memory/sessionbrowser.tsx","./src/components/memory/sessiondetail.tsx","./src/components/mobile/cardswitcher.tsx","./src/components/mobile/mobileapp.tsx","./src/components/mobile/mobileappwindow.tsx","./src/components/mobile/mobilebottomnav.tsx","./src/components/mobile/mobiledock.tsx","./src/components/mobile/mobilehomepages.tsx","./src/components/mobile/mobilelist.tsx","./src/components/mobile/mobilesplitview.tsx","./src/components/mobile/mobiletopbar.tsx","./src/components/mobile/pillbar.tsx","./src/components/mobile/workspacetabpills.tsx","./src/components/persona-picker/personablank.tsx","./src/components/persona-picker/personabrowse.tsx","./src/components/persona-picker/personacreate.tsx","./src/components/persona-picker/personapicker.tsx","./src/components/persona-picker/types.ts","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toolbar.tsx","./src/components/widgets/agentstatuswidget.tsx","./src/components/widgets/clockwidget.tsx","./src/components/widgets/greetingwidget.tsx","./src/components/widgets/quicknoteswidget.tsx","./src/components/widgets/systemstatswidget.tsx","./src/components/widgets/weatherwidget.tsx","./src/contexts/backendstatuscontext.tsx","./src/hooks/use-agent-shortcuts.ts","./src/hooks/use-clock.ts","./src/hooks/use-device-mode.ts","./src/hooks/use-focus-trap.ts","./src/hooks/use-installed-services.ts","./src/hooks/use-is-mobile.ts","./src/hooks/use-list-nav.ts","./src/hooks/use-server-preference.ts","./src/hooks/use-session-persistence.ts","./src/hooks/use-shortcut-registry.tsx","./src/hooks/use-snap-zones.ts","./src/hooks/use-visual-viewport.ts","./src/hooks/use-widget-size.ts","./src/lib/agent-browsers.ts","./src/lib/agent-emoji.ts","./src/lib/backendstatus.ts","./src/lib/browser-agent-api.ts","./src/lib/browser-bookmarks-api.ts","./src/lib/browser-capability-api.ts","./src/lib/browser-extract-api.ts","./src/lib/browser-profile-api.ts","./src/lib/browser-push-api.ts","./src/lib/browser-push-bootstrap.ts","./src/lib/browser-site-permissions-api.ts","./src/lib/browser-suggest-api.ts","./src/lib/browser-windows-api.ts","./src/lib/channel-admin-api.ts","./src/lib/chat-attachments-api.ts","./src/lib/chat-messages-api.ts","./src/lib/cluster.ts","./src/lib/framework-api.ts","./src/lib/github.ts","./src/lib/hw-detect.ts","./src/lib/knowledge.ts","./src/lib/memory.ts","./src/lib/models.ts","./src/lib/personas-api.ts","./src/lib/projects.ts","./src/lib/reddit.ts","./src/lib/slug.ts","./src/lib/sw-register.ts","./src/lib/taos-fetch.ts","./src/lib/use-thread-panel.ts","./src/lib/use-typing-emitter.ts","./src/lib/utils.ts","./src/lib/x-monitor.ts","./src/lib/youtube.ts","./src/registry/app-registry.ts","./src/shell/bottomsheet.tsx","./src/shell/filepicker.tsx","./src/shell/installpromptbanner.tsx","./src/shell/vfsbrowser.tsx","./src/shell/file-picker-api.ts","./src/shell/dnd/dnd-bus.ts","./src/shell/dnd/types.ts","./src/shell/dnd/use-drag-source.ts","./src/shell/dnd/use-drop-target.ts","./src/stores/browser-agent-store.ts","./src/stores/browser-settings-store.ts","./src/stores/browser-store.ts","./src/stores/dock-store.ts","./src/stores/mobile-home-store.ts","./src/stores/notification-store.ts","./src/stores/process-store.ts","./src/stores/taos-agent-store.ts","./src/stores/theme-store.ts","./src/stores/widget-store.ts","./src/types/css-modules.d.ts","./src/types/pell.d.ts","./src/types/plyr.d.ts","./src/types/react-grid-layout.d.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/chatstandalone.tsx","./src/chat-main.tsx","./src/main.tsx","./src/sw.ts","./src/apps/activityapp.tsx","./src/apps/agentbrowsersapp.tsx","./src/apps/agentmessagespanel.tsx","./src/apps/agentskillspanel.tsx","./src/apps/agentsapp.tsx","./src/apps/calculatorapp.tsx","./src/apps/calendarapp.tsx","./src/apps/channelsapp.tsx","./src/apps/chessapp.tsx","./src/apps/clusterapp.tsx","./src/apps/contactsapp.tsx","./src/apps/crosswordsapp.tsx","./src/apps/filesapp.tsx","./src/apps/githubapp.tsx","./src/apps/imageviewerapp.tsx","./src/apps/imagesapp.tsx","./src/apps/importapp.tsx","./src/apps/libraryapp.tsx","./src/apps/mcpapp.tsx","./src/apps/mediaplayerapp.tsx","./src/apps/memoryapp.tsx","./src/apps/messagesapp.a2aselection.ts","./src/apps/messagesapp.tsx","./src/apps/modelsapp.tsx","./src/apps/placeholderapp.tsx","./src/apps/providersapp.tsx","./src/apps/redditapp.tsx","./src/apps/secretsapp.tsx","./src/apps/serviceappwindow.tsx","./src/apps/settingsapp.tsx","./src/apps/tasksapp.tsx","./src/apps/terminalapp.tsx","./src/apps/texteditorapp.tsx","./src/apps/weatherapp.tsx","./src/apps/wordleapp.tsx","./src/apps/xapp.tsx","./src/apps/youtubeapp.tsx","./src/apps/shortcut-launch.ts","./src/apps/browserapp/addressbar.tsx","./src/apps/browserapp/addresssuggest.tsx","./src/apps/browserapp/agentcapabilitiespanel.tsx","./src/apps/browserapp/agentpanel.tsx","./src/apps/browserapp/agentpickerpopover.tsx","./src/apps/browserapp/agentpresencepill.tsx","./src/apps/browserapp/annotationlayer.tsx","./src/apps/browserapp/bookmarksbar.tsx","./src/apps/browserapp/browserapp.tsx","./src/apps/browserapp/capabilitypromptmodal.tsx","./src/apps/browserapp/chrome.tsx","./src/apps/browserapp/copilotbanner.tsx","./src/apps/browserapp/findinpage.tsx","./src/apps/browserapp/movetabmenu.tsx","./src/apps/browserapp/pagecontextmenu.tsx","./src/apps/browserapp/profilemanager.tsx","./src/apps/browserapp/profileswitcher.tsx","./src/apps/browserapp/readermode.tsx","./src/apps/browserapp/settingspanel.tsx","./src/apps/browserapp/sitepermissionspanel.tsx","./src/apps/browserapp/taboverview.tsx","./src/apps/browserapp/tabrenderer.tsx","./src/apps/browserapp/tabstrip.tsx","./src/apps/browserapp/windowchooser.tsx","./src/apps/browserapp/agent-ws-bridge.ts","./src/apps/browserapp/index.ts","./src/apps/browserapp/keyboard.ts","./src/apps/browserapp/live-exclusion.ts","./src/apps/browserapp/types.ts","./src/apps/projectsapp/addagentdialog.tsx","./src/apps/projectsapp/createprojectdialog.tsx","./src/apps/projectsapp/projectactivity.tsx","./src/apps/projectsapp/projectlist.tsx","./src/apps/projectsapp/projectmembers.tsx","./src/apps/projectsapp/projecttasklist.tsx","./src/apps/projectsapp/projectworkspace.tsx","./src/apps/projectsapp/index.tsx","./src/apps/projectsapp/board/boardcolumn.tsx","./src/apps/projectsapp/board/boardfilters.tsx","./src/apps/projectsapp/board/boardlane.tsx","./src/apps/projectsapp/board/boardtoolbar.tsx","./src/apps/projectsapp/board/projectboard.tsx","./src/apps/projectsapp/board/taskcard.tsx","./src/apps/projectsapp/board/taskcardcover.tsx","./src/apps/projectsapp/board/taskmodal.tsx","./src/apps/projectsapp/board/boarddnd.ts","./src/apps/projectsapp/board/boardfiltering.ts","./src/apps/projectsapp/board/boardgrouping.ts","./src/apps/projectsapp/board/types.ts","./src/apps/projectsapp/board/useboarddata.ts","./src/apps/projectsapp/board/useboardlive.ts","./src/apps/projectsapp/board/modal/activity.tsx","./src/apps/projectsapp/board/modal/hero.tsx","./src/apps/projectsapp/board/modal/metadatapane.tsx","./src/apps/projectsapp/board/modal/relationships.tsx","./src/apps/projectsapp/board/modal/subtasks.tsx","./src/apps/projectsapp/canvas/canvasboard.tsx","./src/apps/projectsapp/canvas/canvasview.tsx","./src/apps/projectsapp/canvas/canvas-api.ts","./src/apps/projectsapp/canvas/canvas-sse.ts","./src/apps/projectsapp/canvas/canvas-store.ts","./src/apps/projectsapp/canvas/shapes/imageshape.tsx","./src/apps/projectsapp/canvas/shapes/linkshape.tsx","./src/apps/projectsapp/canvas/shapes/noteshape.tsx","./src/apps/projectsapp/mobile/mobileboardcarousel.tsx","./src/apps/projectsapp/mobile/mobiletaskmodal.tsx","./src/apps/projectsapp/mobile/projectfab.tsx","./src/apps/projectsapp/mobile/taskcreatesheet.tsx","./src/apps/storeapp/backendpillbar.tsx","./src/apps/storeapp/devicepillbar.tsx","./src/apps/storeapp/incompatibletoggle.tsx","./src/apps/storeapp/backends.ts","./src/apps/storeapp/compat-visuals.ts","./src/apps/storeapp/filter.ts","./src/apps/storeapp/index.tsx","./src/apps/storeapp/resolver-types.ts","./src/apps/storeapp/storage.ts","./src/apps/storeapp/types.ts","./src/apps/chat/agentcontextmenu.tsx","./src/apps/chat/allthreadslist.tsx","./src/apps/chat/attachmentgallery.tsx","./src/apps/chat/attachmentlightbox.tsx","./src/apps/chat/attachmentsbar.tsx","./src/apps/chat/channelsettingspanel.tsx","./src/apps/chat/helppanel.tsx","./src/apps/chat/messageeditor.tsx","./src/apps/chat/messagehoveractions.tsx","./src/apps/chat/messageoverflowmenu.tsx","./src/apps/chat/messagetombstone.tsx","./src/apps/chat/pinbadge.tsx","./src/apps/chat/pinrequestaffordance.tsx","./src/apps/chat/pinnedmessagespopover.tsx","./src/apps/chat/slashmenu.tsx","./src/apps/chat/threadindicator.tsx","./src/apps/chat/threadpanel.tsx","./src/apps/chat/typingfooter.tsx","./src/apps/chat/format-author.ts","./src/components/agentshortcutrow.tsx","./src/components/apperrorboundary.tsx","./src/components/appshell.tsx","./src/components/backendbanner.tsx","./src/components/contextmenu.tsx","./src/components/desktop.tsx","./src/components/dock.tsx","./src/components/dockicon.tsx","./src/components/emojipicker.tsx","./src/components/launchpad.tsx","./src/components/launchpadicon.tsx","./src/components/logingate.tsx","./src/components/loginscreen.tsx","./src/components/migrationbanner.tsx","./src/components/modelbrowser.tsx","./src/components/modelpickerflow.tsx","./src/components/modelpickermodal.tsx","./src/components/notificationcentre.tsx","./src/components/notificationtoast.tsx","./src/components/onboardingscreen.tsx","./src/components/searchpalette.tsx","./src/components/serviceicon.tsx","./src/components/snapoverlay.tsx","./src/components/statusindicators.tsx","./src/components/taosassistantpanel.tsx","./src/components/taosassistantsettings.tsx","./src/components/topbar.tsx","./src/components/updateavailabletoast.tsx","./src/components/wallpaperpicker.tsx","./src/components/widgetlayer.tsx","./src/components/window.tsx","./src/components/windowcontent.tsx","./src/components/agent-settings/frameworktab.tsx","./src/components/agent-settings/memorytab.tsx","./src/components/agent-settings/personatab.tsx","./src/components/memory/agentmemorytable.tsx","./src/components/memory/dashboard.tsx","./src/components/memory/memorysettings.tsx","./src/components/memory/pipelinecontrol.tsx","./src/components/memory/schemaformrenderer.tsx","./src/components/memory/sessionbrowser.tsx","./src/components/memory/sessiondetail.tsx","./src/components/mobile/cardswitcher.tsx","./src/components/mobile/mobileapp.tsx","./src/components/mobile/mobileappwindow.tsx","./src/components/mobile/mobilebottomnav.tsx","./src/components/mobile/mobiledock.tsx","./src/components/mobile/mobilehomepages.tsx","./src/components/mobile/mobilelist.tsx","./src/components/mobile/mobilesplitview.tsx","./src/components/mobile/mobiletopbar.tsx","./src/components/mobile/pillbar.tsx","./src/components/mobile/workspacetabpills.tsx","./src/components/persona-picker/personablank.tsx","./src/components/persona-picker/personabrowse.tsx","./src/components/persona-picker/personacreate.tsx","./src/components/persona-picker/personapicker.tsx","./src/components/persona-picker/types.ts","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toolbar.tsx","./src/components/widgets/agentstatuswidget.tsx","./src/components/widgets/clockwidget.tsx","./src/components/widgets/greetingwidget.tsx","./src/components/widgets/quicknoteswidget.tsx","./src/components/widgets/systemstatswidget.tsx","./src/components/widgets/weatherwidget.tsx","./src/contexts/backendstatuscontext.tsx","./src/hooks/use-agent-shortcuts.ts","./src/hooks/use-clock.ts","./src/hooks/use-device-mode.ts","./src/hooks/use-focus-trap.ts","./src/hooks/use-installed-services.ts","./src/hooks/use-is-mobile.ts","./src/hooks/use-is-pwa.ts","./src/hooks/use-list-nav.ts","./src/hooks/use-server-preference.ts","./src/hooks/use-session-persistence.ts","./src/hooks/use-shortcut-registry.tsx","./src/hooks/use-snap-zones.ts","./src/hooks/use-visual-viewport.ts","./src/hooks/use-widget-size.ts","./src/lib/agent-browsers.ts","./src/lib/agent-emoji.ts","./src/lib/auth-guard.ts","./src/lib/backendstatus.ts","./src/lib/browser-agent-api.ts","./src/lib/browser-bookmarks-api.ts","./src/lib/browser-capability-api.ts","./src/lib/browser-extract-api.ts","./src/lib/browser-profile-api.ts","./src/lib/browser-push-api.ts","./src/lib/browser-push-bootstrap.ts","./src/lib/browser-site-permissions-api.ts","./src/lib/browser-suggest-api.ts","./src/lib/browser-windows-api.ts","./src/lib/channel-admin-api.ts","./src/lib/chat-attachments-api.ts","./src/lib/chat-messages-api.ts","./src/lib/cluster.ts","./src/lib/framework-api.ts","./src/lib/github.ts","./src/lib/hw-detect.ts","./src/lib/knowledge.ts","./src/lib/memory.ts","./src/lib/models.ts","./src/lib/personas-api.ts","./src/lib/projects.ts","./src/lib/reddit.ts","./src/lib/slug.ts","./src/lib/sw-register.ts","./src/lib/taos-fetch.ts","./src/lib/use-thread-panel.ts","./src/lib/use-typing-emitter.ts","./src/lib/utils.ts","./src/lib/x-monitor.ts","./src/lib/youtube.ts","./src/registry/app-registry.ts","./src/shell/bottomsheet.tsx","./src/shell/filepicker.tsx","./src/shell/installpromptbanner.tsx","./src/shell/vfsbrowser.tsx","./src/shell/file-picker-api.ts","./src/shell/dnd/dnd-bus.ts","./src/shell/dnd/types.ts","./src/shell/dnd/use-drag-source.ts","./src/shell/dnd/use-drop-target.ts","./src/stores/browser-agent-store.ts","./src/stores/browser-settings-store.ts","./src/stores/browser-store.ts","./src/stores/dock-store.ts","./src/stores/mobile-home-store.ts","./src/stores/notification-store.ts","./src/stores/process-store.ts","./src/stores/taos-agent-store.ts","./src/stores/theme-store.ts","./src/stores/widget-store.ts","./src/types/css-modules.d.ts","./src/types/pell.d.ts","./src/types/plyr.d.ts","./src/types/react-grid-layout.d.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/tests/test_routes_store.py b/tests/test_routes_store.py index 9d9009fb..c4d20b68 100644 --- a/tests/test_routes_store.py +++ b/tests/test_routes_store.py @@ -297,3 +297,85 @@ async def fake_update(app_id, host, port, backend="", ui_path="/"): assert backend == "lxc" +@pytest.mark.asyncio +class TestDockerInstallRecordsRuntimeLocation: + """Local docker services (e.g. SearxNG) must record a runtime location so + they appear in /api/apps/installed and get a Launchpad shortcut. + + Regression: the docker branch previously fell through to a block that only + recorded a location for remote installs (and with port=0), so a local + docker install succeeded but never surfaced a shortcut. + """ + + @pytest.fixture + def docker_catalog_dir(self, tmp_path): + svc = tmp_path / "catalog" / "services" / "searxng" + svc.mkdir(parents=True) + (svc / "manifest.yaml").write_text(yaml.dump({ + "id": "searxng", "name": "SearXNG", "type": "service", + "category": "infrastructure", "version": "2024.12.0", + "description": "Privacy-respecting metasearch engine", + "requires": {"ram_mb": 128, "ports": [8080]}, + "install": { + "method": "docker", + "image": "searxng/searxng:latest", + "ports": [8080], + }, + })) + return tmp_path / "catalog" + + @pytest_asyncio.fixture + async def docker_client(self, tmp_data_dir, docker_catalog_dir): + app = create_app(data_dir=tmp_data_dir, catalog_dir=docker_catalog_dir) + store = app.state.metrics + if store._db is not None: + await store.close() + await store.init() + await app.state.qmd_client.init() + await app.state.installed_apps.init() + app.state.auth.setup_user("admin", "Test Admin", "", "testpass") + _rec = app.state.auth.find_user("admin") + _token = app.state.auth.create_session(user_id=_rec["id"] if _rec else "", long_lived=True) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test", cookies={"taos_session": _token}) as c: + yield c, app + await store.close() + await app.state.qmd_client.close() + await app.state.http_client.aclose() + + async def test_local_docker_install_records_runtime_location_and_appears( + self, docker_client + ): + from unittest.mock import AsyncMock, patch + + client, app = docker_client + + # Mock DockerInstaller so no real docker/compose runs; both pull and + # the auto-start succeed. + with patch( + "tinyagentos.installers.docker_installer.DockerInstaller" + ) as MockDocker: + instance = MockDocker.return_value + instance.install = AsyncMock(return_value={"success": True, "path": "/tmp/x"}) + instance.start = AsyncMock(return_value={"success": True, "output": ""}) + + resp = await client.post( + "/api/store/install-v2", json={"app_id": "searxng"} + ) + assert resp.status_code == 200 + + # Runtime location recorded with the declared host port + docker backend. + loc = await app.state.installed_apps.get_runtime_location("searxng") + assert loc is not None + assert loc["runtime_host"] == "127.0.0.1" + assert loc["runtime_port"] == 8080 + assert loc["backend"] == "docker" + + # And it surfaces in /api/apps/installed so the Launchpad shows a shortcut. + listed = await client.get("/api/apps/installed") + assert listed.status_code == 200 + item = next(i for i in listed.json() if i["app_id"] == "searxng") + assert item["url"] == "/apps/searxng/" + assert item["status"] == "running" + + diff --git a/tinyagentos/routes/store_install.py b/tinyagentos/routes/store_install.py index 5f52bec8..a34ad7cf 100644 --- a/tinyagentos/routes/store_install.py +++ b/tinyagentos/routes/store_install.py @@ -183,6 +183,25 @@ def _registry_get(registry, app_id: str): return registry.get(app_id) +def _docker_published_port(install_config: dict) -> int: + """Return the first host port a docker service publishes, or 0. + + DockerInstaller maps each declared port as ``{p}:{p}`` so the host port + equals the container port. Ports may be declared either at the top level + (``install.ports``) or nested under ``install.requires.ports`` — mirror + the precedence DockerInstaller._generate_compose uses (requires first). + """ + if not isinstance(install_config, dict): + return 0 + ports = (install_config.get("requires") or {}).get("ports") or install_config.get("ports") or [] + for p in ports: + try: + return int(p) + except (TypeError, ValueError): + continue + return 0 + + async def _legacy_install(request: Request, body: dict, app_id: str | None, target_remote: str | None) -> JSONResponse: """Legacy method-driven install path for non-model manifests. @@ -393,6 +412,30 @@ async def _legacy_install(request: Request, body: dict, app_id: str | None, targ await store.install(app_id, body.get("version", ""), meta) raw_remote = body.get("target_remote") or "" _target_remote = raw_remote if raw_remote and raw_remote != "local" else None + + # Docker services publish their port on the host (compose maps {p}:{p}), + # so they are reachable via the service proxy at /apps/{app_id}/. Record a + # runtime location so the app appears in /api/apps/installed and gets a + # Launchpad shortcut. Without this, a local docker install (e.g. SearxNG) + # succeeds but never surfaces a shortcut. Remote docker installs resolve + # the host from the registered incus remote; local installs use 127.0.0.1. + if backend == "docker": + docker_port = _docker_published_port(install_config) + if docker_port: + runtime_host = ( + await _resolve_host(_target_remote) if _target_remote else "127.0.0.1" + ) + await store.update_runtime_location( + app_id, host=runtime_host, port=docker_port, backend="docker", + ui_path=(install_config.get("ui_path", "/") if isinstance(install_config, dict) else "/"), + ) + else: + logger.warning( + "_legacy_install: docker service %s declares no port; " + "no runtime location recorded (won't appear in Launchpad).", + app_id, + ) + if _target_remote is not None: try: import tinyagentos.containers as containers @@ -408,12 +451,16 @@ async def _legacy_install(request: Request, body: dict, app_id: str | None, targ ) except Exception as exc: # noqa: BLE001 logger.warning("_legacy_install default: could not verify remote %r: %s", _target_remote, exc) - runtime_host = await _resolve_host(_target_remote) - await store.update_runtime_location( - app_id, host=runtime_host, port=0, - backend=meta.get("backend", "") if isinstance(meta, dict) else "", - ui_path=(install_config.get("ui_path", "/") if isinstance(install_config, dict) else "/"), - ) + # Docker installs already recorded a full host:port location above — + # don't clobber it with the port=0 placeholder this branch records for + # backends (e.g. pip) that have no proxy-routable port. + if backend != "docker": + runtime_host = await _resolve_host(_target_remote) + await store.update_runtime_location( + app_id, host=runtime_host, port=0, + backend=meta.get("backend", "") if isinstance(meta, dict) else "", + ui_path=(install_config.get("ui_path", "/") if isinstance(install_config, dict) else "/"), + ) if registry is not None: version = body.get("version") or (getattr(manifest, "version", "") if manifest else "") registry.mark_installed(app_id, version)