From 61695d15bdf42944beea32e9a91d2b551f43014b Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:46:43 -0600 Subject: [PATCH] fix(gui-plugins): explicit machines for Keep screen active and the WSL add dialog (#62108) --- packages/app/component-tests/pairing.spec.ts | 26 ++ packages/gui-plugins/README.md | 36 +- .../gui-plugins/src/pairing/dispatch.test.ts | 2 +- .../gui-plugins/src/pairing/machine.test.ts | 295 ++++++++++++---- packages/gui-plugins/src/pairing/machine.ts | 254 +++++++++++++- .../gui-plugins/src/pairing/page.stories.tsx | 5 +- packages/gui-plugins/src/pairing/page.tsx | 72 ++-- .../src/wsl/dialog-machine.test.ts | 299 ++++++++++++++++ .../gui-plugins/src/wsl/dialog-machine.ts | 322 ++++++++++++++++++ packages/gui-plugins/src/wsl/dialog.tsx | 201 ++++++----- packages/gui-plugins/src/wsl/model.ts | 62 ++-- .../gui-plugins/src/wsl/probe-machine.test.ts | 1 - packages/gui-plugins/src/wsl/probe-machine.ts | 2 +- packages/gui-plugins/src/wsl/probes.ts | 3 +- .../stories/mocks/gui-plugins/pairing.tsx | 20 +- 15 files changed, 1353 insertions(+), 247 deletions(-) create mode 100644 packages/app/component-tests/pairing.spec.ts create mode 100644 packages/gui-plugins/src/wsl/dialog-machine.test.ts create mode 100644 packages/gui-plugins/src/wsl/dialog-machine.ts diff --git a/packages/app/component-tests/pairing.spec.ts b/packages/app/component-tests/pairing.spec.ts new file mode 100644 index 000000000000..6123a645215f --- /dev/null +++ b/packages/app/component-tests/pairing.spec.ts @@ -0,0 +1,26 @@ +import { expect, story } from "../../stories/playwright/story" + +story("a Keep screen active read that fails offers Try again and keeps the switch enabled", async ({ mount, page }) => { + // The page reads three more times, 1, 2 and 4 seconds apart, before it shows the failure. + await page.clock.install() + const component = await mount("plugins-pairing--screen-setting-fails") + const toggle = component.getByRole("switch", { name: "Keep screen active", exact: true }) + const error = component.getByRole("alert").filter({ hasText: "Could not update the screen activity setting." }) + const retry = component.getByRole("button", { name: "Try again", exact: true }) + + await expect(toggle).toBeDisabled() + await page.clock.runFor(8_000) + await expect(error).toBeVisible() + await expect(toggle).toBeEnabled() + await expect(toggle).not.toBeChecked() + + await retry.click() + await expect(error).toHaveCount(0) + await expect(retry).toHaveCount(0) + await expect(toggle).toBeEnabled() + + await toggle.focus() + await toggle.press("Space") + await expect(toggle).toBeChecked() + await expect(toggle).toBeEnabled() +}) diff --git a/packages/gui-plugins/README.md b/packages/gui-plugins/README.md index c4a7378bda0a..50f88b90e5ca 100644 --- a/packages/gui-plugins/README.md +++ b/packages/gui-plugins/README.md @@ -213,23 +213,35 @@ const Page = lazy(() => import("./page")) onCleanup(onIdle(() => void Page.preload())) ``` -Pairing's shipping page uses a query, not `createLatest`. It passes the query's abort signal directly to the no-input Ipc method; options are the only argument. The generation in the key asks again when main comes back: +Pairing's shipping page reads through its state machine's driver, not `createLatest`, because a read can retry, time out and race a change of the same setting. Each read or change is an attempt of one generation of main: the driver passes the attempt's abort signal directly to the Ipc method, and every outcome, a timeout included, goes back to the machine, which ignores answers for an attempt it no longer runs. A new generation reads again: - + ```tsx -const screenActive = useQuery(() => ({ - queryKey: [ctx.id, "screen-active", main()?.generation], - queryFn: (input) => { - const live = main() +// Reads or changes the display sleep setting through the generation that asked for it. `screenActive` has no input, +// so its options are its only argument. The attempt's signal ends either call at its timeout, or when the page or +// that generation goes away. +const runScreen = (effect: Extract) => { + const attempt = effect.attempt - if (!live) throw new Error("Pairing's main side is not active") + const fail = () => + dispatch({ type: effect.type === "read-screen" ? "screen.read.failed" : "screen.change.failed", attempt }) - return live.value.screenActive({ signal: input.signal }) - }, - enabled: !!main(), - gcTime: 0, -})) + const live = main() + + if (live?.generation !== effect.generation) return fail() + + const signal = start(attempt, effect.timeoutMs, fail) + + if (effect.type === "read-screen") + return void live.value + .screenActive({ signal }) + .then((enabled) => dispatch({ type: "screen.read.succeeded", attempt, enabled }), fail) + + void live.value + .setScreenActive(effect.enabled, { signal }) + .then(() => dispatch({ type: "screen.change.succeeded", attempt }), fail) +} ``` ## Catalog diff --git a/packages/gui-plugins/src/pairing/dispatch.test.ts b/packages/gui-plugins/src/pairing/dispatch.test.ts index f14b6be51c14..4345ca5a445c 100644 --- a/packages/gui-plugins/src/pairing/dispatch.test.ts +++ b/packages/gui-plugins/src/pairing/dispatch.test.ts @@ -36,7 +36,7 @@ describe("createDispatch", () => { // The dialog that never opened reads as closed, so Pair device opens it again. expect(box.state.dialog).toEqual({ status: "closed" }) - expect(ran).toEqual(["read-remote", "abort", "wait", "open-dialog"]) + expect(ran).toEqual(["read-remote", "read-screen", "abort", "wait", "open-dialog"]) dispatch({ type: "dialog.opened" }) diff --git a/packages/gui-plugins/src/pairing/machine.test.ts b/packages/gui-plugins/src/pairing/machine.test.ts index e560a05fe37b..0dc5887f1199 100644 --- a/packages/gui-plugins/src/pairing/machine.test.ts +++ b/packages/gui-plugins/src/pairing/machine.test.ts @@ -4,11 +4,13 @@ import { REMOTE_CHANGE_TIMEOUT_MS, REMOTE_READ_RETRIES, REMOTE_READ_TIMEOUT_MS, + SCREEN_TIMEOUT_MS, TUNNEL_RETRIES, TUNNEL_RETRY_DELAY_MS, TUNNEL_TIMEOUT_MS, createPairingState, dialogUrl, + pairingEffectFailed, remainingSeconds, transitionPairing, viewDialog, @@ -33,30 +35,36 @@ const replay = (events: readonly PairingEvent[], state: PairingState = createPai const step = (state: PairingState, event: PairingEvent) => transitionPairing(state, event) -// Fails every read, and ends every wait between them, until the machine stops reading. -const failReads = (state: PairingState) => +// Fails every read of one setting, and ends every wait between them, until the machine stops reading it. +const failReads = (state: PairingState, setting: "remote" | "screen" = "remote") => Array.from({ length: (REMOTE_READ_RETRIES + 1) * 2 }).reduce<{ state: PairingState effects: readonly PairingEffect[] }>( (last) => { - const remote = last.state.remote + const current = last.state[setting] - if (remote.status !== "loading") return last + if (current.status !== "loading") return last - const next = step( - last.state, - remote.step === "reading" - ? { type: "read.failed", attempt: remote.attempt } - : { type: "wait.ended", for: "read", attempt: remote.attempt }, - ) + const attempt = current.attempt + const reading = current.step === "reading" + + const remote: PairingEvent = reading + ? { type: "read.failed", attempt } + : { type: "wait.ended", for: "read", attempt } + + const screen: PairingEvent = reading + ? { type: "screen.read.failed", attempt } + : { type: "wait.ended", for: "screen", attempt } + + const next = step(last.state, setting === "remote" ? remote : screen) return { state: next.state, effects: [...last.effects, ...next.effects] } }, { state, effects: [] }, ) -// Main generation 1 is active and the server is listed; the first read is attempt 1. +// Main generation 1 is active and the server is listed; the first reads are attempt 1 (remote access) and 2 (display). const started = replay([ { type: "server.changed", available: true }, { type: "main.changed", main: { status: "active", generation: 1 } }, @@ -71,7 +79,7 @@ const setRemote = (attempt: number, enabled: boolean, restart: boolean): Pairing timeoutMs: REMOTE_CHANGE_TIMEOUT_MS, }) -// Remote access is on and the dialog polls for the tunnel with attempt 2. +// Remote access is on and the dialog polls for the tunnel with attempt 3. const polling = replay( [{ type: "dialog.opened" }, { type: "read.succeeded", attempt: 1, enabled: true }], started, @@ -80,32 +88,47 @@ const polling = replay( // The dialog shows a code that expires 60 s after it loaded at 1000. const ready = replay( [ - { type: "tunnel.found", attempt: 2, tunnel: TUNNEL }, - { type: "code.loaded", attempt: 3, code: "abc", expiresIn: 60, now: 1_000 }, + { type: "tunnel.found", attempt: 3, tunnel: TUNNEL }, + { type: "code.loaded", attempt: 4, code: "abc", expiresIn: 60, now: 1_000 }, ], polling, ).state +const setScreen = (generation: number, attempt: number, enabled: boolean): PairingEffect => ({ + type: "set-screen", + generation, + attempt, + enabled, + timeoutMs: SCREEN_TIMEOUT_MS, +}) + describe("transitionPairing", () => { - test("starting reads the setting through the active generation", () => { + test("starting reads both settings through the active generation", () => { expect(started.remote).toEqual({ status: "loading", generation: 1, attempt: 1, step: "reading", failures: 0 }) + expect(started.screen).toEqual({ status: "loading", generation: 1, attempt: 2, step: "reading", failures: 0 }) expect( step(replay([{ type: "server.changed", available: true }]).state, { type: "main.changed", main: { status: "active", generation: 1 }, }).effects, - ).toEqual([{ type: "read-remote", generation: 1, attempt: 1, timeoutMs: REMOTE_READ_TIMEOUT_MS }]) + ).toEqual([ + { type: "read-remote", generation: 1, attempt: 1, timeoutMs: REMOTE_READ_TIMEOUT_MS }, + { type: "read-screen", generation: 1, attempt: 2, timeoutMs: SCREEN_TIMEOUT_MS }, + ]) }) test("main going inactive while the dialog turns remote access on shows unavailable, not an endless spinner", () => { const turning = replay([{ type: "dialog.opened" }, { type: "read.succeeded", attempt: 1, enabled: false }], started) - expect(turning.effects).toContainEqual(setRemote(2, true, false)) + expect(turning.effects).toContainEqual(setRemote(3, true, false)) expect(viewDialog(turning.state.dialog)).toEqual({ kind: "loading", setup: true }) const inactive = step(turning.state, { type: "main.changed", main: { status: "inactive" } }) - expect(inactive.effects).toEqual([{ type: "abort", attempt: 2 }]) + expect(inactive.effects).toEqual([ + { type: "abort", attempt: 3 }, + { type: "abort", attempt: 2 }, + ]) expect(viewDialog(inactive.state.dialog)).toEqual({ kind: "unavailable" }) expect(viewPairingPage(inactive.state)).toMatchObject({ unavailable: true, pairDisabled: true, mainActive: false }) @@ -113,34 +136,34 @@ describe("transitionPairing", () => { const back = replay( [ { type: "main.changed", main: { status: "active", generation: 2 } }, - { type: "read.succeeded", attempt: 3, enabled: true }, + { type: "read.succeeded", attempt: 4, enabled: true }, ], inactive.state, ) - expect(back.state.dialog).toEqual({ status: "tunnel", attempt: 4, step: "polling", retries: 0 }) + expect(back.state.dialog).toEqual({ status: "tunnel", attempt: 6, step: "polling", retries: 0 }) }) test("a failed read tries again three times with backoff, as the query's default retries did", () => { const failed = step(started, { type: "read.failed", attempt: 1 }) - expect(failed.state.remote).toEqual({ status: "loading", generation: 1, attempt: 2, step: "waiting", failures: 1 }) + expect(failed.state.remote).toEqual({ status: "loading", generation: 1, attempt: 3, step: "waiting", failures: 1 }) expect(failed.effects).toEqual([ { type: "abort", attempt: 1 }, - { type: "wait", for: "read", attempt: 2, delayMs: 1_000 }, + { type: "wait", for: "read", attempt: 3, delayMs: 1_000 }, ]) // The page keeps the row loading meanwhile, without the error. expect(viewPairingPage(failed.state)).toMatchObject({ remoteError: false, remoteDisabled: true }) // A late answer from the failed read changes nothing. expect(step(failed.state, { type: "read.succeeded", attempt: 1, enabled: true }).state).toBe(failed.state) - const again = step(failed.state, { type: "wait.ended", for: "read", attempt: 2 }) + const again = step(failed.state, { type: "wait.ended", for: "read", attempt: 3 }) expect(again.effects).toEqual([ - { type: "abort", attempt: 2 }, - { type: "read-remote", generation: 1, attempt: 3, timeoutMs: REMOTE_READ_TIMEOUT_MS }, + { type: "abort", attempt: 3 }, + { type: "read-remote", generation: 1, attempt: 4, timeoutMs: REMOTE_READ_TIMEOUT_MS }, ]) - expect(step(again.state, { type: "read.succeeded", attempt: 3, enabled: true }).state.remote).toEqual({ + expect(step(again.state, { type: "read.succeeded", attempt: 4, enabled: true }).state.remote).toEqual({ status: "ready", generation: 1, enabled: true, @@ -156,20 +179,20 @@ describe("transitionPairing", () => { }) test("a tunnel wait that ends while main restarts and the setting is read again still polls", () => { - const waiting = step(polling, { type: "tunnel.missing", attempt: 2 }) + const waiting = step(polling, { type: "tunnel.missing", attempt: 3 }) - expect(waiting.effects).toContainEqual({ type: "wait", for: "tunnel", attempt: 3, delayMs: TUNNEL_RETRY_DELAY_MS }) + expect(waiting.effects).toContainEqual({ type: "wait", for: "tunnel", attempt: 4, delayMs: TUNNEL_RETRY_DELAY_MS }) // Main restarts: the dialog keeps its tunnel wait while the new generation reads the setting, and that read fails. const reading = step(waiting.state, { type: "main.changed", main: { status: "active", generation: 2 } }).state - const retrying = step(reading, { type: "read.failed", attempt: 4 }).state + const retrying = step(reading, { type: "read.failed", attempt: 5 }).state - expect(retrying.remote).toMatchObject({ status: "loading", step: "waiting", attempt: 5 }) - expect(retrying.dialog).toEqual({ status: "tunnel", attempt: 3, step: "waiting", retries: 1 }) + expect(retrying.remote).toMatchObject({ status: "loading", step: "waiting", attempt: 7 }) + expect(retrying.dialog).toEqual({ status: "tunnel", attempt: 4, step: "waiting", retries: 1 }) // The tunnel wait ends during the read, and during the read's own wait: neither swallows it. ;[reading, retrying].forEach((state) => { - const polled = step(state, { type: "wait.ended", for: "tunnel", attempt: 3 }) + const polled = step(state, { type: "wait.ended", for: "tunnel", attempt: 4 }) expect(polled.state.dialog).toEqual({ status: "tunnel", attempt: state.issued + 1, step: "polling", retries: 1 }) expect(polled.effects).toContainEqual({ @@ -181,18 +204,20 @@ describe("transitionPairing", () => { }) // The read's wait ends on its own branch and leaves the tunnel wait alone. - const reread = step(retrying, { type: "wait.ended", for: "read", attempt: 5 }) + const reread = step(retrying, { type: "wait.ended", for: "read", attempt: 7 }) expect(reread.state.dialog).toBe(retrying.dialog) expect(reread.effects).toContainEqual({ type: "read-remote", generation: 2, - attempt: 6, + attempt: 8, timeoutMs: REMOTE_READ_TIMEOUT_MS, }) - // A wait for one phase never ends the other with the same attempt number. - expect(step(retrying, { type: "wait.ended", for: "read", attempt: 3 }).state).toBe(retrying) - expect(step(retrying, { type: "wait.ended", for: "tunnel", attempt: 5 }).state).toBe(retrying) + // A wait for one phase never ends another with the same attempt number. + expect(step(retrying, { type: "wait.ended", for: "read", attempt: 4 }).state).toBe(retrying) + expect(step(retrying, { type: "wait.ended", for: "tunnel", attempt: 7 }).state).toBe(retrying) + expect(step(retrying, { type: "wait.ended", for: "screen", attempt: 4 }).state).toBe(retrying) + expect(step(retrying, { type: "wait.ended", for: "screen", attempt: 7 }).state).toBe(retrying) }) test("after reads give up, the switch still works: its change reads the setting first", () => { @@ -225,7 +250,7 @@ describe("transitionPairing", () => { const refreshing = step(ready, { type: "code.refresh" }).state const gone = step(refreshing, { type: "server.changed", available: false }) - expect(gone.effects).toEqual([{ type: "abort", attempt: 4 }]) + expect(gone.effects).toEqual([{ type: "abort", attempt: 5 }]) expect(viewDialog(gone.state.dialog)).toEqual({ kind: "unavailable" }) expect(viewPairingPage(gone.state).unavailable).toBe(true) }) @@ -236,7 +261,7 @@ describe("transitionPairing", () => { const reading = step(ready, { type: "main.changed", main: { status: "active", generation: 2 } }).state // Retries keep the code on screen; only the read that gives up replaces it. - expect(step(reading, { type: "read.failed", attempt: 4 }).state.dialog).toBe(ready.dialog) + expect(step(reading, { type: "read.failed", attempt: 5 }).state.dialog).toBe(ready.dialog) const failed = failReads(reading).state @@ -249,7 +274,7 @@ describe("transitionPairing", () => { [ { type: "read.succeeded", attempt: 1, enabled: false }, { type: "toggled", enabled: true }, - { type: "change.failed", attempt: 2 }, + { type: "change.failed", attempt: 3 }, ], started, ).state @@ -258,13 +283,13 @@ describe("transitionPairing", () => { const opened = step(failed, { type: "dialog.opened" }) - expect(opened.effects).toEqual([{ type: "open-dialog" }, setRemote(3, true, false)]) + expect(opened.effects).toEqual([{ type: "open-dialog" }, setRemote(4, true, false)]) expect(viewDialog(opened.state.dialog)).toEqual({ kind: "loading", setup: true }) - const on = step(opened.state, { type: "change.succeeded", attempt: 3 }).state + const on = step(opened.state, { type: "change.succeeded", attempt: 4 }).state expect(viewPairingPage(on).remoteError).toBe(false) - expect(on.dialog).toEqual({ status: "tunnel", attempt: 4, step: "polling", retries: 0 }) + expect(on.dialog).toEqual({ status: "tunnel", attempt: 5, step: "polling", retries: 0 }) }) test("Try again after the tunnel never connected restarts the service and polls afresh", () => { @@ -318,12 +343,12 @@ describe("transitionPairing", () => { expect(on.effects).toEqual([ { type: "abort", attempt: 1 }, - { type: "poll-tunnel", attempt: 2, timeoutMs: TUNNEL_TIMEOUT_MS }, + { type: "poll-tunnel", attempt: 3, timeoutMs: TUNNEL_TIMEOUT_MS }, ]) const off = step(opened.state, { type: "read.succeeded", attempt: 1, enabled: false }) - expect(off.effects).toEqual([{ type: "abort", attempt: 1 }, setRemote(2, true, false)]) + expect(off.effects).toEqual([{ type: "abort", attempt: 1 }, setRemote(3, true, false)]) expect(step(off.state, { type: "dialog.opened" }).state).toBe(off.state) }) @@ -336,7 +361,7 @@ describe("transitionPairing", () => { const back = replay( [ { type: "main.changed", main: { status: "active", generation: 2 } }, - { type: "read.succeeded", attempt: 4, enabled: true }, + { type: "read.succeeded", attempt: 5, enabled: true }, ], pending, ).state @@ -347,20 +372,20 @@ describe("transitionPairing", () => { test("a refresh keeps the code on screen, and a failed one offers to try again", () => { const refreshing = step(ready, { type: "code.refresh" }) - expect(refreshing.effects).toEqual([{ type: "fetch-code", attempt: 4, timeoutMs: CODE_TIMEOUT_MS }]) + expect(refreshing.effects).toEqual([{ type: "fetch-code", attempt: 5, timeoutMs: CODE_TIMEOUT_MS }]) expect(viewDialog(refreshing.state.dialog)).toMatchObject({ kind: "ready", refreshing: true }) expect(step(refreshing.state, { type: "code.refresh" }).state).toBe(refreshing.state) - const failed = step(refreshing.state, { type: "code.failed", attempt: 4 }).state + const failed = step(refreshing.state, { type: "code.failed", attempt: 5 }).state expect(viewDialog(failed.dialog)).toEqual({ kind: "code-error" }) const again = step(failed, { type: "code.refresh" }) - expect(again.effects).toEqual([{ type: "fetch-code", attempt: 5, timeoutMs: CODE_TIMEOUT_MS }]) + expect(again.effects).toEqual([{ type: "fetch-code", attempt: 6, timeoutMs: CODE_TIMEOUT_MS }]) expect(viewDialog(again.state.dialog)).toEqual({ kind: "loading", setup: false }) - const loaded = step(again.state, { type: "code.loaded", attempt: 5, code: "def", expiresIn: 60, now: 2_000 }) + const loaded = step(again.state, { type: "code.loaded", attempt: 6, code: "def", expiresIn: 60, now: 2_000 }) expect(dialogUrl(loaded.state.dialog)).toBe(`${TUNNEL}/auth/connect/def`) }) @@ -373,15 +398,15 @@ describe("transitionPairing", () => { const expired = step(ready, { type: "tick", now: 600_000 }) expect(remainingSeconds(ready.dialog, 600_000)).toBe(0) - expect(expired.effects).toEqual([{ type: "fetch-code", attempt: 4, timeoutMs: CODE_TIMEOUT_MS }]) + expect(expired.effects).toEqual([{ type: "fetch-code", attempt: 5, timeoutMs: CODE_TIMEOUT_MS }]) expect(step(expired.state, { type: "tick", now: 601_000 }).state).toBe(expired.state) }) test("answers for another attempt change nothing", () => { expect(step(started, { type: "read.succeeded", attempt: 9, enabled: true }).state).toBe(started) expect(step(polling, { type: "tunnel.found", attempt: 1, tunnel: TUNNEL }).state).toBe(polling) - expect(step(polling, { type: "wait.ended", for: "tunnel", attempt: 2 }).state).toBe(polling) - expect(step(ready, { type: "code.loaded", attempt: 3, code: "x", expiresIn: 60, now: 0 }).state).toBe(ready) + expect(step(polling, { type: "wait.ended", for: "tunnel", attempt: 3 }).state).toBe(polling) + expect(step(ready, { type: "code.loaded", attempt: 4, code: "x", expiresIn: 60, now: 0 }).state).toBe(ready) expect(step(ready, { type: "change.succeeded", attempt: 1 }).state).toBe(ready) expect(step(started, { type: "main.changed", main: { status: "active", generation: 1 } }).state).toBe(started) }) @@ -396,7 +421,7 @@ describe("transitionPairing", () => { expect(closed.dialog).toEqual({ status: "closed" }) expect(closed.remote).toMatchObject({ status: "changing", enabled: true }) - expect(step(polling, { type: "dialog.closed" }).effects).toEqual([{ type: "abort", attempt: 2 }]) + expect(step(polling, { type: "dialog.closed" }).effects).toEqual([{ type: "abort", attempt: 3 }]) }) test("the switch cannot open pairing while it turns remote access off", () => { @@ -408,7 +433,7 @@ describe("transitionPairing", () => { started, ) - expect(off.effects).toContainEqual(setRemote(2, false, false)) + expect(off.effects).toContainEqual(setRemote(3, false, false)) expect(viewPairingPage(off.state)).toMatchObject({ pairDisabled: true, remoteChecked: false, remoteDisabled: true }) expect(step(off.state, { type: "toggled", enabled: true }).state).toBe(off.state) }) @@ -419,13 +444,169 @@ describe("transitionPairing", () => { const disposed = step(reading, { type: "disposed" }) expect(disposed.effects).toEqual([ + { type: "abort", attempt: 6 }, + { type: "abort", attempt: 7 }, { type: "abort", attempt: 5 }, - { type: "abort", attempt: 4 }, { type: "close-dialog" }, ]) - expect(step(disposed.state, { type: "code.loaded", attempt: 4, code: "x", expiresIn: 1, now: 0 }).state).toBe( + expect(step(disposed.state, { type: "code.loaded", attempt: 5, code: "x", expiresIn: 1, now: 0 }).state).toBe( + disposed.state, + ) + expect(step(disposed.state, { type: "screen.read.succeeded", attempt: 7, enabled: true }).state).toBe( disposed.state, ) expect(step(disposed.state, { type: "dialog.opened" }).state).toBe(disposed.state) }) }) + +describe("the display sleep setting", () => { + test("reads that give up offer Try again and keep the switch enabled", () => { + const exhausted = failReads(started, "screen") + + expect(exhausted.effects.flatMap((effect) => (effect.type === "wait" ? [effect.delayMs] : []))).toEqual([ + 1_000, 2_000, 4_000, + ]) + expect(exhausted.effects.filter((effect) => effect.type === "read-screen")).toHaveLength(REMOTE_READ_RETRIES) + expect(exhausted.state.screen).toEqual({ status: "failed", generation: 1, previous: undefined }) + // Remote access goes on reading on its own. + expect(exhausted.state.remote).toBe(started.remote) + // The query kept the switch disabled on its error, with "Try again" under it and no way to. + expect(viewPairingPage(exhausted.state)).toMatchObject({ + screenError: true, + screenDisabled: false, + screenChecked: false, + }) + + // "Try again" reads the setting afresh, with its own retries. + const retried = step(exhausted.state, { type: "screen.retry" }) + const read = exhausted.state.issued + 1 + + expect(retried.effects).toEqual([ + { type: "read-screen", generation: 1, attempt: read, timeoutMs: SCREEN_TIMEOUT_MS }, + ]) + expect(retried.state.screen).toEqual({ + status: "loading", + generation: 1, + attempt: read, + step: "reading", + failures: 0, + }) + expect(viewPairingPage(retried.state)).toMatchObject({ screenError: false, screenDisabled: true }) + expect(step(retried.state, { type: "screen.retry" }).state).toBe(retried.state) + expect(step(retried.state, { type: "screen.read.succeeded", attempt: read, enabled: true }).state.screen).toEqual({ + status: "ready", + generation: 1, + enabled: true, + }) + + const toggled = step(exhausted.state, { type: "screen.toggled", enabled: true }) + const attempt = exhausted.state.issued + 1 + + expect(toggled.effects).toEqual([setScreen(1, attempt, true)]) + expect(viewPairingPage(toggled.state)).toMatchObject({ + screenError: false, + screenDisabled: true, + screenChecked: true, + }) + // A second flip while the change runs waits for it. + expect(step(toggled.state, { type: "screen.toggled", enabled: false }).state).toBe(toggled.state) + + const on = step(toggled.state, { type: "screen.change.succeeded", attempt }) + + expect(on.state.screen).toEqual({ status: "ready", generation: 1, enabled: true }) + expect(on.effects).toEqual([{ type: "abort", attempt }]) + }) + + test("a change that finishes after main restarts never writes into the new generation, and errors do not carry over", () => { + const changing = replay( + [ + { type: "screen.read.succeeded", attempt: 2, enabled: false }, + { type: "screen.toggled", enabled: true }, + ], + started, + ) + + expect(changing.effects).toContainEqual(setScreen(1, 3, true)) + + const restarted = step(changing.state, { type: "main.changed", main: { status: "active", generation: 2 } }) + + expect(restarted.effects).toContainEqual({ type: "abort", attempt: 3 }) + expect(restarted.effects).toContainEqual({ + type: "read-screen", + generation: 2, + attempt: 5, + timeoutMs: SCREEN_TIMEOUT_MS, + }) + + const read = step(restarted.state, { type: "screen.read.succeeded", attempt: 5, enabled: false }).state + + expect(read.screen).toEqual({ status: "ready", generation: 2, enabled: false }) + // The old generation's change answers late; the query wrote it under generation 2's key. + expect(step(read, { type: "screen.change.succeeded", attempt: 3 }).state).toBe(read) + expect(step(read, { type: "screen.change.failed", attempt: 3 }).state).toBe(read) + + // A failure on generation 1 never shows under whatever main is next. + const failed = step(changing.state, { type: "screen.change.failed", attempt: 3 }).state + + expect(viewPairingPage(failed).screenError).toBe(true) + ;([{ status: "pending" }, { status: "inactive" }, { status: "active", generation: 2 }] as const).forEach((main) => { + expect(viewPairingPage(step(failed, { type: "main.changed", main }).state).screenError).toBe(false) + }) + }) + + test("a failed change shows the setting it left and allows another try", () => { + const on = replay([{ type: "screen.read.succeeded", attempt: 2, enabled: true }], started).state + + // Flipping to the value it already has changes nothing. + expect(step(on, { type: "screen.toggled", enabled: true }).state).toBe(on) + + const toggled = step(on, { type: "screen.toggled", enabled: false }).state + + expect(viewPairingPage(toggled)).toMatchObject({ screenChecked: false, screenDisabled: true, screenError: false }) + + const failed = step(toggled, { type: "screen.change.failed", attempt: 3 }).state + + expect(viewPairingPage(failed)).toMatchObject({ screenChecked: true, screenDisabled: false, screenError: true }) + expect(step(failed, { type: "screen.toggled", enabled: false }).effects).toEqual([setScreen(1, 4, false)]) + }) + + test("answers for another attempt change nothing, and disposing ends the running request", () => { + const waiting = step(started, { type: "screen.read.failed", attempt: 2 }) + + expect(waiting.effects).toEqual([ + { type: "abort", attempt: 2 }, + { type: "wait", for: "screen", attempt: 3, delayMs: 1_000 }, + ]) + // The remote read's wait and the tunnel's never end the display setting's wait with the same number. + expect(step(waiting.state, { type: "wait.ended", for: "read", attempt: 3 }).state).toBe(waiting.state) + expect(step(waiting.state, { type: "wait.ended", for: "tunnel", attempt: 3 }).state).toBe(waiting.state) + expect(step(waiting.state, { type: "screen.read.succeeded", attempt: 2, enabled: true }).state).toBe(waiting.state) + expect(step(started, { type: "screen.read.succeeded", attempt: 1, enabled: true }).state).toBe(started) + expect(step(started, { type: "screen.change.succeeded", attempt: 2 }).state).toBe(started) + expect(step(started, { type: "screen.toggled", enabled: true }).state).toBe(started) + + const reading = step(waiting.state, { type: "wait.ended", for: "screen", attempt: 3 }) + + expect(reading.effects).toEqual([ + { type: "abort", attempt: 3 }, + { type: "read-screen", generation: 1, attempt: 4, timeoutMs: SCREEN_TIMEOUT_MS }, + ]) + + const disposed = step(reading.state, { type: "disposed" }) + + expect(disposed.effects).toEqual([ + { type: "abort", attempt: 1 }, + { type: "abort", attempt: 4 }, + ]) + expect(step(disposed.state, { type: "screen.read.succeeded", attempt: 4, enabled: true }).state).toBe( + disposed.state, + ) + }) + + test("a request that throws before it answers ends its phase", () => { + expect(pairingEffectFailed(setScreen(1, 3, true))).toEqual({ type: "screen.change.failed", attempt: 3 }) + expect( + pairingEffectFailed({ type: "read-screen", generation: 1, attempt: 2, timeoutMs: SCREEN_TIMEOUT_MS }), + ).toEqual({ type: "screen.read.failed", attempt: 2 }) + }) +}) diff --git a/packages/gui-plugins/src/pairing/machine.ts b/packages/gui-plugins/src/pairing/machine.ts index bf035fea5607..50aefcd4a587 100644 --- a/packages/gui-plugins/src/pairing/machine.ts +++ b/packages/gui-plugins/src/pairing/machine.ts @@ -1,7 +1,7 @@ /** How long one read of the remote access setting may take. */ export const REMOTE_READ_TIMEOUT_MS = 15_000 -/** How many times a failed read of the setting is tried again, as a query's default retries did. */ +/** How many times a failed read of either setting is tried again, as a query's default retries did. */ export const REMOTE_READ_RETRIES = 3 /** The wait before read retry `failures`: 1, 2, then 4 seconds, a query's default backoff. */ @@ -26,6 +26,9 @@ export const TUNNEL_TIMEOUT_MS = 10_000 /** How long one request for a pairing code may take. */ export const CODE_TIMEOUT_MS = 15_000 +/** How long one read or change of the display sleep setting may take. */ +export const SCREEN_TIMEOUT_MS = 15_000 + /** Pairing's main side as the page reads it: remote access changes only through it. */ export type MainStatus = | { readonly status: "active"; readonly generation: number } @@ -60,6 +63,30 @@ export type RemoteState = | { readonly status: "failed"; readonly generation: number; readonly previous: boolean | undefined } | { readonly status: "disposed" } +/** Whether main keeps the display awake, read and changed through one generation of pairing's main side. */ +export type ScreenState = + | { readonly status: "pending" } + | { readonly status: "inactive" } + | { + readonly status: "loading" + readonly generation: number + readonly attempt: number + /** A read runs, or the wait before the next read after a failure. */ + readonly step: "reading" | "waiting" + readonly failures: number + } + | { readonly status: "ready"; readonly generation: number; readonly enabled: boolean } + | { + readonly status: "changing" + readonly generation: number + readonly attempt: number + readonly enabled: boolean + /** The setting before the change, when it was known. */ + readonly previous: boolean | undefined + } + | { readonly status: "failed"; readonly generation: number; readonly previous: boolean | undefined } + | { readonly status: "disposed" } + /** The "Pair a device" dialog. Exactly one variant owns its body. */ export type DialogState = | { readonly status: "closed" } @@ -89,14 +116,16 @@ export type DialogState = | { readonly status: "failed"; readonly reason: "remote" | "tunnel" } /** - * The pairing page: the remote access setting and the dialog that turns it on. One transition owns both, so the - * dialog follows every change of the setting in the same event. `issued` is the last attempt handed out. + * The pairing page: the remote access setting, the dialog that turns it on, and the display sleep setting. One + * transition owns them, so the dialog follows every change of remote access in the same event, and both settings + * follow main's generations together. `issued` is the last attempt handed out. */ export type PairingState = { readonly issued: number /** Whether the local server is listed. Codes come from it, so they never wait for main. */ readonly server: boolean readonly remote: RemoteState + readonly screen: ScreenState readonly dialog: DialogState } @@ -111,6 +140,16 @@ export type PairingEvent = | { readonly type: "change.succeeded"; readonly attempt: number } /** The change failed or timed out. */ | { readonly type: "change.failed"; readonly attempt: number } + | { readonly type: "screen.read.succeeded"; readonly attempt: number; readonly enabled: boolean } + /** The display sleep setting's read failed or timed out. */ + | { readonly type: "screen.read.failed"; readonly attempt: number } + /** The user flipped the "Keep screen active" switch. */ + | { readonly type: "screen.toggled"; readonly enabled: boolean } + /** The user clicked "Try again" beside the display sleep setting's error. */ + | { readonly type: "screen.retry" } + | { readonly type: "screen.change.succeeded"; readonly attempt: number } + /** The display sleep setting's change failed or timed out. */ + | { readonly type: "screen.change.failed"; readonly attempt: number } /** The user clicked "Pair device". */ | { readonly type: "dialog.opened" } | { readonly type: "dialog.closed" } @@ -118,7 +157,7 @@ export type PairingEvent = /** The server advertises no tunnel yet, or the request failed or timed out. */ | { readonly type: "tunnel.missing"; readonly attempt: number } /** A `wait` effect's delay ended; `for` names the phase it belongs to, as the effect did. */ - | { readonly type: "wait.ended"; readonly for: "read" | "tunnel"; readonly attempt: number } + | { readonly type: "wait.ended"; readonly for: "read" | "screen" | "tunnel"; readonly attempt: number } | { readonly type: "code.loaded" readonly attempt: number @@ -144,12 +183,25 @@ export type PairingEffect = readonly restart: boolean readonly timeoutMs: number } + | { readonly type: "read-screen"; readonly generation: number; readonly attempt: number; readonly timeoutMs: number } + | { + readonly type: "set-screen" + readonly generation: number + readonly attempt: number + readonly enabled: boolean + readonly timeoutMs: number + } | { readonly type: "poll-tunnel"; readonly attempt: number; readonly timeoutMs: number } /** - * Waits before the next read of the setting or the next ask for the tunnel; ends with `wait.ended` for the same - * phase, so a read retry and a tunnel retry that wait at once never take each other's event. + * Waits before the next read of a setting or the next ask for the tunnel; ends with `wait.ended` for the same + * phase, so retries that wait at once never take each other's event. */ - | { readonly type: "wait"; readonly for: "read" | "tunnel"; readonly attempt: number; readonly delayMs: number } + | { + readonly type: "wait" + readonly for: "read" | "screen" | "tunnel" + readonly attempt: number + readonly delayMs: number + } | { readonly type: "fetch-code"; readonly attempt: number; readonly timeoutMs: number } /** Ends an attempt's request and timer. */ | { readonly type: "abort"; readonly attempt: number } @@ -161,11 +213,18 @@ export type PairingTransition = { readonly state: PairingState; readonly effects const NONE: readonly PairingEffect[] = [] export function createPairingState(): PairingState { - return { issued: 0, server: false, remote: { status: "pending" }, dialog: { status: "closed" } } + return { + issued: 0, + server: false, + remote: { status: "pending" }, + screen: { status: "pending" }, + dialog: { status: "closed" }, + } } export function transitionPairing(state: PairingState, event: PairingEvent): PairingTransition { const remote = state.remote + const screen = state.screen const dialog = state.dialog if (remote.status === "disposed") return { state, effects: NONE } @@ -174,8 +233,8 @@ export function transitionPairing(state: PairingState, event: PairingEvent): Pai const close: readonly PairingEffect[] = dialog.status === "closed" ? [] : [{ type: "close-dialog" }] return { - state: { ...state, remote: { status: "disposed" }, dialog: { status: "closed" } }, - effects: [...abortRemote(remote), ...abortDialog(dialog), ...close], + state: { ...state, remote: { status: "disposed" }, screen: { status: "disposed" }, dialog: { status: "closed" } }, + effects: [...abortRemote(remote), ...abortScreen(screen), ...abortDialog(dialog), ...close], } } @@ -183,28 +242,59 @@ export function transitionPairing(state: PairingState, event: PairingEvent): Pai const main = event.main if (main.status !== "active") { - if (remote.status === main.status) return { state, effects: NONE } + if (remote.status === main.status && screen.status === main.status) return { state, effects: NONE } - return settle({ ...state, remote: { status: main.status } }, abortRemote(remote)) + return settle({ ...state, remote: { status: main.status }, screen: { status: main.status } }, [ + ...abortRemote(remote), + ...abortScreen(screen), + ]) } - if ("generation" in remote && remote.generation === main.generation) return { state, effects: NONE } + if ( + "generation" in remote && + remote.generation === main.generation && + "generation" in screen && + screen.generation === main.generation + ) + return { state, effects: NONE } const attempt = state.issued + 1 + // Both settings are read again through the new generation, so neither keeps an answer or error from the last one. + const screenAttempt = attempt + 1 return settle( { ...state, - issued: attempt, + issued: screenAttempt, remote: { status: "loading", generation: main.generation, attempt, step: "reading", failures: 0 }, + screen: { + status: "loading", + generation: main.generation, + attempt: screenAttempt, + step: "reading", + failures: 0, + }, }, [ ...abortRemote(remote), + ...abortScreen(screen), { type: "read-remote", generation: main.generation, attempt, timeoutMs: REMOTE_READ_TIMEOUT_MS }, + { type: "read-screen", generation: main.generation, attempt: screenAttempt, timeoutMs: SCREEN_TIMEOUT_MS }, ], ) } + if ( + event.type === "screen.read.succeeded" || + event.type === "screen.read.failed" || + event.type === "screen.toggled" || + event.type === "screen.retry" || + event.type === "screen.change.succeeded" || + event.type === "screen.change.failed" || + (event.type === "wait.ended" && event.for === "screen") + ) + return transitionScreen(state, event) + if (event.type === "server.changed") { if (state.server === event.available) return { state, effects: NONE } @@ -310,6 +400,126 @@ export function transitionPairing(state: PairingState, event: PairingEvent): Pai return transitionCode(state, event) } +/** + * The display sleep setting. It reads and retries as remote access does. After a failure, "Try again" reads it + * afresh, and the switch stays enabled, so flipping it sets the value outright. Nothing else depends on it. + */ +function transitionScreen( + state: PairingState, + event: Extract< + PairingEvent, + { + type: + | "screen.read.succeeded" + | "screen.read.failed" + | "screen.toggled" + | "screen.retry" + | "screen.change.succeeded" + | "screen.change.failed" + | "wait.ended" + } + >, +): PairingTransition { + const screen = state.screen + const attempt = state.issued + 1 + + if (event.type === "screen.retry") { + if (screen.status !== "failed") return { state, effects: NONE } + + return { + state: { + ...state, + issued: attempt, + screen: { status: "loading", generation: screen.generation, attempt, step: "reading", failures: 0 }, + }, + effects: [{ type: "read-screen", generation: screen.generation, attempt, timeoutMs: SCREEN_TIMEOUT_MS }], + } + } + + if (event.type === "screen.read.succeeded" || event.type === "screen.read.failed") { + if (screen.status !== "loading" || screen.step !== "reading" || screen.attempt !== event.attempt) + return { state, effects: NONE } + + const effects: readonly PairingEffect[] = [{ type: "abort", attempt: event.attempt }] + + if (event.type === "screen.read.succeeded") + return { + state: { ...state, screen: { status: "ready", generation: screen.generation, enabled: event.enabled } }, + effects, + } + + if (screen.failures >= REMOTE_READ_RETRIES) + return { + state: { ...state, screen: { status: "failed", generation: screen.generation, previous: undefined } }, + effects, + } + + return { + state: { + ...state, + issued: attempt, + screen: { ...screen, attempt, step: "waiting", failures: screen.failures + 1 }, + }, + effects: [...effects, { type: "wait", for: "screen", attempt, delayMs: remoteReadDelay(screen.failures) }], + } + } + + if (event.type === "wait.ended") { + if (screen.status !== "loading" || screen.step !== "waiting" || screen.attempt !== event.attempt) + return { state, effects: NONE } + + return { + state: { ...state, issued: attempt, screen: { ...screen, attempt, step: "reading" } }, + effects: [ + { type: "abort", attempt: event.attempt }, + { type: "read-screen", generation: screen.generation, attempt, timeoutMs: SCREEN_TIMEOUT_MS }, + ], + } + } + + if (event.type === "screen.toggled") { + if (screen.status !== "ready" && screen.status !== "failed") return { state, effects: NONE } + + if (screen.status === "ready" && screen.enabled === event.enabled) return { state, effects: NONE } + + return { + state: { + ...state, + issued: attempt, + screen: { + status: "changing", + generation: screen.generation, + attempt, + enabled: event.enabled, + previous: screen.status === "ready" ? screen.enabled : screen.previous, + }, + }, + effects: [ + { + type: "set-screen", + generation: screen.generation, + attempt, + enabled: event.enabled, + timeoutMs: SCREEN_TIMEOUT_MS, + }, + ], + } + } + + if (screen.status !== "changing" || screen.attempt !== event.attempt) return { state, effects: NONE } + + return { + state: { + ...state, + screen: + event.type === "screen.change.succeeded" + ? { status: "ready", generation: screen.generation, enabled: screen.enabled } + : { status: "failed", generation: screen.generation, previous: screen.previous }, + }, + effects: [{ type: "abort", attempt: event.attempt }], + } +} + /** The dialog's own requests: the tunnel address, then the code and its replacements. */ function transitionCode(state: PairingState, event: PairingEvent): PairingTransition { const dialog = state.dialog @@ -550,6 +760,10 @@ function abortRemote(remote: RemoteState): readonly PairingEffect[] { return remote.status === "loading" || remote.status === "changing" ? [{ type: "abort", attempt: remote.attempt }] : [] } +function abortScreen(screen: ScreenState): readonly PairingEffect[] { + return screen.status === "loading" || screen.status === "changing" ? [{ type: "abort", attempt: screen.attempt }] : [] +} + function abortDialog(dialog: DialogState): readonly PairingEffect[] { if (dialog.status === "tunnel" || dialog.status === "code") return [{ type: "abort", attempt: dialog.attempt }] @@ -565,6 +779,10 @@ export function pairingEffectFailed(effect: PairingEffect): PairingEvent | undef if (effect.type === "set-remote") return { type: "change.failed", attempt: effect.attempt } + if (effect.type === "read-screen") return { type: "screen.read.failed", attempt: effect.attempt } + + if (effect.type === "set-screen") return { type: "screen.change.failed", attempt: effect.attempt } + if (effect.type === "poll-tunnel") return { type: "tunnel.missing", attempt: effect.attempt } if (effect.type === "wait") return { type: "wait.ended", for: effect.for, attempt: effect.attempt } @@ -579,6 +797,7 @@ export function pairingEffectFailed(effect: PairingEffect): PairingEvent | undef /** What the pairing settings page shows. */ export function viewPairingPage(state: PairingState) { const remote = state.remote + const screen = state.screen const mainActive = remote.status !== "pending" && remote.status !== "inactive" && remote.status !== "disposed" return { @@ -590,6 +809,13 @@ export function viewPairingPage(state: PairingState) { (remote.status === "failed" && remote.previous === true), remoteDisabled: remote.status !== "ready" && remote.status !== "failed", remoteError: remote.status === "failed", + screenChecked: + ((screen.status === "changing" || screen.status === "ready") && screen.enabled) || + (screen.status === "failed" && screen.previous === true), + // A failed read or change keeps the switch enabled, so flipping it tries again. + screenDisabled: screen.status !== "ready" && screen.status !== "failed", + // The error shows with "Try again", which reads the setting again. + screenError: screen.status === "failed", // Pending main is still starting; anything else that leaves it inactive keeps pairing from turning on. unavailable: remote.status === "inactive" || !state.server, } diff --git a/packages/gui-plugins/src/pairing/page.stories.tsx b/packages/gui-plugins/src/pairing/page.stories.tsx index f027c11959f4..52b0ba69f59a 100644 --- a/packages/gui-plugins/src/pairing/page.stories.tsx +++ b/packages/gui-plugins/src/pairing/page.stories.tsx @@ -1,7 +1,7 @@ import { PairingHost, pairingRemote } from "../../../stories/mocks/gui-plugins/pairing" import PairingPage from "./page" -const story = (remote: { enabled: boolean; fails?: boolean }, main = true) => ({ +const story = (remote: { enabled: boolean; fails?: boolean; screenFails?: boolean }, main = true) => ({ render: () => { const mock = pairingRemote(remote) @@ -41,3 +41,6 @@ export const SetupFails = story({ enabled: false, fails: true }) /** Pairing's main side is not running, so remote access cannot be turned on and pairing is unavailable. */ export const WithoutMainProcess = story({ enabled: false }, false) + +/** The display sleep setting cannot be read at first; after the retries, Try again reads it and the switch stays enabled. */ +export const ScreenSettingFails = story({ enabled: true, screenFails: true }) diff --git a/packages/gui-plugins/src/pairing/page.tsx b/packages/gui-plugins/src/pairing/page.tsx index 741a0d16f8a0..b71412cf666a 100644 --- a/packages/gui-plugins/src/pairing/page.tsx +++ b/packages/gui-plugins/src/pairing/page.tsx @@ -1,7 +1,6 @@ import { Button } from "@opencode/ui/button" import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog" import { Loader } from "@opencode/ui/loader" -import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query" import { createMemo, createSignal, Match, onCleanup, Show, Switch, untrack, type Accessor } from "solid-js" import { renderSVG } from "@opencode/util/qr" import { createKeyed, usePlugin, type DialogHandle, type Live, type IpcClient } from "@opencode/plugin/gui" @@ -53,7 +52,6 @@ export default function PairingPage(props: { const ui = ctx.ui // The plugin's dialogs close with it, so disabling pairing also stops the dialog's code polling. const dialogs = ctx.dialogs - const queryClient = useQueryClient() // Remote access and the display setting need pairing's main side; codes come from the server, so they never wait. const main = () => { @@ -85,6 +83,31 @@ export default function PairingPage(props: { return AbortSignal.any([ctx.signal, controller.signal]) } + // Reads or changes the display sleep setting through the generation that asked for it. `screenActive` has no input, + // so its options are its only argument. The attempt's signal ends either call at its timeout, or when the page or + // that generation goes away. + const runScreen = (effect: Extract) => { + const attempt = effect.attempt + + const fail = () => + dispatch({ type: effect.type === "read-screen" ? "screen.read.failed" : "screen.change.failed", attempt }) + + const live = main() + + if (live?.generation !== effect.generation) return fail() + + const signal = start(attempt, effect.timeoutMs, fail) + + if (effect.type === "read-screen") + return void live.value + .screenActive({ signal }) + .then((enabled) => dispatch({ type: "screen.read.succeeded", attempt, enabled }), fail) + + void live.value + .setScreenActive(effect.enabled, { signal }) + .then(() => dispatch({ type: "screen.change.succeeded", attempt }), fail) + } + const run = (effect: PairingEffect) => { if (effect.type === "abort") { const request = requests.get(effect.attempt) @@ -104,6 +127,8 @@ export default function PairingPage(props: { if (effect.type === "close-dialog") return opened.dialog?.close() + if (effect.type === "read-screen" || effect.type === "set-screen") return runScreen(effect) + if (effect.type === "read-remote" || effect.type === "set-remote") { const attempt = effect.attempt const fail = () => dispatch({ type: effect.type === "read-remote" ? "read.failed" : "change.failed", attempt }) @@ -188,30 +213,6 @@ export default function PairingPage(props: { ) onCleanup(() => dispatch({ type: "disposed" })) - const screenActive = useQuery(() => ({ - queryKey: [ctx.id, "screen-active", main()?.generation], - queryFn: (input) => { - const live = main() - - if (!live) throw new Error("Pairing's main side is not active") - - return live.value.screenActive({ signal: input.signal }) - }, - enabled: !!main(), - gcTime: 0, - })) - - const screenActivity = useMutation(() => ({ - mutationFn: (enabled: boolean) => { - const live = main() - - if (!live) throw new Error("Pairing's main side is not active") - - return live.value.setScreenActive(enabled) - }, - onSuccess: (_, enabled) => queryClient.setQueryData([ctx.id, "screen-active", main()?.generation], enabled), - })) - return ( - - + +
+ + dispatch({ type: "screen.retry" })}> + {ctx.t("retry")} + +
- {controller.view() === "main" ? language.t("server.add") : language.t("onboarding.installDistro")} + {model().view === "main" ? language.t("server.add") : language.t("onboarding.installDistro")} @@ -96,7 +102,7 @@ export function DialogAddWslServer(props: DialogWslServerProps) { class="settings-wsl-catalog-search" appearance="large" placeholder={language.t("onboarding.searchDistros")} - value={controller.catalogSearch()} + value={model().catalogSearch} disabled={model().busy} onInput={(event) => controller.setCatalogSearch(event.currentTarget.value)} /> @@ -227,7 +233,7 @@ export function DialogAddWslServer(props: DialogWslServerProps) { -