Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop/e2e-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@
"zh-tw-locale.spec.ts": {
"tests": 1,
"electron": "the locale is a persisted Host setting and only takes effect through a renderer reload"
},
"session-windows.spec.ts": {
"tests": 2,
"electron": "native BrowserWindow focus/minimize/close and main recreation, real WebContentsView identity across renderer/main transfer, and restart with neither restored session windows nor persisted handoff drafts"
}
}
}
115 changes: 115 additions & 0 deletions apps/desktop/e2e/session-windows.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { createServer } from 'node:http';
import type { ElectronApplication, Page } from '@playwright/test';
import { COMPOSER_INPUT, awaitSendReady, ensureSidebarExpanded, expect, test } from './fixtures';

async function createTask(page: Page, text: string): Promise<string> {
await page.locator(COMPOSER_INPUT).fill(text);
await awaitSendReady(page);
await page.locator(COMPOSER_INPUT).press('Enter');
await expect(page.getByText(`Fake backend received: ${text}`)).toBeVisible();
await ensureSidebarExpanded(page);
const id = await page.locator('[data-session-id]:has([aria-current="page"])').getAttribute('data-session-id');
if (!id) throw new Error('No active session');
return id;
}
async function detach(app: ElectronApplication, page: Page, id: string): Promise<Page> {
const next = app.waitForEvent('window');
await page.evaluate((sessionId) => window.maka.appWindow.openSessionWindow(sessionId), id);
const child = await next;
await expect(child.locator(COMPOSER_INPUT)).toBeVisible();
return child;
}

// Electron boundary: real WebContentsView identity and renderer/main handoff;
// a mock window cannot expose Chromium destruction during native reparenting.
test('detaching and returning keeps the same native browser and restores a minimized owner', async ({ sessionLocalWindow }) => {
const { page, app } = sessionLocalWindow;
const id = await createTask(page, 'multi-window browser continuity');
await page.locator(COMPOSER_INPUT).fill('draft travelling with the session');
const server = createServer((_request, response) => response.end('<!doctype html><title>Window handoff</title><input id="state" value="initial">'));
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
const address = server.address();
if (!address || typeof address === 'string') throw new Error('No server address');
const url = `http://127.0.0.1:${address.port}/`;
await page.evaluate(async ({ id, url }) => { window.maka.browser.setActiveSession(id); await window.maka.browser.navigate(id, url); }, { id, url });
const nativePageId = await app.evaluate(async ({ webContents }, url) => {
const native = webContents.getAllWebContents().find((contents) => contents.getURL() === url);
if (!native) throw new Error('Native browser page not found');
await native.executeJavaScript('document.querySelector("input").value = "preserved native state"');
return native.id;
}, url);
const child = await detach(app, page, id);
await expect(child.locator(COMPOSER_INPUT)).toHaveText('draft travelling with the session');
await expect(page.locator(COMPOSER_INPUT)).toHaveText('');
const childNative = await app.browserWindow(child);
await childNative.evaluate((window) => window.minimize());
await page.evaluate((sessionId) => window.maka.appWindow.openSessionWindow(sessionId), id);
await expect.poll(() => childNative.evaluate((window) => window.isMinimized())).toBe(false);
// Isolated E2E fixtures intentionally reveal without taking OS focus.
await expect.poll(() => childNative.evaluate((window) => window.isVisible())).toBe(true);
await child.getByRole('button', { name: '在主窗口打开', exact: true }).click();
await expect.poll(() => child.isClosed()).toBe(true);
await expect(page.locator(COMPOSER_INPUT)).toHaveText('draft travelling with the session');
expect(await app.evaluate(async ({ webContents }, nativeId) => {
const native = webContents.fromId(nativeId);
return native?.executeJavaScript('document.querySelector("input").value');
}, nativePageId)).toBe('preserved native state');
} finally { await new Promise<void>((resolve) => server.close(() => resolve())); }
});

// Electron boundary: closing/recreating real BrowserWindows and app restart,
// including in-memory handoff between renderers and a fresh main-process lifetime.
test('closing main keeps the session window alive and restart opens only main', async ({ sessionLocalWindow }) => {
const { page, app, restart } = sessionLocalWindow;
const id = await createTask(page, 'multi-window independent lifetime');
const child = await detach(app, page, id);
await child.locator(COMPOSER_INPUT).fill('saved after closing a session window');
const mainNative = await app.browserWindow(page);
await mainNative.evaluate((window) => window.close());
await expect.poll(() => page.isClosed()).toBe(true);
expect(child.isClosed()).toBe(false);
const reopened = app.waitForEvent('window');
await child.getByRole('button', { name: '在主窗口打开', exact: true }).click();
const main = await reopened;
await expect(main.locator(COMPOSER_INPUT)).toHaveText('saved after closing a session window');
const secondChild = await detach(app, main, id);
await expect(secondChild.locator(COMPOSER_INPUT)).toHaveText('saved after closing a session window');
const childNative = await app.browserWindow(secondChild);
await childNative.evaluate((window) => window.close());
await expect.poll(() => secondChild.isClosed()).toBe(true);
await ensureSidebarExpanded(main);
await main.locator(`[data-session-id=${JSON.stringify(id)}]`).click();
await expect(main.locator(COMPOSER_INPUT)).toHaveText('saved after closing a session window');
await detach(app, main, id);
let restartedApp: ElectronApplication | undefined;
const restored = await restart(async (next) => { restartedApp = next; });
expect((await restored.evaluate(() => window.maka.appWindow.context())).kind).toBe('main');
await expect.poll(async () => {
const pages = restartedApp!.windows().filter((window) => !window.isClosed());
const contexts = await Promise.all(pages.map((window) => window.evaluate(() => window.maka?.appWindow?.context()).catch(() => undefined)));
return contexts.filter((context) => context?.kind === 'session').length;
}).toBe(0);
await ensureSidebarExpanded(restored);
await restored.locator(`[data-session-id=${JSON.stringify(id)}]`).click();
await expect(restored.locator(COMPOSER_INPUT)).toHaveText('');
});
30 changes: 23 additions & 7 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -348,12 +348,15 @@
"ConversationMessageConsumer",
"ConversationProvider",
"ConversationTranscriptRegion",
"ConversationWindowState",
"NEW_TASK_PENDING_KEY",
"PlanExecutionSurface",
"PlanProvider",
"TaskReadinessProvider",
"createComposerStagingCommands",
"createComposerSubmissionCommands",
"createComposerSurfaceOwner",
"createComposerUriDispatcher",
"createExecutorSessionActivator",
"resolveTaskReadinessModelTarget",
"useActiveExecutionBoundary",
Expand Down Expand Up @@ -444,6 +447,7 @@
},
"src/renderer/features/session-navigation/index.ts": {
"appShell": [
"SessionNavigationCommands",
"SessionNavigationProvider",
"createSessionOpenCommand",
"sessionRailLayoutStore",
Expand All @@ -456,12 +460,24 @@
"src/renderer/features/session-settings/index.ts": {
"appShell": [
"SessionSettingsProvider",
"createSessionModeCommands",
"deriveSessionSettingsPresentation",
"useSessionSettingIntent"
],
"composition": [
"SessionSettingsServicesProvider"
]
},
"src/renderer/features/session-windows/index.ts": {
"appShell": [
"SessionWindowBinding",
"SessionWindowEmptyState",
"SessionWindowRoot"
],
"composition": [
"SessionWindowServicesProvider"
]
},
"src/renderer/features/storage-usage/index.ts": {
"appShell": [
"ArchiveRetentionNotices"
Expand Down Expand Up @@ -523,7 +539,7 @@
"@maka/ui": 1
},
"importSpecifiers": 0,
"nonTriviaTokens": 122
"nonTriviaTokens": 117
},
"src/renderer/app-shell-command-actions.ts": {
"importDeclarations": 4,
Expand All @@ -547,7 +563,7 @@
"react": 1
},
"importSpecifiers": 7,
"nonTriviaTokens": 2158
"nonTriviaTokens": 2132
},
"src/renderer/app-shell-copy.ts": {
"importDeclarations": 1,
Expand Down Expand Up @@ -609,7 +625,7 @@
"react": 1
},
"importSpecifiers": 7,
"nonTriviaTokens": 843
"nonTriviaTokens": 841
},
"src/renderer/app-shell-overlays.tsx": {
"importDeclarations": 4,
Expand Down Expand Up @@ -642,7 +658,7 @@
"nonTriviaTokens": 858
},
"src/renderer/app-shell.tsx": {
"importDeclarations": 34,
"importDeclarations": 33,
"bridgePaths": {},
"environmentCapabilities": {
"window.requestAnimationFrame": 3
Expand Down Expand Up @@ -699,6 +715,7 @@
"./features/session-collaboration": 1,
"./features/session-navigation": 1,
"./features/session-settings": 1,
"./features/session-windows/index.js": 1,
"./features/storage-usage/index.js": 1,
"./features/task-entry": 1,
"./features/workbar": 1,
Expand All @@ -725,13 +742,12 @@
"@astryxdesign/core/AppShell": 1,
"@maka/core/onboarding-milestone": 1,
"@maka/core/session": 1,
"@maka/core/session-revisions": 1,
"@maka/core/ui-locale": 1,
"@maka/ui": 1,
"react": 1
},
"importSpecifiers": 43,
"nonTriviaTokens": 7290
"importSpecifiers": 42,
"nonTriviaTokens": 7279
},
"src/renderer/use-app-shell-session-list.ts": {
"importDeclarations": 3,
Expand Down
10 changes: 7 additions & 3 deletions apps/desktop/scripts/workhub-browser-presentation-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ import { createServer } from 'node:http';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { app, BrowserWindow, desktopCapturer, screen } from 'electron';
import { app, BrowserWindow, desktopCapturer, screen, View } from 'electron';
import { createWorkHubPresentation } from '../dist/main/workhub-presentation.js';
import { desktopSessionResourceKey } from '../dist/shared/runtime-host-identity.js';
import { desktopSessionKey, desktopSessionResourceKey } from '../dist/shared/runtime-host-identity.js';
import { registerBrowserIpc } from '../dist/main/browser-ipc-main.js';
import { BrowserViewController } from '../dist/main/browser/controller.js';
import { BrowserViewManager } from '../dist/main/browser/view-manager.js';
Expand Down Expand Up @@ -74,18 +74,22 @@ async function run() {
});
presentation.registerIpc();
presentation.attachMainWindow(main);
const isMainRenderer = (wc) => !main.isDestroyed() && wc === main.webContents;
registerBrowserIpc({
mainWindowController: {
getBrowserViews: (resolve) => {
parentForSession = resolve;
return views;
},
isMainRenderer: (wc) => !main.isDestroyed() && wc === main.webContents,
isMainRenderer,
isWorkspaceRenderer: isMainRenderer,
},
auxiliaryWindowRegistry: {
rendererParent: (wc) => wc === owner ? ownerParent : undefined,
},
isHostActive: (ref) => ref.hostId === scope.hostId && ref.targetEpoch === scope.targetEpoch,
canUseSession: (wc, id) => isMainRenderer(wc) && id === desktopSessionKey({ ...scope, sessionId }),
parkingParent: new View(),
});
const host = { visible: true, rect: { x: 0, y: 0, width: 470, height: 700 } };
const rect = { x: 500, y: 80, width: 430, height: 560 };
Expand Down
40 changes: 40 additions & 0 deletions apps/desktop/src/main/__tests__/attachment-preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createAttachmentApprovalRegistry } from '../attachment-approval.js';
import { SessionLocalAttachmentRecovery } from '../session-local-attachment-recovery.js';
import {
loadApprovalPreview,
loadRecoveryApprovalPreview,
registerAttachmentPreviewIpc,
type AttachmentPreviewResult,
} from '../attachment-preview.js';
Expand Down Expand Up @@ -200,6 +202,8 @@ describe('staged attachment preview (approval source)', () => {
(event: { sender: { id: number } }, approvalId: unknown) => Promise<AttachmentPreviewResult>
>();
registerAttachmentPreviewIpc({
recovery: new SessionLocalAttachmentRecovery(),
authorizeRecovery: () => true,
ipcMain: { handle: (channel, listener) => handlers.set(channel, listener) },
approvals,
readFile: async () => PNG,
Expand Down Expand Up @@ -259,3 +263,39 @@ describe('staged attachment preview (approval source)', () => {
);
});
});


describe('workspace recovery image preview', () => {
const owner = { senderId: 1, partition: 'authorized', scope: { hostId: 'host', targetEpoch: 'epoch' }, sessionId: 'session' };
it('rebuilds a thumbnail for the new window without exposing original bytes or consuming its send approval', async () => {
const recovery = new SessionLocalAttachmentRecovery();
const [approval] = recovery.issue(owner, [{ name: 'image.png', mimeType: 'image/png', content: PNG }]);
const thumbnail = Uint8Array.from([...PNG, 1, 2, 3]);
const result = await loadRecoveryApprovalPreview({ senderId: owner.senderId, approvalId: approval.approvalId, recovery,
authorizeRecovery: (candidate) => candidate.partition === owner.partition && candidate.scope.targetEpoch === owner.scope.targetEpoch,
renderPreview: async () => ({ mimeType: 'image/png', bytes: thumbnail }),
});
assert.deepEqual(result, { ok: true, mimeType: 'image/png', base64: Buffer.from(thumbnail).toString('base64') });
assert.notEqual((result as { base64: string }).base64, Buffer.from(PNG).toString('base64'));
assert.equal(recovery.prepare(owner, [approval]).commit(() => true), true);
});
it('rejects another window or revoked Host and never falls back to original recovery bytes', async () => {
const recovery = new SessionLocalAttachmentRecovery();
const [approval] = recovery.issue(owner, [{ name: 'image.png', mimeType: 'image/png', content: PNG }]);
const input = { senderId: owner.senderId, approvalId: approval.approvalId, recovery, authorizeRecovery: () => true, renderPreview: async () => null };
assert.deepEqual(await loadRecoveryApprovalPreview({ ...input, senderId: 2 }), { ok: false, reason: 'not_found' });
assert.deepEqual(await loadRecoveryApprovalPreview({ ...input, authorizeRecovery: () => false }), { ok: false, reason: 'not_found' });
assert.deepEqual(await loadRecoveryApprovalPreview(input), { ok: false, reason: 'unreadable' });
});
it('discards a thumbnail if source authority expires during decoding', async () => {
const recovery = new SessionLocalAttachmentRecovery();
const [approval] = recovery.issue(owner, [{ name: 'image.png', mimeType: 'image/png', content: PNG }]);
const result = await loadRecoveryApprovalPreview({ senderId: owner.senderId, approvalId: approval.approvalId, recovery,
authorizeRecovery: () => true, renderPreview: async () => {
recovery.clear(owner.partition);
return { mimeType: 'image/png', bytes: PNG };
},
});
assert.deepEqual(result, { ok: false, reason: 'not_found' });
});
});
32 changes: 30 additions & 2 deletions apps/desktop/src/main/__tests__/browser-ipc-main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,14 +157,20 @@ test('browser IPC isolates owned renderer documents and their native parents', a
await controller?.dispose();
},
};
const workspaceRenderers = new Set<unknown>([main]);
let sessionOwner: unknown = main;
const mainWindowController = {
isWorkspaceRenderer: (contents: unknown) => workspaceRenderers.has(contents),
getBrowserViews: (resolve: typeof parentResolver) => {
parentResolver = resolve;
return manager;
},
isMainRenderer: (contents: Electron.WebContents) => contents === main as unknown as Electron.WebContents,
};
const parkingParent = { getVisible: () => false } as Electron.View;
const browserIpc = registerBrowserIpc({
parkingParent,
canUseSession: (contents, id) => !id.includes('main-session') || contents === sessionOwner,
mainWindowController: mainWindowController as never,
auxiliaryWindowRegistry: {
rendererParent: (contents: Electron.WebContents) =>
Expand Down Expand Up @@ -360,10 +366,32 @@ test('browser IPC isolates owned renderer documents and their native parents', a
// Electron rejects fromWebContents on that destroyed native object.
emit('browser:active-session', main, scope, 'main-session', 'main-document', 5);
emit('browser:setViewport', main, scope, { sessionId: 'main-session', rect: mainRect }, 'main-document', 5);
const child = new FakeRenderer('child-frame');
const childWindow = new FakeWindow();
const childParent = { getVisible: () => true } as Electron.View;
childWindow.contentView = childParent;
windows.set(child, childWindow);
workspaceRenderers.add(child);
emit('browser:document-ready', child, 'child-document');
sessionOwner = child;
browserIpc.transferSessions([JSON.stringify([scope.hostId, 'main-session'])], main as never, child as never);
emit('browser:active-session', child, scope, 'main-session', 'child-document', 1);
emit('browser:setViewport', child, scope, { sessionId: 'main-session', rect: mainRect }, 'child-document', 1);
emit('browser:active-session', main, scope, 'main-session', 'main-document', 6);
emit('browser:setViewport', main, scope, { sessionId: 'main-session', rect: mainRect }, 'main-document', 6);
await invoke('browser:navigate', main, scope, 'main-session', 'https://late-source.example/');
assert.equal(mainController.parent, childParent, 'late source selection cannot steal the destination page');
assert.deepEqual(mainController.navigations, ['https://main.example/']);
assert.doesNotThrow(() => main.destroy());
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(mainController.disposed, true, 'closing Main still releases its owned browser page');
assert.equal(await browserViewHost().canDrive(mainKey, 'observe'), false);
assert.equal(mainController.parent, childParent, 'source destruction leaves the native page with its new owner');
assert.equal(mainController.disposed, false);
assert.doesNotThrow(() => child.destroy());
assert.equal(mainController.disposed, false, 'closing a workspace preserves the task browser page');
assert.equal(mainController.parent, parkingParent, 'pages leave the destroyed window');
assert.equal(await browserViewHost().canDrive(mainKey, 'observe'), true);
await browserIpc.retireTarget(scope);
assert.equal(mainController.disposed, true, 'retiring the Host still releases its browser resources');
assert.equal(mainWindow.listenerCount('close'), 0, 'renderer teardown removes window listeners');
} finally {
hooks.deregister();
Expand Down
Loading