diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index fd83d0dcf6f1b..7056ea06f74dc 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -2759,6 +2759,51 @@ it.live.each( ).pipe(Effect.provide(layer)), ); +it.effect("published root viewport reads stay ordered during a resize burst", () => + Effect.scoped( + Effect.gen(function* () { + desktopRendersNext = true; + const { browser, tabId } = yield* ready; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + const cdp = desktopConnections[0]!.context.sessions[0]!; + yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); + yield* Queue.takeAll(viewer.output); + const reads = yield* Queue.unbounded>>(); + const send = cdp.send.getMockImplementation()!; + cdp.send.mockImplementation(async (method, input) => { + if ( + method === "Page.getLayoutMetrics" || + (method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 9) + ) { + const read = Promise.withResolvers>(); + Queue.offerUnsafe(reads, read); + return read.promise; + } + return send(method, input); + }); + const answer = (width: number, height: number) => ({ + cssVisualViewport: { clientWidth: width, clientHeight: height }, + result: { value: { width, height } }, + }); + const first = cdp.emitAsync("Page.frameResized"); + const firstRead = yield* Queue.take(reads); + const burst = Promise.all([ + cdp.emitAsync("Page.frameResized"), + cdp.emitAsync("Page.frameResized"), + ]); + firstRead.resolve(answer(1000, 750)); + (yield* Queue.take(reads)).resolve(answer(1250, 938)); + expect(yield* Queue.size(reads)).toBe(0); + yield* Effect.promise(() => Promise.all([first, burst])); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual([ + "1000x750", + "1250x938", + "1250x938", + ]); + }), + ).pipe(Effect.provide(layer)), +); + it.live("published root zoom keeps measured viewport, pointer and snapshot scale coherent", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 295065c2484bf..8fa1aa3b37341 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -997,10 +997,7 @@ const make = Effect.gen(function* () { }; const broadcastViewport = async (tab: ServerTab) => { - const size = - tab.backingPage === "desktop-root" - ? await ServerBrowserPage.viewportSize(tab.page, tab.cdp) - : await layoutSize(tab); + const size = await layoutSize(tab); if (!size || tab.closing) return; for (const viewer of tab.viewers) viewer.push({ _tag: "viewport", ...size }); }; diff --git a/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx b/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx index f965ba0ca2ab9..3d1c7a7fa07f0 100644 --- a/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx @@ -79,6 +79,38 @@ async function renderSurface(controller: PreviewStreamControl["controller"]) { const keys = () => mocks.sent.filter((input) => input.type === "key"); +it("maps streamed clicks at 80 percent panel zoom into the page CSS viewport", async () => { + setup(); + const { screen, input, canvas } = await renderSurface("you"); + await using _screen = screen; + const surface = input.closest("[data-server-browser-surface]")!; + surface.style.zoom = "0.8"; + canvas.width = 1600; + canvas.height = 1200; + canvas.style.width = "400px"; + canvas.style.height = "300px"; + canvas.setPointerCapture = () => undefined; + const handlers = mocks.createClient.mock.calls.at(-1)![1]; + await act(() => { + mocks.painted?.(); + handlers.onViewport({ width: 1000, height: 750 }); + }); + const rect = canvas.getBoundingClientRect(); + canvas.dispatchEvent( + new PointerEvent("pointerdown", { + button: 0, + buttons: 1, + pointerType: "mouse", + bubbles: true, + clientX: rect.left + rect.width * 0.4, + clientY: rect.top + rect.height * 0.4, + }), + ); + expect(mocks.sent.filter((message) => message.type === "mouse")).toContainEqual( + expect.objectContaining({ action: "down", x: 400, y: 300 }), + ); +}); + describe("ServerBrowserSurface clipboard commands", () => { it("sends a native Copy or Cut menu command to the page once", async () => { setup(); diff --git a/apps/web/src/browser/openFileInPreview.test.ts b/apps/web/src/browser/openFileInPreview.test.ts index b0cb6d10d29a2..93c62afe2260a 100644 --- a/apps/web/src/browser/openFileInPreview.test.ts +++ b/apps/web/src/browser/openFileInPreview.test.ts @@ -1,3 +1,4 @@ +import { setPreviewBootstrapTokenOnUrl } from "@t3tools/shared/remote"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, @@ -215,6 +216,29 @@ describe("managed app links", () => { }, ); + it("opens a managed app directly on its local desktop with renewed navigation access", async () => { + mocks.desktopHost.mockReturnValue("local"); + const url = "http://127.0.0.1:5173/app?q=1#section"; + const navigationUrl = setPreviewBootstrapTokenOnUrl(new URL(url), "fresh").href; + mocks.prepareHostedPreview.mockResolvedValue({ + url, + localUrl: url, + navigationUrl, + managed: true, + restored: true, + }); + const openPreview = vi.fn(async (_input: { input: PreviewOpenInput }) => + AsyncResult.success(snapshot), + ); + await openUrlInPreview({ threadRef, url, openPreview }); + expect(openPreview.mock.calls[0]?.[0].input).toMatchObject({ + url: navigationUrl, + runtime: "server", + desktopHostId: "local", + }); + expect(openPreview.mock.calls[0]?.[0].input).not.toHaveProperty("browserHost"); + }); + it("preserves an explicitly selected desktop profile for a managed app", async () => { mocks.desktopHost.mockReturnValue("remote-desktop"); const url = "http://workstation.example:5173/sign-in"; diff --git a/apps/web/src/browser/preparedPreviewOpenTarget.ts b/apps/web/src/browser/preparedPreviewOpenTarget.ts index e9a67224ce5d8..05287268b92b5 100644 --- a/apps/web/src/browser/preparedPreviewOpenTarget.ts +++ b/apps/web/src/browser/preparedPreviewOpenTarget.ts @@ -39,7 +39,8 @@ export function preparedPreviewOpenTarget( prepared.managed && prepared.localUrl !== undefined && (profileId === undefined || isBuiltInBrowserProfileId(profileId)) && - readEnvironmentSupportsServerBrowser(environmentId)) + readEnvironmentSupportsServerBrowser(environmentId) && + desktopBrowserHostFor(environmentId) !== "local") ) { return { url: hostedPreviewNavigationUrl(prepared, prepared.localUrl ?? authoredUrl), diff --git a/apps/web/src/browser/previewRuntime.test.ts b/apps/web/src/browser/previewRuntime.test.ts index dca0301cbf124..6a294d347b3bd 100644 --- a/apps/web/src/browser/previewRuntime.test.ts +++ b/apps/web/src/browser/previewRuntime.test.ts @@ -32,6 +32,8 @@ import { rendersServerTabNatively, } from "./previewRuntime"; +import { preparedPreviewOpenTarget } from "./preparedPreviewOpenTarget"; + const primary = EnvironmentId.make("primary"); const remote = EnvironmentId.make("remote"); @@ -211,3 +213,27 @@ describe("runtime and native ownership boundaries", () => { ).toBe(false); }); }); + +describe("managed preview locality", () => { + it("uses the desktop only for a confirmed local host and honors explicit streaming", () => { + const url = "http://127.0.0.1:8123/app"; + const prepared = { url, localUrl: url, managed: true, restored: true }; + expect(preparedPreviewOpenTarget(primary, prepared, url, "default")).toEqual({ + url, + runtime: "server", + desktopHostId: "local", + }); + const streamed = { url, runtime: "server", browserHost: "environment" }; + expect(preparedPreviewOpenTarget(primary, prepared, url, "default", "server")).toEqual( + streamed, + ); + expect(preparedPreviewOpenTarget(remote, prepared, url, "default")).toEqual(streamed); + for (const localDesktopBrowser of [false, undefined]) { + state.localDesktopBrowser = localDesktopBrowser; + expect(preparedPreviewOpenTarget(primary, prepared, url, "default")).toEqual(streamed); + } + state.localDesktopBrowser = true; + state.electron = false; + expect(preparedPreviewOpenTarget(primary, prepared, url, "default")).toEqual(streamed); + }); +}); diff --git a/apps/web/src/handoffs/useOpenHandoff.test.ts b/apps/web/src/handoffs/useOpenHandoff.test.ts index aef6d40dbb1c4..cf4b17ce8be7e 100644 --- a/apps/web/src/handoffs/useOpenHandoff.test.ts +++ b/apps/web/src/handoffs/useOpenHandoff.test.ts @@ -350,12 +350,13 @@ describe("opening a saved handoff", () => { expect(readThreadHandoffs(remote)).toHaveLength(1); }); it("leaves a failed desktop app tab intact and opens its handoff in the owning environment", async () => { + const remote = { ...ref, environmentId: EnvironmentId.make("remote-environment") }; const ops = operations(); const url = "http://workstation.example:8123/app?q=1#section"; const localUrl = "http://127.0.0.1:8123/app?q=1#section"; native.bridge = { navigate: vi.fn(async () => undefined) }; hosting.prepare.mockResolvedValue({ url, localUrl, managed: true, restored: true }); - const entry = recordHandoff(ref, { kind: "url", url }); + const entry = recordHandoff(remote, { kind: "url", url }); const failed: PreviewSessionSnapshot = { ...snapshot(url, "desktop-tab"), profileId: "default", @@ -364,8 +365,8 @@ describe("opening a saved handoff", () => { desktopHostId: "local", navStatus: { _tag: "LoadFailed", url, title: "", code: -102, description: "Unreachable" }, }; - applyPreviewServerSnapshot(ref, failed); - rememberHandoffBrowser(ref, failed.tabId, entry.target, url); + applyPreviewServerSnapshot(remote, failed); + rememberHandoffBrowser(remote, failed.tabId, entry.target, url); ops.openPreview.mockImplementation(async ({ input }) => AsyncResult.success({ ...snapshot(input.url ?? "", "environment-tab"), @@ -374,20 +375,56 @@ describe("opening a saved handoff", () => { }), ); - await openHandoff(ref, entry, ops); + await openHandoff(remote, entry, ops); expect(ops.openPreview.mock.calls[0]?.[0].input).toMatchObject({ url: localUrl, runtime: "server", browserHost: "environment", }); expect(native.bridge.navigate).not.toHaveBeenCalled(); - expect(panel().activeSurfaceId).toBe("browser:environment-tab"); - expect(readThreadPreviewState(ref).sessions[failed.tabId]).toEqual(failed); - await openHandoff(ref, entry, ops); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, remote) + .activeSurfaceId, + ).toBe("browser:environment-tab"); + expect(readThreadPreviewState(remote).sessions[failed.tabId]).toEqual(failed); + await openHandoff(remote, entry, ops); expect(ops.openPreview).toHaveBeenCalledTimes(1); - expect(panel().activeSurfaceId).toBe("browser:environment-tab"); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, remote) + .activeSurfaceId, + ).toBe("browser:environment-tab"); }); + it.each(["server", "desktop"] as const)( + "restores a local managed handoff in its retained %s tab", + async (backingPage) => { + const ops = operations(); + const url = "http://127.0.0.1:8123/app"; + native.bridge = { navigate: vi.fn(async () => undefined) }; + hosting.prepare.mockResolvedValue({ + url, + localUrl: url, + managed: true, + restored: true, + restarted: true, + }); + const entry = recordHandoff(ref, { kind: "url", url }); + applyPreviewServerSnapshot(ref, { + ...snapshot(url), + profileId: "default", + runtime: "server", + backingPage, + ...(backingPage === "desktop" ? { desktopHostId: "local" } : {}), + }); + await openHandoff(ref, entry, ops); + expect(ops.openPreview).not.toHaveBeenCalled(); + expect(panel().activeSurfaceId).toBe("browser:tab"); + if (backingPage === "desktop") + expect(native.bridge.navigate).toHaveBeenCalledWith(expect.any(String), url); + else expect(refresh.request).toHaveBeenCalledWith(ref, "tab", url); + }, + ); + it("keeps a managed handoff in its existing named desktop profile", async () => { const ops = operations(); const url = "http://workstation.example:8123/account"; diff --git a/docs/lastcode/preview-handoffs.md b/docs/lastcode/preview-handoffs.md index c813a8bf2a438..4df365447d429 100644 --- a/docs/lastcode/preview-handoffs.md +++ b/docs/lastcode/preview-handoffs.md @@ -140,10 +140,16 @@ An existing server cannot be adopted merely by remembering its URL: relaunch it through `preview_host` to establish native ownership. One managed listener owns a local origin; use a distinct port for another preview. Native browser recovery uses the owning environment and thread, never an unrelated thread or public -website. In the integrated Browser, managed app links use an environment-hosted -page and the saved listener's local address, so a loopback-only app does not -depend on desktop access to its port. An explicitly selected desktop profile -keeps its desktop identity and requires a reachable browser destination. Native +website. In the integrated Browser, how a managed app link opens in the default +or incognito profile depends on the environment. On a verified local host (the +primary desktop with inherited IPC), it opens as the usual direct desktop page. +For a remote, unknown, or non-desktop environment, it opens as a streamed +environment-hosted page at the saved listener's local address, so a +loopback-only app does not depend on desktop access to its port. An explicit +server runtime request also uses the environment page. Reopening a handoff keeps +a healthy streamed tab as it is rather than migrating it, which preserves page +state. An explicitly selected desktop profile keeps its desktop identity and +requires a reachable browser destination. Native mobile opens managed app links in the environment Browser viewer too. External browsers still need a reachable listener. Managed images, audio, video, and PDFs use signed environment-hosted resource URLs, so their clients do not need access