diff --git a/.github/actions/setup-apt-mirrors/action.yml b/.github/actions/setup-apt-mirrors/action.yml new file mode 100644 index 000000000000..5beff201d188 --- /dev/null +++ b/.github/actions/setup-apt-mirrors/action.yml @@ -0,0 +1,22 @@ +name: Setup APT mirrors +description: Configure Ubuntu package downloads with automatic mirror failover. +runs: + using: composite + steps: + - shell: bash + run: | + # Replace the existing Blacksmith mirror list as well as direct sources. + printf '%s\tpriority:%s\n' \ + https://archive.ubuntu.com/ubuntu 1 \ + https://mirrors.edge.kernel.org/ubuntu 2 \ + https://mirror.math.princeton.edu/pub/ubuntu 3 \ + | sudo tee /etc/apt/blacksmith-ubuntu-mirrors.txt > /dev/null + + # APT's mirror transport retries each file against the next server. + sudo find /etc/apt -maxdepth 2 -type f \( -name '*.list' -o -name '*.sources' \) \ + -exec sed -i -E \ + 's#https?://(([^/]+\.)?archive|security)\.ubuntu\.com/ubuntu/?#mirror+file:/etc/apt/blacksmith-ubuntu-mirrors.txt#g' {} + + + # Move on to a fallback before an unreachable server exhausts the job. + printf '%s\n' 'Acquire::http::Timeout "15";' 'Acquire::https::Timeout "15";' \ + | sudo tee /etc/apt/apt.conf.d/80-mirror-timeouts > /dev/null diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fee5f57a83c..c6d07c653040 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,14 +45,22 @@ jobs: - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron + # Export cleanup is still a manual audit; files and dependencies have no baseline. + - name: Check unused files and dependencies + run: vp run knip:check + - name: Check run: vp check - name: Typecheck run: vpr typecheck + - uses: ./.github/actions/setup-apt-mirrors + - name: Install browser secret helper build libraries - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + run: | + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - name: Build desktop pipeline run: vp run build:desktop @@ -88,8 +96,12 @@ jobs: - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron + - uses: ./.github/actions/setup-apt-mirrors + - name: Install browser secret helper build libraries - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + run: | + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - name: Test run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 404e4e8075cc..2785ecb8fa78 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -216,6 +216,8 @@ jobs: - name: Typecheck run: vp run typecheck + - uses: ./.github/actions/setup-apt-mirrors + - name: Install browser secret helper build libraries run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config @@ -519,7 +521,7 @@ jobs: $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" $proc = Start-Process -FilePath $setupExe ` -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` - "Microsoft.VisualStudio.Component.VC.Tools.x86.x64.Spectre", "--quiet", "--norestart" ` + "Microsoft.VisualStudio.Component.VC.Runtimes.x86.x64.Spectre", "--quiet", "--norestart" ` -Wait -PassThru -NoNewWindow if ($null -eq $proc -or $proc.ExitCode -ne 0) { $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } @@ -527,6 +529,9 @@ jobs: exit $code } + - uses: ./.github/actions/setup-apt-mirrors + if: matrix.platform == 'linux' + - name: Install Linux desktop build libraries if: matrix.platform == 'linux' shell: bash diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 3acaf7154508..2ed5a1f2f913 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -43,7 +43,6 @@ describe("ElectronDialog", () => { ); assert.instanceOf(error, ElectronDialog.ElectronDialogPickFolderError); - assert.isTrue(ElectronDialog.isElectronDialogError(error)); assert.strictEqual(error.ownerWindowId, 7); assert.strictEqual(error.defaultPath, "/workspace"); assert.strictEqual(error.cause, cause); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 4300d9ab0d39..30ca73a5e143 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -73,7 +73,6 @@ export const ElectronDialogError = Schema.Union([ ElectronDialogShowErrorBoxError, ]); export type ElectronDialogError = typeof ElectronDialogError.Type; -export const isElectronDialogError = Schema.is(ElectronDialogError); export interface ElectronDialogPickFolderInput { readonly owner: Option.Option; diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts index 4b81943eff2b..b4028930af66 100644 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -64,7 +64,6 @@ describe("ElectronTheme", () => { const error = yield* Effect.flip(electronTheme.setSource("dark")); assert.instanceOf(error, ElectronTheme.ElectronThemeSetSourceError); - assert.isTrue(ElectronTheme.isElectronThemeSetSourceError(error)); assert.strictEqual(error.source, "dark"); assert.strictEqual(error.cause, cause); assert.include(error.message, "dark"); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index ef47e3d0954f..24b2d856b9d2 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -19,8 +19,6 @@ export class ElectronThemeSetSourceError extends Schema.TaggedErrorClass { const error = yield* updater.checkForUpdates.pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "beta"); assert.strictEqual(error.cause, cause); assert.equal(error.message, "Electron updater failed to check for updates on channel beta."); @@ -89,7 +88,6 @@ describe("ElectronUpdater", () => { const error = yield* updater.downloadUpdate.pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "nightly"); assert.strictEqual(error.cause, cause); assert.equal( @@ -126,7 +124,6 @@ describe("ElectronUpdater", () => { .pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterQuitAndInstallError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "alpha"); assert.equal(error.isSilent, true); assert.equal(error.isForceRunAfter, false); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 4157d29a9df8..8e044de65ad6 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -54,7 +54,6 @@ export const ElectronUpdaterError = Schema.Union([ ElectronUpdaterQuitAndInstallError, ]); export type ElectronUpdaterError = typeof ElectronUpdaterError.Type; -export const isElectronUpdaterError = Schema.is(ElectronUpdaterError); export class ElectronUpdater extends Context.Service< ElectronUpdater, diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index bebb0e5c4178..c802e595633a 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -79,7 +79,6 @@ describe("ElectronWindow", () => { const error = yield* electronWindow.create(options).pipe(Effect.flip); assert.instanceOf(error, ElectronWindow.ElectronWindowCreateError); - assert.isTrue(ElectronWindow.isElectronWindowCreateError(error)); assert.deepEqual(error.options, { title: "T3 Code", width: 1100, diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 5f6a9d34280b..9234399191cf 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -58,8 +58,6 @@ export class ElectronWindowCreateError extends Schema.TaggedErrorClass()( "ElectronWindowOperationError", { diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts index a91790200771..5827e38e406f 100644 --- a/apps/desktop/src/linuxSecretStorage.test.ts +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { normalizeLinuxPasswordStorePreference, resolveLinuxPasswordStoreSwitch, - resolveLinuxSecretStorageUnavailableMessage, } from "./linuxSecretStorage.ts"; const autoSwitch = (env: NodeJS.ProcessEnv) => @@ -124,80 +123,4 @@ describe("linuxSecretStorage", () => { }), ).toBe("gnome-libsecret"); }); - - it("uses GNOME Keyring remediation for libsecret and unknown backends", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "niri" }, - }), - ).toContain("GNOME Keyring"); - }); - - it("prefers explicit libsecret selection over KDE desktop heuristics", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "gnome-libsecret", - selectedBackend: "unknown", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("GNOME Keyring"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("GNOME Keyring"); - }); - - it("prefers explicit KWallet preference over selected gnome-libsecret backend", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "kwallet6", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "niri" }, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "kwallet", - selectedBackend: "gnome-libsecret", - env: {}, - }), - ).toContain("KWallet"); - }); - - it("uses KWallet remediation wording for KDE-looking sessions", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "kwallet6", - env: {}, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { DESKTOP_SESSION: "plasmawayland" }, - }), - ).toContain("KWallet"); - // A desktop name outranks a bare KDE marker when choosing the wording. - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { GDMSESSION: "gnome", KDE_FULL_SESSION: "true" }, - }), - ).toContain("GNOME Keyring"); - }); }); diff --git a/apps/desktop/src/linuxSecretStorage.ts b/apps/desktop/src/linuxSecretStorage.ts index fe3e21eadb92..3aa7a440d1e8 100644 --- a/apps/desktop/src/linuxSecretStorage.ts +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -25,9 +25,6 @@ const ELECTRON_KDE_DESKTOP = "KDE"; // Chromium recognizes LXQt and still selects basic text for it, so it does need a forced backend. const ELECTRON_UNPROTECTED_DESKTOPS = new Set(["LXQt"]); -const KDE_NAME_PREFIXES = ["kde", "plasma"]; -const NEGATIVE_FLAG_VALUES = new Set(["0", "false", "no", "off"]); - export function normalizeLinuxPasswordStorePreference( value: unknown, ): LinuxPasswordStorePreference { @@ -77,102 +74,6 @@ function electronSelectsProtectedBackend(env: NodeJS.ProcessEnv): boolean { return false; } -export function resolveLinuxSecretStorageUnavailableMessage(input: { - readonly configuredPreference: LinuxPasswordStorePreference; - readonly selectedBackend: string | null; - readonly env: NodeJS.ProcessEnv; -}): string { - if (input.configuredPreference === "gnome-libsecret") { - return getGnomeKeyringRemediationMessage(); - } - - if ( - input.configuredPreference === "kwallet" || - input.configuredPreference === "kwallet5" || - input.configuredPreference === "kwallet6" - ) { - return getKWalletRemediationMessage(); - } - - const backend = normalizeSelectedStorageBackend(input.selectedBackend); - if (backend === "gnome-libsecret") { - return getGnomeKeyringRemediationMessage(); - } - - if ( - backend === "kwallet" || - backend === "kwallet5" || - backend === "kwallet6" || - looksLikeKdeSession(input.env) - ) { - return getKWalletRemediationMessage(); - } - - return getGnomeKeyringRemediationMessage(); -} - -function getGnomeKeyringRemediationMessage(): string { - return "T3 Code could not access GNOME Keyring to save this environment credential. Install and start GNOME Keyring, then restart T3 Code."; -} - -function getKWalletRemediationMessage(): string { - return "T3 Code could not access KWallet to save this environment credential. Enable the KDE wallet subsystem in System Settings, then restart T3 Code."; -} - -// Advisory only: this picks between the GNOME Keyring and KWallet wording in the failure notice. It -// never decides which backend to select, so a loose match costs a user slightly wrong instructions -// rather than an unprotected credential store. -function looksLikeKdeSession(env: NodeJS.ProcessEnv): boolean { - const currentDesktopNames = nonEmptyDesktopNames(env.XDG_CURRENT_DESKTOP); - if (currentDesktopNames.length > 0) { - return currentDesktopNames.some(isKdeDesktopName); - } - - const legacyNames = legacyDesktopNames(env); - if (legacyNames.length > 0) { - return legacyNames.some(isKdeDesktopName); - } - - return isSet(env.KDE_SESSION_VERSION) || isAffirmativeFlag(env.KDE_FULL_SESSION); -} - -function isKdeDesktopName(name: string): boolean { - return KDE_NAME_PREFIXES.some((prefix) => name.startsWith(prefix)); -} - -function legacyDesktopNames(env: NodeJS.ProcessEnv): string[] { - return [env.XDG_SESSION_DESKTOP, env.DESKTOP_SESSION, env.GDMSESSION].flatMap((entry) => { - const normalized = normalizeDesktopName(entry); - return normalized ? [normalized] : []; - }); -} - -function nonEmptyDesktopNames(value: string | undefined): string[] { - return splitDesktopNameList(value).flatMap((entry) => { - const normalized = normalizeDesktopName(entry); - return normalized ? [normalized] : []; - }); -} - -function isSet(value: string | undefined): boolean { - return Boolean(value?.trim()); -} - -function isAffirmativeFlag(value: string | undefined): boolean { - const normalized = value?.trim().toLowerCase(); - return normalized ? !NEGATIVE_FLAG_VALUES.has(normalized) : false; -} - function splitDesktopNameList(value: string | undefined): string[] { return value?.split(":") ?? []; } - -function normalizeDesktopName(value: string | undefined): string | null { - const normalized = value?.trim().toLowerCase(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function normalizeSelectedStorageBackend(value: string | null): string | null { - const normalized = value?.trim().toLowerCase().replace(/_/gu, "-"); - return normalized && normalized.length > 0 ? normalized : null; -} diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 1eb65f0c7400..79c7fd1725e1 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -2060,10 +2060,30 @@ describe("PreviewManager", () => { /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, ); + // Chromium reports UnknownVizError while a hidden guest warms its + // first compositor frame, so transient failures are retried. + capturePage.mockClear(); + capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); + capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); + const retriedFiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust(1_000); + const retriedExit = yield* Fiber.join(retriedFiber); + expect(Exit.isSuccess(retriedExit)).toBe(true); + expect(capturePage).toHaveBeenCalledTimes(3); + + // A persistent failure still surfaces once the retries are spent. + capturePage.mockClear(); const captureCause = new Error("capture failed"); - capturePage.mockRejectedValueOnce(captureCause); - const exit = yield* Effect.exit(manager.captureScreenshot("tab_1")); + capturePage.mockRejectedValue(captureCause); + const failingFiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust(1_000); + const exit = yield* Fiber.join(failingFiber); expect(Exit.isFailure(exit)).toBe(true); + expect(capturePage).toHaveBeenCalledTimes(3); if (Exit.isSuccess(exit)) return; const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); expect(error).toMatchObject({ @@ -2397,6 +2417,127 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("stops capture retries when the tab swaps during the retry delay", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => ({ + toPNG: () => Buffer.from("png"), + toJPEG: () => Buffer.from("jpeg"), + getSize: () => ({ width: 100, height: 80 }), + })); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42)); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + + capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); + const fiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + // Let the rejection schedule its retry before replacing the guest. + yield* TestClock.adjust(60); + expect(capturePage).toHaveBeenCalledTimes(1); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 43)); + yield* manager.registerWebview("tab_1", 43); + yield* TestClock.adjust(1_000); + const exit = yield* Fiber.join(fiber); + + expect(Exit.isFailure(exit)).toBe(true); + expect(capturePage).toHaveBeenCalledTimes(1); + expect(writeFile).not.toHaveBeenCalled(); + }), + ), + ); + + effectIt.effect("discards a screenshot that resolves after its guest is replaced", () => + withManager((manager) => + Effect.gen(function* () { + const image = { + toPNG: () => Buffer.from("stale-png"), + toJPEG: () => Buffer.from("stale-jpeg"), + getSize: () => ({ width: 100, height: 80 }), + }; + const pending = Promise.withResolvers(); + const capturePage = vi.fn(() => pending.promise); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42)); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + + const fiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust(0); + expect(capturePage).toHaveBeenCalledOnce(); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 43)); + yield* manager.registerWebview("tab_1", 43); + pending.resolve(image); + const exit = yield* Fiber.join(fiber); + + expect(Exit.isFailure(exit)).toBe(true); + expect(capturePage).toHaveBeenCalledOnce(); + expect(writeFile).not.toHaveBeenCalled(); + }), + ), + ); + + effectIt.effect("releases snapshot control when every capture attempt stalls", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(() => new Promise(() => {})); + const wc = makeTestPreviewWebContents(capturePage); + Object.assign(wc, { isDevToolsOpened: () => false }); + Object.assign(wc.debugger, { + sendCommand: vi.fn(async (method: string, params?: Record) => { + if (method === "Runtime.evaluate") { + return { + result: { + value: + params?.["expression"] === "42" + ? 42 + : { + url: "https://example.com", + title: "Example", + loading: false, + visibleText: "Example", + interactiveElements: [], + }, + }, + }; + } + return method === "Accessibility.getFullAXTree" ? { nodes: [] } : undefined; + }), + }); + fromId.mockReturnValue(wc); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + + const snapshot = yield* Effect.exit(manager.automationSnapshot("tab_1")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust(100); + expect(capturePage).toHaveBeenCalledOnce(); + const evaluate = yield* manager + .automationEvaluate("tab_1", { expression: "42" }) + .pipe(Effect.forkChild({ startImmediately: true })); + expect(evaluate.pollUnsafe()).toBeUndefined(); + + yield* TestClock.adjust(4_000); + const exit = yield* Fiber.join(snapshot); + expect(Exit.isFailure(exit)).toBe(true); + expect(capturePage).toHaveBeenCalledTimes(3); + if (Exit.isSuccess(exit)) return; + const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); + expect(error).toMatchObject({ + _tag: "PreviewOperationError", + operation: "automationSnapshot.capturePage", + tabId: "tab_1", + webContentsId: 42, + cause: { _tag: "TimeoutError" }, + }); + expect(yield* Fiber.join(evaluate)).toBe(42); + }), + ), + ); + effectIt.effect("grants each concurrent preview recording its own tab frame", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index aa149bc9ee1d..900ba5fe983c 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -48,6 +48,7 @@ import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; import * as SynchronizedRef from "effect/SynchronizedRef"; @@ -113,6 +114,13 @@ const MAX_SCREENSHOT_WIDTH = 1280; const RECORDING_ARM_GRACE_MS = 10_000; const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; +/** + * Cold guests can reject capturePage with UnknownVizError or never settle it. + * Bound each attempt so snapshots release control even when Chromium stalls. + */ +const CAPTURE_PAGE_RETRY_ATTEMPTS = 3; +const CAPTURE_PAGE_RETRY_DELAY_MS = 120; +const CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS = 1_000; const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480; const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320; const PICTURE_IN_PICTURE_MIN_WIDTH = 240; @@ -639,6 +647,42 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function try: evaluate, catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), }); + const capturePageWithRetry = Effect.fn("PreviewManager.capturePageWithRetry")(function* ( + errorContext: PreviewOperationContext, + tabId: string, + wc: Electron.WebContents, + ) { + const requireCurrentGuest = Effect.gen(function* () { + const tabs = yield* SynchronizedRef.get(tabsRef); + if (wc.isDestroyed() || tabs.get(tabId)?.webContentsId !== wc.id) { + return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId: wc.id }); + } + }); + const capture = Effect.gen(function* () { + // Check after the retry delay, and again before accepting its result. + yield* requireCurrentGuest; + const image = yield* Effect.tryPromise({ + // An abort-signal parameter makes a stalled promise interruptible. + try: (_signal) => wc.capturePage(), + catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), + }).pipe( + Effect.timeout(CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail(new PreviewOperationError({ ...errorContext, cause })), + }), + ); + yield* requireCurrentGuest; + return image; + }); + return yield* capture.pipe( + Effect.retry({ + times: CAPTURE_PAGE_RETRY_ATTEMPTS - 1, + schedule: Schedule.spaced(CAPTURE_PAGE_RETRY_DELAY_MS), + while: isPreviewOperationError, + }), + ); + }); const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); const currentMillis = Clock.currentTimeMillis; const encodeJson = (errorContext: PreviewOperationContext, value: unknown) => @@ -2648,13 +2692,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const [createdAt, millis, image] = yield* Effect.all([ currentIso, currentMillis, - attemptPromise( + capturePageWithRetry( { operation: "captureScreenshot.capturePage", tabId, webContentsId: wc.id, }, - () => wc.capturePage(), + tabId, + wc, ), ]); const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}`; @@ -3513,13 +3558,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([ send("Accessibility.getFullAXTree"), - attemptPromise( + capturePageWithRetry( { operation: "automationSnapshot.capturePage", tabId, webContentsId: wc.id, }, - () => wc.capturePage(), + tabId, + wc, ), Ref.get(diagnosticsRef), Ref.get(actionTimelineRef), diff --git a/apps/marketing/src/lib/homeMotion.test.ts b/apps/marketing/src/lib/homeMotion.test.ts new file mode 100644 index 000000000000..42f7775a0157 --- /dev/null +++ b/apps/marketing/src/lib/homeMotion.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { startHomeMotion } from "./homeMotion"; + +class ElementStub extends EventTarget { + properties = new Map(); + style = { setProperty: (name: string, value: string) => this.properties.set(name, value) }; + children: ElementStub[] = []; + scrollLeft = 0; + scrollWidth = 1_200; + clientWidth = 400; + matches = () => false; + contains = (target: EventTarget | null) => + target === this || (target instanceof ElementStub && this.children.includes(target)); + querySelectorAll = () => this.children; + getBoundingClientRect = vi.fn(() => ({ left: 0, top: 0, width: 400, height: 600 })); + scrollTo = vi.fn((options: ScrollToOptions) => { + this.scrollLeft = options.left ?? this.scrollLeft; + }); +} + +let observers: ObserverStub[] = []; +class ObserverStub { + constructor(private readonly callback: IntersectionObserverCallback) { + observers.push(this); + } + observe = vi.fn(); + disconnect = vi.fn(); + report(target: ElementStub, isIntersecting: boolean) { + this.callback( + [{ target, isIntersecting } as unknown as IntersectionObserverEntry], + this as unknown as IntersectionObserver, + ); + } +} + +let page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null }); +let viewport = new EventTarget(); +let reduced = Object.assign(new EventTarget(), { matches: false }); +let fine = Object.assign(new EventTarget(), { matches: true }); +let frames = new Map(); +let dispose: (() => void) | undefined; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + observers = []; + frames = new Map(); + page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null }); + viewport = new EventTarget(); + reduced = Object.assign(new EventTarget(), { matches: false }); + fine = Object.assign(new EventTarget(), { matches: true }); + vi.stubGlobal("document", page); + vi.stubGlobal( + "window", + Object.assign(viewport, { + matchMedia: (query: string) => (query.includes("reduced-motion") ? reduced : fine), + }), + ); + vi.stubGlobal("Node", ElementStub); + vi.stubGlobal("IntersectionObserver", ObserverStub); + let frameId = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++frameId, callback); + return frameId; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)); +}); + +afterEach(() => { + dispose?.(); + dispose = undefined; + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +function fixture() { + const hero = new ElementStub(); + const field = new ElementStub(); + const mark = new ElementStub(); + const otherMark = new ElementStub(); + field.children = [mark, otherMark]; + const endorsements = new ElementStub(); + const caret = new ElementStub(); + dispose = startHomeMotion({ hero, field, endorsements, caret } as unknown as Parameters< + typeof startHomeMotion + >[0]); + return { hero, field, mark, otherMark, endorsements, caret, observer: observers[0]! }; +} + +function movePointer(hero: ElementStub, x = 400, y = 600) { + hero.dispatchEvent(Object.assign(new Event("pointermove"), { clientX: x, clientY: y })); +} + +describe("homepage motion", () => { + it("gates each mark and caret and batches pointer input into one frame", () => { + const { hero, field, mark, otherMark, caret, observer } = fixture(); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + observer.report(mark, true); + observer.report(caret, true); + expect(mark.properties.get("--home-motion-state")).toBe("running"); + expect(otherMark.properties.get("--home-motion-state")).toBe("paused"); + expect(caret.properties.get("--home-motion-state")).toBe("running"); + + movePointer(hero, 100, 100); + movePointer(hero); + expect(frames.size).toBe(1); + expect(hero.getBoundingClientRect).not.toHaveBeenCalled(); + const [id, callback] = [...frames][0]!; + frames.delete(id); + callback(0); + expect(field.properties.get("--px")).toBe("18.0px"); + expect(field.properties.get("--py")).toBe("14.0px"); + + movePointer(hero); + page.visibilityState = "hidden"; + page.dispatchEvent(new Event("visibilitychange")); + expect(frames.size).toBe(0); + expect(field.properties.get("--px")).toBe("0px"); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + expect(caret.properties.get("--home-motion-state")).toBe("paused"); + page.visibilityState = "visible"; + page.dispatchEvent(new Event("visibilitychange")); + reduced.matches = true; + reduced.dispatchEvent(new Event("change")); + movePointer(hero); + expect(frames.size).toBe(0); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + reduced.matches = false; + fine.matches = false; + reduced.dispatchEvent(new Event("change")); + movePointer(hero); + expect(frames.size).toBe(0); + expect(mark.properties.get("--home-motion-state")).toBe("running"); + }); + + it("pages every eight seconds, reverses at the end, and has no timer without overflow", () => { + const { endorsements, observer } = fixture(); + expect(vi.getTimerCount()).toBe(0); + observer.report(endorsements, true); + vi.advanceTimersByTime(7_999); + expect(endorsements.scrollTo).not.toHaveBeenCalled(); + vi.advanceTimersByTime(16_001); + expect(endorsements.scrollTo.mock.calls.map(([options]) => options.left)).toEqual([ + 400, 800, 400, + ]); + expect( + endorsements.scrollTo.mock.calls.every(([options]) => options.behavior === "smooth"), + ).toBe(true); + + endorsements.clientWidth = endorsements.scrollWidth; + viewport.dispatchEvent(new Event("resize")); + expect(vi.getTimerCount()).toBe(0); + expect(endorsements.scrollTo).toHaveBeenLastCalledWith({ left: 400, behavior: "instant" }); + endorsements.clientWidth = 400; + viewport.dispatchEvent(new Event("resize")); + expect(vi.getTimerCount()).toBe(1); + }); + + it("pauses paging for hover, focus, hidden content, and reduced motion", () => { + const { endorsements, observer } = fixture(); + observer.report(endorsements, true); + const changeVisibility = (visible: boolean) => { + page.visibilityState = visible ? "visible" : "hidden"; + page.dispatchEvent(new Event("visibilitychange")); + }; + const changeMotion = (matches: boolean) => { + reduced.matches = matches; + reduced.dispatchEvent(new Event("change")); + }; + const pauses = [ + [ + () => endorsements.dispatchEvent(new Event("pointerenter")), + () => endorsements.dispatchEvent(new Event("pointerleave")), + ], + [ + () => endorsements.dispatchEvent(new Event("focusin")), + () => + endorsements.dispatchEvent(Object.assign(new Event("focusout"), { relatedTarget: null })), + ], + [() => changeVisibility(false), () => changeVisibility(true)], + [() => observer.report(endorsements, false), () => observer.report(endorsements, true)], + [() => changeMotion(true), () => changeMotion(false)], + ] as const; + for (const [pause, resume] of pauses) { + pause(); + expect(vi.getTimerCount()).toBe(0); + vi.advanceTimersByTime(16_000); + resume(); + expect(vi.getTimerCount()).toBe(1); + } + expect(endorsements.scrollTo).not.toHaveBeenCalled(); + vi.advanceTimersByTime(8_000); + expect(endorsements.scrollTo).toHaveBeenCalledWith({ left: 400, behavior: "smooth" }); + endorsements.dispatchEvent(new Event("pointerenter")); + expect(endorsements.scrollTo).toHaveBeenLastCalledWith({ left: 400, behavior: "instant" }); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(["wheel", "pointerdown", "keydown"])("hands control to the user after %s", (event) => { + const { endorsements, observer } = fixture(); + observer.report(endorsements, true); + endorsements.dispatchEvent(new Event(event)); + observer.report(endorsements, false); + observer.report(endorsements, true); + endorsements.dispatchEvent(new Event("pointerleave")); + viewport.dispatchEvent(new Event("resize")); + vi.advanceTimersByTime(60_000); + expect(vi.getTimerCount()).toBe(0); + expect(endorsements.scrollTo).not.toHaveBeenCalled(); + }); + + it("cancels pending work and ignores events after cleanup", () => { + const { hero, mark, endorsements, observer } = fixture(); + observer.report(mark, true); + observer.report(endorsements, true); + movePointer(hero); + dispose?.(); + observer.report(mark, true); + movePointer(hero); + reduced.dispatchEvent(new Event("change")); + expect(observer.disconnect).toHaveBeenCalledTimes(1); + expect(frames.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + }); +}); diff --git a/apps/marketing/src/lib/homeMotion.ts b/apps/marketing/src/lib/homeMotion.ts new file mode 100644 index 000000000000..5322eae4406d --- /dev/null +++ b/apps/marketing/src/lib/homeMotion.ts @@ -0,0 +1,176 @@ +/** Runs homepage motion only while its content is visible. Manual scrolling stops paging. */ +export function startHomeMotion({ + hero, + field, + endorsements, + caret, +}: { + hero: HTMLElement; + field: HTMLElement; + endorsements: HTMLElement; + caret: HTMLElement; +}) { + if (typeof IntersectionObserver === "undefined") return () => {}; + + const marks = Array.from(field.querySelectorAll(".hero-float-mark")); + const visible = new Set(); + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + const finePointer = window.matchMedia("(pointer: fine)"); + const events = new AbortController(); + const eventOptions = { signal: events.signal }; + let disposed = false; + let hovered = endorsements.matches(":hover"); + let focused = endorsements.contains(document.activeElement); + let userControlled = false; + let direction = 1; + let automaticScroll = false; + let pageTimer: ReturnType | undefined; + let pointerFrame: number | undefined; + let pointer: { x: number; y: number } | null = null; + + const canMove = (element: Element) => + !disposed && + visible.has(element) && + document.visibilityState === "visible" && + !reducedMotion.matches; + const canParallax = () => finePointer.matches && marks.some(canMove); + const canPage = () => + canMove(endorsements) && + !hovered && + !focused && + !userControlled && + endorsements.scrollWidth > endorsements.clientWidth; + + function resetPointer() { + if (pointerFrame !== undefined) cancelAnimationFrame(pointerFrame); + pointerFrame = undefined; + pointer = null; + field.style.setProperty("--px", "0px"); + field.style.setProperty("--py", "0px"); + } + + function updatePaging() { + if (canPage()) { + pageTimer ??= setTimeout(advancePage, 8_000); + return; + } + if (pageTimer !== undefined) clearTimeout(pageTimer); + pageTimer = undefined; + if (automaticScroll) { + automaticScroll = false; + endorsements.scrollTo({ left: endorsements.scrollLeft, behavior: "instant" }); + } + } + + function advancePage() { + pageTimer = undefined; + if (!canPage()) return; + const end = endorsements.scrollWidth - endorsements.clientWidth; + const current = endorsements.scrollLeft; + if (current >= end - 1) direction = -1; + else if (current <= 1) direction = 1; + automaticScroll = true; + endorsements.scrollTo({ + left: Math.max(0, Math.min(end, current + direction * endorsements.clientWidth)), + behavior: "smooth", + }); + updatePaging(); + } + + function update() { + for (const mark of marks) { + mark.style.setProperty("--home-motion-state", canMove(mark) ? "running" : "paused"); + } + caret.style.setProperty("--home-motion-state", canMove(caret) ? "running" : "paused"); + const parallax = canParallax(); + field.style.setProperty("--parallax-duration", parallax ? "0.7s" : "0s"); + if (!parallax) resetPointer(); + updatePaging(); + } + + const observer = new IntersectionObserver((entries) => { + if (disposed) return; + for (const entry of entries) { + if (entry.isIntersecting) visible.add(entry.target); + else visible.delete(entry.target); + } + update(); + }); + for (const element of [...marks, endorsements, caret]) observer.observe(element); + + hero.addEventListener( + "pointermove", + (event) => { + if (!canParallax()) return; + pointer = { x: event.clientX, y: event.clientY }; + pointerFrame ??= requestAnimationFrame(() => { + pointerFrame = undefined; + if (!pointer || !canParallax()) return; + const bounds = hero.getBoundingClientRect(); + if (bounds.width === 0 || bounds.height === 0) return; + field.style.setProperty( + "--px", + `${(((pointer.x - bounds.left) / bounds.width - 0.5) * 36).toFixed(1)}px`, + ); + field.style.setProperty( + "--py", + `${(((pointer.y - bounds.top) / bounds.height - 0.5) * 28).toFixed(1)}px`, + ); + }); + }, + eventOptions, + ); + hero.addEventListener("pointerleave", resetPointer, eventOptions); + endorsements.addEventListener( + "pointerenter", + () => { + hovered = true; + updatePaging(); + }, + eventOptions, + ); + endorsements.addEventListener( + "pointerleave", + () => { + hovered = false; + updatePaging(); + }, + eventOptions, + ); + endorsements.addEventListener( + "focusin", + () => { + focused = true; + updatePaging(); + }, + eventOptions, + ); + endorsements.addEventListener( + "focusout", + (event) => { + focused = event.relatedTarget instanceof Node && endorsements.contains(event.relatedTarget); + updatePaging(); + }, + eventOptions, + ); + const takeControl = () => { + userControlled = true; + updatePaging(); + }; + endorsements.addEventListener("wheel", takeControl, { ...eventOptions, passive: true }); + endorsements.addEventListener("pointerdown", takeControl, eventOptions); + endorsements.addEventListener("keydown", takeControl, eventOptions); + document.addEventListener("visibilitychange", update, eventOptions); + window.addEventListener("resize", update, eventOptions); + reducedMotion.addEventListener("change", update, eventOptions); + finePointer.addEventListener("change", update, eventOptions); + update(); + + return () => { + if (disposed) return; + disposed = true; + events.abort(); + observer.disconnect(); + update(); + }; +} diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index cd28b446ccf1..723ad4d4324b 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -355,6 +355,7 @@ const screenshot = await getImage({