| author | |
| committer | |
| log | 7ff2eabf56d30af15a8ac86b0eb46013a9e910ee |
| tree | 4169e40cd44b7dca703288b3eaf17747bfbaac15 |
| parent | fd7ee2c30a7eeef910c99e7bc92e944526555db7 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: PR Batch Tester <agent@local.test>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>4 files changed, 168 insertions(+), 9 deletions(-)
apps/desktop/src/preview/Manager.test.ts+117| ... | ... | @@ -1691,6 +1691,123 @@ describe("PreviewManager", () => { |
| 1691 | 1691 | ), |
| 1692 | 1692 | ); |
| 1693 | 1693 | |
| 1694 | const makeAttachingGuest = (id: number) => { | |
| 1695 | const listeners = new Map<string, () => void>(); | |
| 1696 | const attach = vi.fn(); | |
| 1697 | const detach = vi.fn(); | |
| 1698 | const sendCommand = vi.fn<(method: string, params?: unknown) => Promise<unknown>>( | |
| 1699 | async () => undefined, | |
| 1700 | ); | |
| 1701 | let destroyed = false; | |
| 1702 | const wc = { | |
| 1703 | id, | |
| 1704 | isDestroyed: () => destroyed, | |
| 1705 | isDevToolsOpened: () => { | |
| 1706 | // Electron throws from native methods once a WebContents is destroyed. | |
| 1707 | if (destroyed) throw new TypeError("Object has been destroyed"); | |
| 1708 | return false; | |
| 1709 | }, | |
| 1710 | getType: () => "webview", | |
| 1711 | getURL: () => "http://localhost:5173/README.md", | |
| 1712 | getTitle: () => "README.md", | |
| 1713 | isLoading: () => true, | |
| 1714 | getZoomFactor: () => 1, | |
| 1715 | setZoomFactor: vi.fn(), | |
| 1716 | setAudioMuted: vi.fn(), | |
| 1717 | isCurrentlyAudible: () => false, | |
| 1718 | on: vi.fn(), | |
| 1719 | off: vi.fn(), | |
| 1720 | once: (event: string, listener: () => void) => { | |
| 1721 | listeners.set(event, listener); | |
| 1722 | }, | |
| 1723 | ipc: { on: vi.fn(), off: vi.fn() }, | |
| 1724 | send: webviewSend, | |
| 1725 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | |
| 1726 | setIgnoreMenuShortcuts: vi.fn(), | |
| 1727 | setWindowOpenHandler: vi.fn(), | |
| 1728 | debugger: { | |
| 1729 | isAttached: () => attach.mock.calls.length > detach.mock.calls.length, | |
| 1730 | attach, | |
| 1731 | detach, | |
| 1732 | sendCommand, | |
| 1733 | on: vi.fn(), | |
| 1734 | off: vi.fn(), | |
| 1735 | }, | |
| 1736 | }; | |
| 1737 | return { | |
| 1738 | wc: wc as unknown as Electron.WebContents, | |
| 1739 | attach, | |
| 1740 | detach, | |
| 1741 | sendCommand, | |
| 1742 | destroy: () => { | |
| 1743 | destroyed = true; | |
| 1744 | listeners.get("destroyed")?.(); | |
| 1745 | }, | |
| 1746 | }; | |
| 1747 | }; | |
| 1748 | ||
| 1749 | effectIt.effect("sets the opaque base as soon as a guest attaches", () => | |
| 1750 | withManager((manager) => | |
| 1751 | Effect.gen(function* () { | |
| 1752 | // The tab's first document can paint before the renderer registers the | |
| 1753 | // guest, so the opaque base has to be the first command on attach. | |
| 1754 | const claimed = makeAttachingGuest(44); | |
| 1755 | fromId.mockReturnValue(claimed.wc); | |
| 1756 | yield* manager.prepareWebview(claimed.wc); | |
| 1757 | expect(claimed.attach).toHaveBeenCalledTimes(1); | |
| 1758 | expect(claimed.sendCommand.mock.calls[0]).toEqual([ | |
| 1759 | "Emulation.setDefaultBackgroundColorOverride", | |
| 1760 | { color: { r: 255, g: 255, b: 255, a: 1 } }, | |
| 1761 | ]); | |
| 1762 | ||
| 1763 | yield* manager.createTab("tab_early"); | |
| 1764 | yield* manager.registerWebview("tab_early", 44); | |
| 1765 | yield* manager.setColorScheme("tab_early", "dark"); | |
| 1766 | expect(claimed.attach).toHaveBeenCalledTimes(1); | |
| 1767 | expect(claimed.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { | |
| 1768 | features: [{ name: "prefers-color-scheme", value: "dark" }], | |
| 1769 | }); | |
| 1770 | ||
| 1771 | // A guest destroyed before any tab claims it releases its session. | |
| 1772 | const unclaimed = makeAttachingGuest(45); | |
| 1773 | yield* manager.prepareWebview(unclaimed.wc); | |
| 1774 | expect(unclaimed.attach).toHaveBeenCalledTimes(1); | |
| 1775 | unclaimed.destroy(); | |
| 1776 | yield* Effect.yieldNow; | |
| 1777 | expect(unclaimed.detach).toHaveBeenCalledTimes(1); | |
| 1778 | expect(claimed.detach).not.toHaveBeenCalled(); | |
| 1779 | }), | |
| 1780 | ), | |
| 1781 | ); | |
| 1782 | ||
| 1783 | effectIt.effect("skips a guest destroyed while another guest's session opens", () => | |
| 1784 | withManager((manager) => | |
| 1785 | Effect.gen(function* () { | |
| 1786 | const slow = makeAttachingGuest(46); | |
| 1787 | let releaseSlow = () => {}; | |
| 1788 | const slowCommand = new Promise<void>((resolve) => { | |
| 1789 | releaseSlow = resolve; | |
| 1790 | }); | |
| 1791 | slow.sendCommand.mockImplementation(() => slowCommand); | |
| 1792 | const queued = makeAttachingGuest(47); | |
| 1793 | ||
| 1794 | const slowFiber = yield* manager | |
| 1795 | .prepareWebview(slow.wc) | |
| 1796 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 1797 | const queuedFiber = yield* manager | |
| 1798 | .prepareWebview(queued.wc) | |
| 1799 | .pipe(Effect.forkChild({ startImmediately: true })); | |
| 1800 | queued.destroy(); | |
| 1801 | releaseSlow(); | |
| 1802 | ||
| 1803 | expect(Exit.isSuccess(yield* Fiber.await(slowFiber))).toBe(true); | |
| 1804 | expect(Exit.isSuccess(yield* Fiber.await(queuedFiber))).toBe(true); | |
| 1805 | expect(slow.attach).toHaveBeenCalledTimes(1); | |
| 1806 | expect(queued.attach).not.toHaveBeenCalled(); | |
| 1807 | }), | |
| 1808 | ), | |
| 1809 | ); | |
| 1810 | ||
| 1694 | 1811 | const makeAudioWebContents = (id: number) => { |
| 1695 | 1812 | const listeners = new Map<string, (...args: never[]) => void>(); |
| 1696 | 1813 | const setAudioMuted = vi.fn(); |
apps/desktop/src/preview/Manager.ts+49-9| ... | ... | @@ -1303,6 +1303,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1303 | 1303 | > => { |
| 1304 | 1304 | const existing = sessions.get(wc.id); |
| 1305 | 1305 | if (existing) return Effect.succeed([existing, sessions] as const); |
| 1306 | // A guest can be destroyed while it waits for this lock, and its native | |
| 1307 | // methods throw once it is. | |
| 1308 | if (wc.isDestroyed()) { | |
| 1309 | return Effect.fail( | |
| 1310 | new PreviewOperationError({ | |
| 1311 | operation: "ensureControlSession", | |
| 1312 | webContentsId: wc.id, | |
| 1313 | cause: new Error("WebContents was destroyed"), | |
| 1314 | }), | |
| 1315 | ); | |
| 1316 | } | |
| 1306 | 1317 | if (wc.isDevToolsOpened()) { |
| 1307 | 1318 | return Effect.fail( |
| 1308 | 1319 | new PreviewAutomationDevToolsOpenError({ |
| ... | ... | @@ -1409,21 +1420,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1409 | 1420 | wcDebugger.on("message", onMessage); |
| 1410 | 1421 | wcDebugger.attach("1.3"); |
| 1411 | 1422 | }); |
| 1412 | yield* Effect.forEach( | |
| 1413 | ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"], | |
| 1414 | (method) => | |
| 1415 | attemptPromise( | |
| 1416 | { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, | |
| 1417 | () => wcDebugger.sendCommand(method), | |
| 1418 | ), | |
| 1419 | { concurrency: "unbounded", discard: true }, | |
| 1420 | ); | |
| 1421 | 1423 | // Electron gives `<webview>` guests a transparent base background, and |
| 1422 | 1424 | // Chromium only paints a dark canvas for dark color-scheme pages over an |
| 1423 | 1425 | // opaque base. Without this, dark-scheme pages with no background of |
| 1424 | 1426 | // their own (text/plain, e.g. .md files) render white text on white. |
| 1425 | 1427 | // White matches the webview's existing white backing, so light pages look |
| 1426 | 1428 | // the same; Chromium still swaps in its dark canvas for dark-scheme pages. |
| 1429 | // Sent first because a document that paints before it arrives keeps the | |
| 1430 | // transparent base until its next load. | |
| 1427 | 1431 | yield* attemptPromise( |
| 1428 | 1432 | { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id }, |
| 1429 | 1433 | () => |
| ... | ... | @@ -1431,6 +1435,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1431 | 1435 | color: { r: 255, g: 255, b: 255, a: 1 }, |
| 1432 | 1436 | }), |
| 1433 | 1437 | ); |
| 1438 | yield* Effect.forEach( | |
| 1439 | ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"], | |
| 1440 | (method) => | |
| 1441 | attemptPromise( | |
| 1442 | { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, | |
| 1443 | () => wcDebugger.sendCommand(method), | |
| 1444 | ), | |
| 1445 | { concurrency: "unbounded", discard: true }, | |
| 1446 | ); | |
| 1434 | 1447 | return [ |
| 1435 | 1448 | control, |
| 1436 | 1449 | replaceMap(sessions, (copy) => { |
| ... | ... | @@ -2415,6 +2428,30 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2415 | 2428 | ); |
| 2416 | 2429 | }); |
| 2417 | 2430 | |
| 2431 | // Called when a guest attaches to the window, before its first document | |
| 2432 | // paints. A tab opened straight to a URL often paints before the renderer | |
| 2433 | // gets to registerWebview, which would leave that page on the transparent | |
| 2434 | // base. registerWebview reuses the session opened here. | |
| 2435 | const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* ( | |
| 2436 | wc: Electron.WebContents, | |
| 2437 | ) { | |
| 2438 | const webContentsId = wc.id; | |
| 2439 | // A guest destroyed before any tab claims it has no other cleanup path. | |
| 2440 | wc.once("destroyed", () => { | |
| 2441 | runFork(detachControlSession(webContentsId)); | |
| 2442 | }); | |
| 2443 | // Runs detached from the attach event, so nothing may escape. registerWebview | |
| 2444 | // opens the session again if this one did not. | |
| 2445 | yield* ensureControlSession(wc).pipe( | |
| 2446 | Effect.catchCause((cause) => | |
| 2447 | Effect.logDebug("Preview webview control session was not opened on attach.", { | |
| 2448 | webContentsId, | |
| 2449 | cause, | |
| 2450 | }), | |
| 2451 | ), | |
| 2452 | ); | |
| 2453 | }); | |
| 2454 | ||
| 2418 | 2455 | const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { |
| 2419 | 2456 | const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () => |
| 2420 | 2457 | normalizePreviewUrl(rawUrl), |
| ... | ... | @@ -4675,6 +4712,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 4675 | 4712 | openPictureInPicture, |
| 4676 | 4713 | openDevTools, |
| 4677 | 4714 | pickElement, |
| 4715 | prepareWebview, | |
| 4678 | 4716 | reapplyZoom, |
| 4679 | 4717 | refresh, |
| 4680 | 4718 | registerWebview, |
| ... | ... | @@ -5011,6 +5049,7 @@ export class PreviewManager extends Context.Service< |
| 5011 | 5049 | tabId: string, |
| 5012 | 5050 | webContentsId: number, |
| 5013 | 5051 | ) => Effect.Effect<void, PreviewManagerError>; |
| 5052 | readonly prepareWebview: (webContents: Electron.WebContents) => Effect.Effect<void>; | |
| 5014 | 5053 | readonly navigate: (tabId: string, url: string) => Effect.Effect<void, PreviewManagerError>; |
| 5015 | 5054 | readonly goBack: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 5016 | 5055 | readonly goForward: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| ... | ... | @@ -5137,6 +5176,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 5137 | 5176 | createTab: operations.createTab, |
| 5138 | 5177 | closeTab: operations.closeTab, |
| 5139 | 5178 | registerWebview: operations.registerWebview, |
| 5179 | prepareWebview: operations.prepareWebview, | |
| 5140 | 5180 | navigate: operations.navigate, |
| 5141 | 5181 | goBack: operations.goBack, |
| 5142 | 5182 | goForward: operations.goForward, |
apps/desktop/src/window/DesktopWindow.test.ts+1| ... | ... | @@ -314,6 +314,7 @@ function makeTestLayer(input: { |
| 314 | 314 | Layer.mock(PreviewManager.PreviewManager)({ |
| 315 | 315 | getBrowserSession: () => Effect.succeed({} as Electron.Session), |
| 316 | 316 | setMainWindow: () => Effect.void, |
| 317 | prepareWebview: () => Effect.void, | |
| 317 | 318 | isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), |
| 318 | 319 | getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), |
| 319 | 320 | reapplyZoom: () => |
apps/desktop/src/window/DesktopWindow.ts+1| ... | ... | @@ -600,6 +600,7 @@ export const make = Effect.gen(function* () { |
| 600 | 600 | installContextMenu(window, window.webContents); |
| 601 | 601 | window.webContents.on("did-attach-webview", (_event, contents) => { |
| 602 | 602 | installContextMenu(window, contents); |
| 603 | void runPromise(previewManager.prepareWebview(contents)); | |
| 603 | 604 | }); |
| 604 | 605 | |
| 605 | 606 | window.webContents.setWindowOpenHandler(({ url }) => { |