diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 5549d73a..e2e36444 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -1805,6 +1805,7 @@ describe('registerIpcHandlers SSH hosts', () => { } as const const agentPackageManager = { getAllSnapshot: vi.fn(async () => agentPackageInventory), + getAllInventory: vi.fn(async () => agentPackageInventory), forPlatform: vi.fn(() => agentPackageManager), download: vi.fn( async ( @@ -1944,7 +1945,16 @@ describe('registerIpcHandlers SSH hosts', () => { ).resolves.toEqual(agentPackageInventory) expect( agentPackageManager.getAllSnapshot - ).toHaveBeenCalledWith({ refresh: true }) + ).toHaveBeenCalledWith({ refresh: true, localOnly: false }) + await expect( + electronMocks.handlers.get( + ipcChannels.sshHostsAgentPackageInventory + )?.(event, { localOnly: true }) + ).resolves.toEqual(agentPackageInventory) + expect(agentPackageManager.getAllInventory).toHaveBeenCalledWith({ + refresh: false, + localOnly: true + }) await expect( electronMocks.handlers.get( ipcChannels.sshHostsAgentPackageDownload diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 876fcf30..ad2b306f 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -4855,7 +4855,9 @@ export function registerIpcHandlers( const request = agentPackageInventoryRequestSchema.parse(input ?? {}) return agentPackageInventorySchema.parse( - await agentPackageManager.getAllSnapshot(request) + request.localOnly + ? await agentPackageManager.getAllInventory(request) + : await agentPackageManager.getAllSnapshot(request) ) } ) diff --git a/src/main/remote-agent/agent-package-manager.ts b/src/main/remote-agent/agent-package-manager.ts index 4313210a..9208bd83 100644 --- a/src/main/remote-agent/agent-package-manager.ts +++ b/src/main/remote-agent/agent-package-manager.ts @@ -300,6 +300,17 @@ export class AgentPackageManager { }) } + async getAllInventory(options: { refresh?: boolean } = {}): Promise { + const linux = await this.forPlatform('linux').getInventory(options) + const darwin = this.forPlatform('darwin') + darwin.#catalogState = this.forPlatform('linux').#catalogState + const mac = await darwin.getInventory(options) + return agentPackageInventorySchema.parse({ + ...linux, + entries: [...linux.entries, ...mac.entries] + }) + } + #select(catalog: AgentPackageCatalog, architecture: AgentArchitecture, desktopVersion: string) { return selectLatestCompatibleEntry(catalog, architecture, desktopVersion, this.#platform) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 19c86869..fa95263d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -528,10 +528,10 @@ const desktopApi: DesktopApi = { handler ) }, - getAgentPackageInventory: (refresh = false) => + getAgentPackageInventory: (refresh = false, localOnly = false) => ipcRenderer.invoke( ipcChannels.sshHostsAgentPackageInventory, - { refresh } + { refresh, localOnly } ) as Promise, downloadAgentPackage: (architecture: AgentArchitecture, platform: 'linux' | 'darwin' = 'linux') => ipcRenderer.invoke( diff --git a/src/preload/preload-sandbox.test.ts b/src/preload/preload-sandbox.test.ts index 81825b9a..d9930403 100644 --- a/src/preload/preload-sandbox.test.ts +++ b/src/preload/preload-sandbox.test.ts @@ -292,7 +292,7 @@ describe('sandboxed preload', () => { 'ipcChannels.sshHostsAgentConnectionStatus' ) expect(source).toContain( - 'getAgentPackageInventory: (refresh = false)' + 'getAgentPackageInventory: (refresh = false, localOnly = false)' ) expect(source).toContain('downloadAgentPackage:') expect(source).toContain('importAgentPackage:') diff --git a/src/renderer/src/AgentPackageInventoryPanel.test.tsx b/src/renderer/src/AgentPackageInventoryPanel.test.tsx index 5cf896e0..3452db4c 100644 --- a/src/renderer/src/AgentPackageInventoryPanel.test.tsx +++ b/src/renderer/src/AgentPackageInventoryPanel.test.tsx @@ -42,7 +42,7 @@ const agentPackageInventory: AgentPackageInventory = { } ] } -const getAgentPackageInventory = vi.fn<(refresh?: boolean) => Promise>() +const getAgentPackageInventory = vi.fn<(refresh?: boolean, localOnly?: boolean) => Promise>() const downloadAgentPackage = vi.fn<(architecture: string, platform: string) => Promise>() const importAgentPackage = vi.fn<() => Promise>() const exportAgentPackage = vi.fn<(architecture: string, platform: string) => Promise>() @@ -84,11 +84,12 @@ describe('AgentPackageInventoryPanel', () => { expect(await screen.findByText('当前 1 / 2 个平台与架构目标可用。')).toBeInTheDocument() expect(screen.getByText('有更新')).toBeInTheDocument() expect(screen.getByText('未下载')).toBeInTheDocument() - expect(getAgentPackageInventory).toHaveBeenCalledExactlyOnceWith(false) + expect(getAgentPackageInventory).toHaveBeenNthCalledWith(1, false, true) + expect(getAgentPackageInventory).toHaveBeenNthCalledWith(2, false) fireEvent.click(screen.getByRole('button', { name: '刷新包清单' })) - await waitFor(() => expect(getAgentPackageInventory).toHaveBeenCalledTimes(2)) - expect(getAgentPackageInventory).toHaveBeenNthCalledWith(2, true) + await waitFor(() => expect(getAgentPackageInventory).toHaveBeenCalledTimes(3)) + expect(getAgentPackageInventory).toHaveBeenNthCalledWith(3, true) const x64Card = screen.getByText('Linux x64').closest('[role="listitem"]') as HTMLElement const arm64Card = screen.getByText('Linux arm64').closest('[role="listitem"]') as HTMLElement @@ -115,6 +116,37 @@ describe('AgentPackageInventoryPanel', () => { await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ dedupeKey: 'agent-package-imported' }))) }) + it('shows the local inventory before the online catalog returns', async () => { + const localInventory: AgentPackageInventory = { + ...agentPackageInventory, + catalog: { state: 'not-checked', checkedAt: null, error: null }, + entries: agentPackageInventory.entries.map((entry) => ({ + ...entry, + latestVersion: null, + updateAvailable: false + })) + } + let resolveOnline: ((inventory: AgentPackageInventory) => void) | undefined + getAgentPackageInventory + .mockResolvedValueOnce(localInventory) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveOnline = resolve + }) + ) + + render() + expect(await screen.findByText('当前 1 / 2 个平台与架构目标可用。')).toBeInTheDocument() + expect(getAgentPackageInventory).toHaveBeenNthCalledWith(1, false, true) + expect(getAgentPackageInventory).toHaveBeenNthCalledWith(2, false) + + await act(async () => { + resolveOnline?.(agentPackageInventory) + await Promise.resolve() + }) + expect(await screen.findByText('有更新')).toBeInTheDocument() + }) + it('shows loading and recovers from a refresh error', async () => { let resolveInventory: ((inventory: AgentPackageInventory) => void) | undefined getAgentPackageInventory.mockReturnValueOnce( @@ -133,15 +165,18 @@ describe('AgentPackageInventoryPanel', () => { await screen.findByText('inventory unavailable') fireEvent.click(screen.getByRole('button', { name: '刷新包清单' })) await waitFor(() => expect(screen.queryByText('inventory unavailable')).not.toBeInTheDocument()) - expect(getAgentPackageInventory).toHaveBeenCalledTimes(3) + expect(getAgentPackageInventory).toHaveBeenCalledTimes(4) }) it('keeps local Agent packages usable when the online catalog is unavailable', async () => { - getAgentPackageInventory.mockResolvedValueOnce({ + const unavailableInventory: AgentPackageInventory = { ...agentPackageInventory, catalog: { state: 'unavailable', checkedAt: '2026-08-24T00:00:00.000Z', error: 'network unavailable' }, entries: agentPackageInventory.entries.map((entry) => ({ ...entry, latestVersion: null, updateAvailable: false })) - }) + } + getAgentPackageInventory + .mockResolvedValueOnce(unavailableInventory) + .mockResolvedValueOnce(unavailableInventory) render() expect(await screen.findByText(/network unavailable/u)).toBeInTheDocument() const x64Card = screen.getByText('Linux x64').closest('[role="listitem"]') as HTMLElement diff --git a/src/renderer/src/AgentPackageInventoryPanel.tsx b/src/renderer/src/AgentPackageInventoryPanel.tsx index 2b8f4f33..a0771964 100644 --- a/src/renderer/src/AgentPackageInventoryPanel.tsx +++ b/src/renderer/src/AgentPackageInventoryPanel.tsx @@ -54,6 +54,9 @@ export function AgentPackageInventoryPanel({ setAgentInventoryLoading(true) setAgentInventoryError(undefined) try { + if (!refresh) { + setAgentInventory(await getInventory(false, true)) + } setAgentInventory(await getInventory(refresh)) } catch (reason) { setAgentInventoryError( @@ -259,16 +262,17 @@ export function AgentPackageInventoryPanel({ )}

)} + {agentInventoryError && ( +

+ {agentInventoryError} +

+ )} {agentInventoryLoading && !agentInventory ? (

{t( 'platformFeatures.remoteProjects.agentInventory.loading' )}

- ) : agentInventoryError ? ( -

- {agentInventoryError} -

) : agentInventory ? (
{ ).toThrow() }) - it('accepts only a bounded refresh flag for inventory reads', () => { + it('accepts only bounded inventory read flags', () => { expect(agentPackageInventoryRequestSchema.parse({})).toEqual({ - refresh: false + refresh: false, + localOnly: false }) expect(() => agentPackageInventoryRequestSchema.parse({ diff --git a/src/shared/agent-package-contracts.ts b/src/shared/agent-package-contracts.ts index a7339efd..e4e0fdef 100644 --- a/src/shared/agent-package-contracts.ts +++ b/src/shared/agent-package-contracts.ts @@ -281,7 +281,8 @@ export type AgentPackageInventory = z.infer< export const agentPackageInventoryRequestSchema = z .object({ - refresh: z.boolean().default(false) + refresh: z.boolean().default(false), + localOnly: z.boolean().default(false) }) .strict() diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 5aa77545..cc002cbd 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -1869,7 +1869,8 @@ export type DesktopApi = { listener: (status: SshHostAgentConnectionStatus) => void ) => () => void getAgentPackageInventory: ( - refresh?: boolean + refresh?: boolean, + localOnly?: boolean ) => Promise downloadAgentPackage: ( architecture: AgentArchitecture,