diff --git a/desktop/src/components/Launchpad.tsx b/desktop/src/components/Launchpad.tsx index 66e11816d..4d1db96b9 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 000000000..ffe0c7260 --- /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 f370350ab..48726effb 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 9d9009fb5..c4d20b685 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 5f52bec84..a34ad7cf9 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)