fix(gui-plugins): explicit machines for Keep screen active and the WSL add dialog (#62108)

This commit is contained in:
Luke Parker 2026-11-05 09:46:43 -06:00 • committed by GitHub
parent 17100fc529
commit 61695d15bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1353 additions and 247 deletions

View file

@ -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()
})

View file

@ -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:
<!-- source: src/pairing/page.tsx#screenActive -->
<!-- source: src/pairing/page.tsx#runScreen -->
```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<PairingEffect, { type: "read-screen" | "set-screen" }>) => {
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

View file

@ -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" })

View file

@ -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 })
})
})

View file

@ -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,
}

View file

@ -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 })

View file

@ -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<PairingEffect, { type: "read-screen" | "set-screen" }>) => {
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 (
<ui.SettingsLayout title={ctx.t("title")} description={ctx.t("description")}>
<ui.SettingsSection
@ -222,10 +223,15 @@ export default function PairingPage(props: {
{ctx.t("remote.error")}
</p>
</Show>
<Show when={pageView().mainActive && (screenActive.error || screenActivity.error)}>
<p class="text-text-danger-base" role="alert">
{ctx.t("screenActive.error")}
</p>
<Show when={pageView().screenError}>
<div class="flex flex-wrap items-center gap-2">
<p class="text-text-danger-base" role="alert">
{ctx.t("screenActive.error")}
</p>
<ui.Button size="small" variant="ghost" onClick={() => dispatch({ type: "screen.retry" })}>
{ctx.t("retry")}
</ui.Button>
</div>
</Show>
<Show when={pageView().unavailable}>
<p class="text-text-danger-base" role="alert">
@ -262,9 +268,9 @@ export default function PairingPage(props: {
<ui.Switch
label={ctx.t("screenActive.title")}
hideLabel
checked={screenActive.isSuccess && screenActive.data}
disabled={screenActive.isPending || !!screenActive.error || screenActivity.isPending}
onChange={(enabled) => screenActivity.mutate(enabled)}
checked={pageView().screenChecked}
disabled={pageView().screenDisabled}
onChange={(enabled) => dispatch({ type: "screen.toggled", enabled })}
/>
</ui.SettingsRow>
</Show>

View file

@ -0,0 +1,299 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { WslServersState } from "./contract"
import {
createWslDialogState,
transitionWslDialog,
WSL_ACTION_TIMEOUT_MS,
WSL_INSTALL_TIMEOUT_MS,
type WslDialogEffect,
type WslDialogEvent,
type WslDialogState,
} from "./dialog-machine"
import { addServerProbePlan, addServerViewModel } from "./model"
// Main's state reaches the window through the Ipc, which decodes it.
function wsl(fields: Partial<typeof WslServersState.Encoded>) {
return Schema.decodeUnknownSync(WslServersState)({
runtime: { available: true, version: "2.0", error: null },
installed: [],
online: [],
distroProbes: {},
opencodeChecks: {},
pendingRestart: false,
servers: [],
hostJob: "idle",
distroJobs: {},
...fields,
})
}
const probe = (name: string) => ({ name, canExecute: true, hasBash: true, hasCurl: true, error: null })
const check = (distro: string, version: string | null) => ({
distro,
resolvedPath: "/usr/bin/opencode",
version,
expectedVersion: "1.0",
matchesDesktop: version === "1.0",
error: null,
})
const distros = {
installed: [
{ name: "Ubuntu", version: 2, isDefault: true },
{ name: "Debian", version: 2, isDefault: false },
],
online: [
{ name: "Fedora", label: "Fedora Linux" },
{ name: "Arch", label: "Arch Linux" },
],
}
// Ubuntu is ready to add; Debian needs OpenCode; Fedora can be installed from the catalog. Main runs no job.
const idle = wsl({
...distros,
distroProbes: { Ubuntu: probe("Ubuntu"), Debian: probe("Debian") },
opencodeChecks: { Ubuntu: check("Ubuntu", "1.0"), Debian: check("Debian", "0.9") },
})
// The same distros before any probe, so the main view wants to probe them.
const unprobed = wsl(distros)
const view = (dialog: WslDialogState) => addServerViewModel({ state: idle, dialog, probingAddable: false })
// The probe the dialog would start now, planned as the dialog plans it.
const plan = (dialog: WslDialogState) => {
const model = view(dialog)
return addServerProbePlan({
state: unprobed,
view: model.view,
busy: model.busy,
selectedDistro: model.selectedDistro,
addableInstalledDistros: model.addableInstalledDistros,
})
}
function replay(state: WslDialogState, events: readonly WslDialogEvent[]) {
return events.reduce<{ state: WslDialogState; effects: readonly WslDialogEffect[] }>(
(result, event) => {
const next = transitionWslDialog(result.state, event)
return { state: next.state, effects: [...result.effects, ...next.effects] }
},
{ state, effects: [] },
)
}
const catalog = replay(createWslDialogState(), [{ type: "catalog.opened", defaultTarget: "Fedora", busy: false }]).state
// The main view with `distro` picked.
const picked = (distro: string) =>
transitionWslDialog(createWslDialogState(), { type: "distro.selected", distro }).state
// A click on the primary button with `distro` picked, as the dialog reads the button.
const primary = (distro: string): WslDialogEvent => {
const button = view(picked(distro)).primaryButton
return { type: "primary.requested", distro, action: button.action, disabled: button.disabled || button.loading }
}
describe("wsl add-server dialog", () => {
test("installing a distro claims the catalog before main's state shows the job", () => {
const installing = transitionWslDialog(catalog, {
type: "catalog.install.requested",
distro: "Fedora",
busy: false,
})
expect(installing.state).toMatchObject({ status: "catalog-installing", attempt: 1, distro: "Fedora" })
expect(installing.effects).toEqual([
{ type: "probes.reset" },
{
type: "action.run",
attempt: 1,
action: { kind: "install-distro", distro: "Fedora" },
timeoutMs: WSL_INSTALL_TIMEOUT_MS,
},
])
// Main still reports no job, yet the dialog is busy and no probe starts beside the install.
expect(view(installing.state)).toMatchObject({ busy: true, installingCatalogDistro: true, view: "catalog" })
expect(plan(catalog)).toBeUndefined()
expect(plan(transitionWslDialog(catalog, { type: "catalog.closed", busy: false }).state)).toBeDefined()
expect(plan(installing.state)).toBeUndefined()
// Cancel, another distro and a second install all wait for the first.
;(
[
{ type: "catalog.closed", busy: false },
{ type: "catalog.target.changed", distro: "Arch" },
{ type: "catalog.install.requested", distro: "Arch", busy: false },
] as const
).forEach((event) => {
expect(transitionWslDialog(installing.state, event)).toEqual({ state: installing.state, effects: [] })
})
})
test("each action in the main view claims the dialog before its call leaves", () => {
const main = createWslDialogState()
const opencode = transitionWslDialog(picked("Debian"), primary("Debian"))
const add = transitionWslDialog(main, primary("Ubuntu"))
const refresh = transitionWslDialog(main, { type: "distros.refresh.requested", busy: false })
const install = transitionWslDialog(main, { type: "wsl.install.requested", busy: false })
expect(opencode.effects).toEqual([
{
type: "action.run",
attempt: 1,
action: { kind: "install-opencode", distro: "Debian" },
timeoutMs: WSL_INSTALL_TIMEOUT_MS,
},
])
expect(add.effects).toEqual([
{
type: "action.run",
attempt: 1,
action: { kind: "add-server", distro: "Ubuntu" },
timeoutMs: WSL_ACTION_TIMEOUT_MS,
},
])
expect(refresh.effects).toEqual([
{ type: "probes.reset" },
{ type: "action.run", attempt: 1, action: { kind: "refresh-distros" }, timeoutMs: WSL_ACTION_TIMEOUT_MS },
])
expect(install.effects).toEqual([
{ type: "action.run", attempt: 1, action: { kind: "install-wsl" }, timeoutMs: WSL_INSTALL_TIMEOUT_MS },
])
// Install OpenCode spins at once; before, it waited for main's "updating" with every control still enabled.
expect(view(opencode.state).primaryButton).toMatchObject({
action: "install-opencode",
loading: true,
})
expect(view(add.state)).toMatchObject({ adding: true, primaryButton: { action: "add", loading: true } })
;[opencode, add, refresh, install].forEach((running) => {
expect(running.state.status).toBe("main-running")
expect(view(running.state).busy).toBe(true)
;(
[
primary("Ubuntu"),
{ type: "distro.selected", distro: "Debian" },
{ type: "catalog.opened", defaultTarget: "Fedora", busy: false },
{ type: "distros.refresh.requested", busy: false },
] satisfies WslDialogEvent[]
).forEach((event) => {
expect(transitionWslDialog(running.state, event).state).toBe(running.state)
})
})
// Busy guards follow main's jobs and probes too.
expect(transitionWslDialog(main, { type: "wsl.install.requested", busy: true }).state).toBe(main)
expect(
transitionWslDialog(main, { type: "primary.requested", distro: "Ubuntu", action: "add", disabled: true }).state,
).toBe(main)
})
test("an add that never answers ends at its timeout, or when main goes away, so Cancel works again", () => {
const adding = transitionWslDialog(createWslDialogState(), primary("Ubuntu")).state
expect(view(adding).adding).toBe(true)
const timedOut = transitionWslDialog(adding, { type: "action.failed", attempt: 1 })
expect(timedOut.state).toEqual({ status: "main", issued: 1, selectedDistro: null })
expect(timedOut.effects).toEqual([
{ type: "action.abort", attempt: 1 },
{ type: "toast.error", cause: undefined },
])
expect(view(timedOut.state)).toMatchObject({ adding: false, busy: false })
const gone = transitionWslDialog(adding, { type: "availability.changed", available: false })
expect(gone.state).toEqual({ status: "main", issued: 1, selectedDistro: null })
expect(gone.effects).toEqual([{ type: "action.abort", attempt: 1 }])
expect(view(gone.state).adding).toBe(false)
// The aborted call rejects later; nothing more happens.
expect(transitionWslDialog(gone.state, { type: "action.failed", attempt: 1, cause: "aborted" }).state).toBe(
gone.state,
)
expect(transitionWslDialog(adding, { type: "availability.changed", available: true }).state).toBe(adding)
})
test("a call that settles after the dialog closed or moved on changes nothing", () => {
const installing = transitionWslDialog(catalog, {
type: "catalog.install.requested",
distro: "Fedora",
busy: false,
}).state
const adding = transitionWslDialog(createWslDialogState(), primary("Ubuntu")).state
;[installing, adding].forEach((running) => {
const disposed = transitionWslDialog(running, { type: "disposed" })
expect(disposed.effects).toEqual([{ type: "action.abort", attempt: 1 }])
;(
[
{ type: "action.succeeded", attempt: 1 },
{ type: "action.failed", attempt: 1, cause: new Error("unavailable") },
] as const
).forEach((event) => {
expect(transitionWslDialog(disposed.state, event)).toEqual({ state: disposed.state, effects: [] })
})
// An answer for another attempt is ignored too.
expect(transitionWslDialog(running, { type: "action.succeeded", attempt: 7 }).state).toBe(running)
expect(transitionWslDialog(running, { type: "action.failed", attempt: 7 }).state).toBe(running)
})
// A late answer for an install that already ended never pulls the user out of the catalog they reopened.
const reopened = replay(installing, [
{ type: "action.failed", attempt: 1, cause: new Error("busy") },
{ type: "catalog.closed", busy: false },
{ type: "catalog.opened", defaultTarget: "Arch", busy: false },
]).state
expect(transitionWslDialog(reopened, { type: "action.succeeded", attempt: 1 }).state).toBe(reopened)
})
test("each call ends in its own view", () => {
const searched = replay(catalog, [
{ type: "catalog.search.changed", value: "arch" },
{ type: "catalog.target.changed", distro: "Arch" },
]).state
const installing = transitionWslDialog(searched, { type: "catalog.install.requested", distro: "Arch", busy: false })
expect(transitionWslDialog(installing.state, { type: "action.succeeded", attempt: 1 })).toEqual({
state: { status: "main", issued: 1, selectedDistro: null },
effects: [{ type: "action.abort", attempt: 1 }],
})
// A failed install stays in the catalog with what the user searched and picked.
const cause = new Error("download failed")
expect(transitionWslDialog(installing.state, { type: "action.failed", attempt: 1, cause })).toEqual({
state: { status: "catalog", issued: 1, selectedDistro: null, catalogSearch: "arch", catalogTarget: "Arch" },
effects: [
{ type: "action.abort", attempt: 1 },
{ type: "toast.error", cause },
],
})
const selected = transitionWslDialog(createWslDialogState(), { type: "distro.selected", distro: "Ubuntu" }).state
const added = replay(selected, [primary("Ubuntu"), { type: "action.succeeded", attempt: 1 }])
expect(added.state).toEqual({ status: "completed", selectedDistro: "Ubuntu" })
expect(added.effects.slice(-2)).toEqual([{ type: "action.abort", attempt: 1 }, { type: "dialog.close" }])
// The dialog stays busy while it closes.
expect(view(added.state)).toMatchObject({ adding: true, busy: true })
expect(transitionWslDialog(added.state, { type: "distro.selected", distro: "Debian" }).state).toBe(added.state)
const refreshed = replay(createWslDialogState(), [
{ type: "distros.refresh.requested", busy: false },
{ type: "action.succeeded", attempt: 1 },
])
expect(refreshed.state).toEqual({ status: "main", issued: 1, selectedDistro: null })
})
})

View file

@ -0,0 +1,322 @@
/** Ceiling for the dialog's quick calls (`refreshDistros`, `addServer`), above main's 20 s `wsl.exe` timeout. */
export const WSL_ACTION_TIMEOUT_MS = 60_000
/**
* Ceiling for the install calls (`installWsl`, `installDistro`, `installOpencode`), in case the bridge hangs. Main
* stops an install after 15 minutes, and a distro install then lists and probes for up to another minute, so this
* waits a minute beyond that before the dialog gives up on the answer.
*/
export const WSL_INSTALL_TIMEOUT_MS = 17 * 60_000
/** A call to main the dialog's main view starts. */
export type WslMainOp =
| { readonly kind: "install-wsl" }
| { readonly kind: "refresh-distros" }
| { readonly kind: "install-opencode"; readonly distro: string }
| { readonly kind: "add-server"; readonly distro: string }
export type WslDialogAction = WslMainOp | { readonly kind: "install-distro"; readonly distro: string }
/**
* The window's side of the "Add WSL server" dialog: which view shows, and the call to main it waits for. A running
* variant claims the click before the call leaves, so the dialog is busy before main's state says so. `completed`
* keeps the dialog busy while it closes after a server was added. `issued` is the last attempt handed out.
*/
export type WslDialogState =
| { readonly status: "main"; readonly issued: number; readonly selectedDistro: string | null }
| {
readonly status: "main-running"
readonly issued: number
readonly selectedDistro: string | null
readonly attempt: number
readonly op: WslMainOp
}
| {
readonly status: "catalog"
readonly issued: number
readonly selectedDistro: string | null
readonly catalogSearch: string
readonly catalogTarget: string | null
}
| {
readonly status: "catalog-installing"
readonly issued: number
readonly selectedDistro: string | null
readonly catalogSearch: string
readonly catalogTarget: string | null
readonly attempt: number
readonly distro: string
}
| { readonly status: "completed"; readonly selectedDistro: string | null }
| { readonly status: "disposed" }
/** User actions carry what the dialog shows when they happen; `busy` also counts jobs main runs and probes. */
export type WslDialogEvent =
| { readonly type: "distro.selected"; readonly distro: string }
| { readonly type: "catalog.opened"; readonly defaultTarget: string | null; readonly busy: boolean }
| { readonly type: "catalog.closed"; readonly busy: boolean }
| { readonly type: "catalog.search.changed"; readonly value: string }
| { readonly type: "catalog.target.changed"; readonly distro: string }
| { readonly type: "catalog.install.requested"; readonly distro: string | null; readonly busy: boolean }
| { readonly type: "wsl.install.requested"; readonly busy: boolean }
| { readonly type: "distros.refresh.requested"; readonly busy: boolean }
| {
readonly type: "primary.requested"
readonly distro: string | null
readonly action: "install-opencode" | "add" | null
readonly disabled: boolean
}
| { readonly type: "action.succeeded"; readonly attempt: number }
/** The call rejected, or timed out without a `cause`. */
| { readonly type: "action.failed"; readonly attempt: number; readonly cause?: unknown }
/** WSL's main side went away or came back. */
| { readonly type: "availability.changed"; readonly available: boolean }
| { readonly type: "disposed" }
export type WslDialogEffect =
| {
readonly type: "action.run"
readonly attempt: number
readonly action: WslDialogAction
readonly timeoutMs: number
}
/** Ends an attempt's call and timer. */
| { readonly type: "action.abort"; readonly attempt: number }
/** Probes that failed may run again: the user asked to check again or left the catalog. */
| { readonly type: "probes.reset" }
| { readonly type: "dialog.close" }
| { readonly type: "toast.error"; readonly cause?: unknown }
export type WslDialogTransition = {
readonly state: WslDialogState
readonly effects: readonly WslDialogEffect[]
}
export function createWslDialogState(): WslDialogState {
return { status: "main", issued: 0, selectedDistro: null }
}
export function transitionWslDialog(state: WslDialogState, event: WslDialogEvent): WslDialogTransition {
if (state.status === "disposed") return unchanged(state)
if (event.type === "disposed")
return {
state: { status: "disposed" },
effects:
state.status === "main-running" || state.status === "catalog-installing"
? [{ type: "action.abort", attempt: state.attempt }]
: [],
}
if (state.status === "main") return transitionMain(state, event)
if (state.status === "catalog") return transitionCatalog(state, event)
if (state.status === "completed") return unchanged(state)
return transitionRunning(state, event)
}
function transitionMain(
state: Extract<WslDialogState, { status: "main" }>,
event: WslDialogEvent,
): WslDialogTransition {
if (event.type === "distro.selected") {
if (state.selectedDistro === event.distro) return unchanged(state)
return { state: { ...state, selectedDistro: event.distro }, effects: [] }
}
if (event.type === "catalog.opened") {
if (event.busy) return unchanged(state)
return {
state: {
status: "catalog",
issued: state.issued,
selectedDistro: state.selectedDistro,
catalogSearch: "",
catalogTarget: event.defaultTarget,
},
effects: [],
}
}
if (event.type === "wsl.install.requested") {
if (event.busy) return unchanged(state)
return run(state, { kind: "install-wsl" }, WSL_INSTALL_TIMEOUT_MS, [])
}
if (event.type === "distros.refresh.requested") {
if (event.busy) return unchanged(state)
return run(state, { kind: "refresh-distros" }, WSL_ACTION_TIMEOUT_MS, [{ type: "probes.reset" }])
}
if (event.type === "primary.requested") {
if (event.disabled || !event.distro || !event.action) return unchanged(state)
if (event.action === "install-opencode")
return run(state, { kind: "install-opencode", distro: event.distro }, WSL_INSTALL_TIMEOUT_MS, [])
return run(state, { kind: "add-server", distro: event.distro }, WSL_ACTION_TIMEOUT_MS, [])
}
return unchanged(state)
}
function transitionCatalog(
state: Extract<WslDialogState, { status: "catalog" }>,
event: WslDialogEvent,
): WslDialogTransition {
if (event.type === "catalog.closed") {
if (event.busy) return unchanged(state)
return {
state: { status: "main", issued: state.issued, selectedDistro: state.selectedDistro },
effects: [{ type: "probes.reset" }],
}
}
if (event.type === "catalog.search.changed") {
if (state.catalogSearch === event.value) return unchanged(state)
return { state: { ...state, catalogSearch: event.value }, effects: [] }
}
if (event.type === "catalog.target.changed") {
if (state.catalogTarget === event.distro) return unchanged(state)
return { state: { ...state, catalogTarget: event.distro }, effects: [] }
}
if (event.type === "catalog.install.requested") {
if (event.busy || !event.distro) return unchanged(state)
const attempt = state.issued + 1
return {
state: {
status: "catalog-installing",
issued: attempt,
selectedDistro: state.selectedDistro,
catalogSearch: state.catalogSearch,
catalogTarget: event.distro,
attempt,
distro: event.distro,
},
effects: [
{ type: "probes.reset" },
{
type: "action.run",
attempt,
action: { kind: "install-distro", distro: event.distro },
timeoutMs: WSL_INSTALL_TIMEOUT_MS,
},
],
}
}
return unchanged(state)
}
// A running call ends in its own view: an installed distro returns to the list, a failed install stays in the catalog.
function transitionRunning(
state: Extract<WslDialogState, { status: "main-running" | "catalog-installing" }>,
event: WslDialogEvent,
): WslDialogTransition {
const settled =
state.status === "main-running"
? { status: "main" as const, issued: state.issued, selectedDistro: state.selectedDistro }
: {
status: "catalog" as const,
issued: state.issued,
selectedDistro: state.selectedDistro,
catalogSearch: state.catalogSearch,
catalogTarget: state.catalogTarget,
}
// Main going away answers the call itself: the dialog shows WSL as unavailable, without a toast.
if (event.type === "availability.changed") {
if (event.available) return unchanged(state)
return { state: settled, effects: [{ type: "action.abort", attempt: state.attempt }] }
}
if (event.type !== "action.succeeded" && event.type !== "action.failed") return unchanged(state)
if (event.attempt !== state.attempt) return unchanged(state)
const abort: WslDialogEffect = { type: "action.abort", attempt: event.attempt }
if (event.type === "action.failed")
return { state: settled, effects: [abort, { type: "toast.error", cause: event.cause }] }
if (state.status === "catalog-installing")
return { state: { status: "main", issued: state.issued, selectedDistro: state.selectedDistro }, effects: [abort] }
if (state.op.kind === "add-server")
return {
state: { status: "completed", selectedDistro: state.selectedDistro },
effects: [abort, { type: "dialog.close" }],
}
return { state: settled, effects: [abort] }
}
function run(
state: Extract<WslDialogState, { status: "main" }>,
op: WslMainOp,
timeoutMs: number,
before: readonly WslDialogEffect[],
): WslDialogTransition {
const attempt = state.issued + 1
return {
state: { status: "main-running", issued: attempt, selectedDistro: state.selectedDistro, attempt, op },
effects: [...before, { type: "action.run", attempt, action: op, timeoutMs }],
}
}
function unchanged(state: WslDialogState): WslDialogTransition {
return { state, effects: [] }
}
/** Which view the dialog shows. */
export function selectWslDialogView(state: WslDialogState): "main" | "catalog" {
return state.status === "catalog" || state.status === "catalog-installing" ? "catalog" : "main"
}
/** The distro the user picked, before the view model falls back to a default one. */
export function selectWslDialogSelectedDistro(state: WslDialogState) {
return state.status === "disposed" ? null : state.selectedDistro
}
export function selectWslDialogCatalogSearch(state: WslDialogState) {
return state.status === "catalog" || state.status === "catalog-installing" ? state.catalogSearch : ""
}
export function selectWslDialogCatalogTarget(state: WslDialogState) {
return state.status === "catalog" || state.status === "catalog-installing" ? state.catalogTarget : null
}
/** The dialog waits for a call it started, or closes after one; its controls stay disabled meanwhile. */
export function selectWslDialogBusy(state: WslDialogState) {
return state.status === "main-running" || state.status === "catalog-installing" || state.status === "completed"
}
/** A server is being added: Cancel is disabled and Add server spins. */
export function selectWslDialogAdding(state: WslDialogState) {
return (state.status === "main-running" && state.op.kind === "add-server") || state.status === "completed"
}
export function selectWslDialogInstallingCatalog(state: WslDialogState) {
return state.status === "catalog-installing"
}
export function selectWslDialogInstallingOpencode(state: WslDialogState, distro: string | null) {
return (
!!distro && state.status === "main-running" && state.op.kind === "install-opencode" && state.op.distro === distro
)
}

View file

@ -5,11 +5,17 @@ import { Divider } from "@opencode/ui/divider"
import { Loader } from "@opencode/ui/loader"
import { RadioGroup, RadioItem } from "@opencode/ui/radio"
import { TextInput } from "@opencode/ui/text-input"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { batch, createMemo, createSignal, For, onCleanup, Show, untrack } from "solid-js"
import { showToast } from "@opencode/ui/toast"
import { usePlugin, type Context, type IpcClient } from "@opencode/plugin/gui"
import { createKeyed, usePlugin, type Context, type IpcClient } from "@opencode/plugin/gui"
import type { Wsl, WslServersState } from "./contract"
import {
createWslDialogState,
transitionWslDialog,
type WslDialogAction,
type WslDialogEffect,
type WslDialogEvent,
} from "./dialog-machine"
import { useWslAddServerProbes } from "./probes"
import { addServerViewModel, type AddServerText } from "./model"
@ -83,12 +89,12 @@ export function DialogAddWslServer(props: DialogWslServerProps) {
<Dialog fit class="settings-wsl-dialog">
<DialogHeader hideClose={true}>
<DialogTitle>
{controller.view() === "main" ? language.t("server.add") : language.t("onboarding.installDistro")}
{model().view === "main" ? language.t("server.add") : language.t("onboarding.installDistro")}
</DialogTitle>
</DialogHeader>
<Divider />
<Show
when={controller.view() === "main"}
when={model().view === "main"}
fallback={
<>
<DialogBody class="settings-wsl-dialog-body settings-wsl-catalog-picker">
@ -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) {
</DialogBody>
<DialogFooter>
<Button variant="neutral" disabled={controller.adding()} onClick={controller.close}>
<Button variant="neutral" disabled={model().adding} onClick={controller.close}>
{language.t("common.cancel")}
</Button>
<Button
@ -261,32 +267,15 @@ function useWslAddServerController(props: DialogWslServerProps) {
return client
}
const [store, setStore] = createStore<{
view: "main" | "catalog"
selectedDistro: string | null
catalogSearch: string
catalogTarget: string | null
adding: boolean
}>({
view: "main",
selectedDistro: null,
catalogSearch: "",
catalogTarget: null,
adding: false,
})
// The machine state is immutable; each transition replaces it whole.
const [dialogState, setDialogState] = createSignal(createWslDialogState())
// Each running call's abort controller and timeout.
const runs = new Map<number, { controller: AbortController; timer: ReturnType<typeof setTimeout> }>()
const current = createMemo(() => (props.api ? props.state : unavailable(language.t("error.unavailable"))))
const viewModel = (probingAddable: boolean) =>
addServerViewModel({
state: current(),
view: store.view,
selectedDistro: store.selectedDistro,
catalogSearch: store.catalogSearch,
catalogTarget: store.catalogTarget,
adding: store.adding,
probingAddable,
})
addServerViewModel({ state: current(), dialog: dialogState(), probingAddable })
const baseModel = createMemo(() => viewModel(false))
@ -294,8 +283,7 @@ function useWslAddServerController(props: DialogWslServerProps) {
state: current,
api,
available: () => !!props.api,
view: () => store.view,
adding: () => store.adding,
view: () => baseModel().view,
busy: () => baseModel().busy,
selectedDistro: () => baseModel().selectedDistro,
addableInstalledDistros: () => baseModel().addableInstalledDistros,
@ -304,94 +292,104 @@ function useWslAddServerController(props: DialogWslServerProps) {
const model = createMemo(() => viewModel(probes.probingAddable()))
const openCatalog = () => {
const first = model().installableDistros[0]
setStore({
view: "catalog",
catalogSearch: "",
catalogTarget: first?.name ?? null,
const dispatch = (event: WslDialogEvent) => {
const previous = untrack(dialogState)
const next = transitionWslDialog(previous, event)
// One batch, so the probes see the dialog busy in the same update that clears their failures, and never start a
// probe beside the call.
batch(() => {
if (next.state !== previous) setDialogState(next.state)
next.effects.forEach(
(effect) => void Promise.try(() => run(effect)).catch((cause) => requestError(language, cause)),
)
})
}
const run = async <T,>(action: () => Promise<T>) => {
try {
await action()
} catch (err) {
requestError(language, err)
}
}
const run = (effect: WslDialogEffect) => {
if (effect.type === "probes.reset") return probes.resetProbeFailure()
const refreshDistros = () => {
void run(async () => {
probes.resetProbeFailure()
await api().refreshDistros()
})
}
if (effect.type === "dialog.close") return dialog.close()
const installDistro = (name: string) => {
void run(async () => {
probes.resetProbeFailure()
await api().installDistro({ name })
setStore("view", "main")
})
}
if (effect.type === "toast.error") return requestError(language, effect.cause)
const installCatalogDistro = () => {
if (model().installingCatalogDistro) return
const name = model().catalogTarget
if (effect.type === "action.abort") {
const running = runs.get(effect.attempt)
if (!name) return
installDistro(name)
}
const closeCatalog = () => {
probes.resetProbeFailure()
setStore({ view: "main", catalogSearch: "", catalogTarget: null })
}
const runPrimary = async () => {
const button = model().primaryButton
if (button.loading) return
const distro = model().selectedDistro
const action = button.action
if (!distro || !action) return
if (action === "install-opencode") {
await run(() => api().installOpencode({ name: distro }))
runs.delete(effect.attempt)
clearTimeout(running?.timer)
running?.controller.abort()
return
}
setStore("adding", true)
const attempt = effect.attempt
const controller = new AbortController()
try {
await api().addServer({ distro })
dialog.close()
} catch (err) {
requestError(language, err)
} finally {
setStore("adding", false)
const settle = (event: WslDialogEvent) => {
if (runs.has(attempt)) dispatch(event)
}
const fail = (cause?: unknown) => settle({ type: "action.failed", attempt, cause })
runs.set(attempt, { controller, timer: setTimeout(() => fail(), effect.timeoutMs) })
void Promise.try(() => call(effect.action, AbortSignal.any([language.signal, controller.signal]))).then(
() => settle({ type: "action.succeeded", attempt }),
fail,
)
}
const call = async (action: WslDialogAction, signal: AbortSignal) => {
const client = api()
if (action.kind === "install-wsl") return client.installWsl({ signal })
if (action.kind === "refresh-distros") return client.refreshDistros({ signal })
if (action.kind === "install-distro") return client.installDistro({ name: action.distro }, { signal })
if (action.kind === "install-opencode") return client.installOpencode({ name: action.distro }, { signal })
// The server shows up in main's state; the dialog needs only to know it was saved.
await client.addServer({ distro: action.distro }, { signal })
}
// WSL's main side going away ends the call the dialog waits for; the dialog then shows WSL as unavailable.
createKeyed(
() => props.api,
() => dispatch({ type: "availability.changed", available: true }),
{ otherwise: () => dispatch({ type: "availability.changed", available: false }) },
)
onCleanup(() => dispatch({ type: "disposed" }))
return {
state: current,
model,
runtimeError: () => current()?.runtime?.error ?? null,
view: () => store.view,
catalogSearch: () => store.catalogSearch,
adding: () => store.adding,
setCatalogSearch: (value: string) => setStore("catalogSearch", value),
setCatalogTarget: (value: string) => setStore("catalogTarget", value),
setSelectedDistro: (value: string) => setStore("selectedDistro", value),
openCatalog,
closeCatalog,
refreshDistros,
installCatalogDistro,
installWsl: () => void run(() => api().installWsl()),
runPrimary: () => void runPrimary(),
setCatalogSearch: (value: string) => dispatch({ type: "catalog.search.changed", value }),
setCatalogTarget: (distro: string) => dispatch({ type: "catalog.target.changed", distro }),
setSelectedDistro: (distro: string) => dispatch({ type: "distro.selected", distro }),
openCatalog: () =>
dispatch({
type: "catalog.opened",
defaultTarget: model().installableDistros[0]?.name ?? null,
busy: model().busy,
}),
closeCatalog: () => dispatch({ type: "catalog.closed", busy: model().busy }),
refreshDistros: () => dispatch({ type: "distros.refresh.requested", busy: model().busy }),
installCatalogDistro: () =>
dispatch({ type: "catalog.install.requested", distro: model().catalogTarget, busy: model().busy }),
installWsl: () => dispatch({ type: "wsl.install.requested", busy: model().busy }),
runPrimary: () => {
const button = model().primaryButton
dispatch({
type: "primary.requested",
distro: model().selectedDistro,
action: button.action,
disabled: button.disabled || button.loading,
})
},
close: () => dialog.close(),
}
}
@ -469,12 +467,13 @@ function DialogWslSetup(props: {
)
}
// A call that timed out has no cause; its toast says only that the request failed.
function requestError(language: Context, cause: unknown) {
console.error("WSL servers request failed", cause instanceof Error ? (cause.stack ?? cause.message) : String(cause))
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: cause instanceof Error ? cause.message : String(cause),
description: cause === undefined ? undefined : cause instanceof Error ? cause.message : String(cause),
})
}

View file

@ -1,5 +1,16 @@
import fuzzysort from "fuzzysort"
import type { WslInstalledDistro, WslOnlineDistro, WslOpencodeCheck, WslServersState } from "./contract"
import {
selectWslDialogAdding,
selectWslDialogBusy,
selectWslDialogCatalogSearch,
selectWslDialogCatalogTarget,
selectWslDialogInstallingCatalog,
selectWslDialogInstallingOpencode,
selectWslDialogSelectedDistro,
selectWslDialogView,
type WslDialogState,
} from "./dialog-machine"
export type AddServerText = {
key: string
@ -62,27 +73,34 @@ function wslDistroReady(state: WslServersState | undefined, name: string) {
export function addServerViewModel(input: {
state: WslServersState | undefined
view: WslAddServerView
selectedDistro: string | null
catalogSearch: string
catalogTarget: string | null
adding: boolean
dialog: WslDialogState
probingAddable: boolean
}) {
const state = input.state
const catalogSearch = selectWslDialogCatalogSearch(input.dialog)
const visibleInstalledDistros = (state?.installed ?? []).filter((item) => !isHiddenDistro(item.name))
const visibleOnlineDistros = (state?.online ?? []).filter((item) => !isHiddenDistro(item.name))
const existingServerDistros = new Set((state?.servers ?? []).map((item) => item.config.distro))
const addableInstalledDistros = visibleInstalledDistros.filter((item) => !existingServerDistros.has(item.name))
const selectedDistro = addServerSelectedDistro(input.selectedDistro, visibleInstalledDistros, addableInstalledDistros)
const selectedDistro = addServerSelectedDistro(
selectWslDialogSelectedDistro(input.dialog),
visibleInstalledDistros,
addableInstalledDistros,
)
const opencodeCheck = selectedDistro ? (state?.opencodeChecks[selectedDistro] ?? null) : null
const installableDistros = addServerInstallableDistros(visibleInstalledDistros, visibleOnlineDistros)
const filteredInstallableDistros = addServerFilteredInstallableDistros(installableDistros, input.catalogSearch)
const catalogTarget = addServerCatalogTarget(input.catalogTarget, filteredInstallableDistros)
const busy = (!!state && dialogJobRunning(state)) || input.adding || input.probingAddable
const filteredInstallableDistros = addServerFilteredInstallableDistros(installableDistros, catalogSearch)
const catalogTarget = addServerCatalogTarget(selectWslDialogCatalogTarget(input.dialog), filteredInstallableDistros)
// The dialog's own call counts from the click, before main's state shows the job.
const working = (!!state && dialogJobRunning(state)) || selectWslDialogBusy(input.dialog)
return {
busy,
view: selectWslDialogView(input.dialog),
catalogSearch,
adding: selectWslDialogAdding(input.dialog),
busy: working || input.probingAddable,
runtimeState: addServerRuntimeState(state),
visibleInstalledDistros,
visibleOnlineDistros,
@ -103,13 +121,16 @@ export function addServerViewModel(input: {
state,
selectedDistro,
opencodeCheck,
adding: input.adding,
busy: working,
installingOpencode: selectWslDialogInstallingOpencode(input.dialog, selectedDistro),
adding: selectWslDialogAdding(input.dialog),
probingAddable: input.probingAddable,
}),
installableDistros,
filteredInstallableDistros,
catalogTarget,
installingCatalogDistro: Object.values(state?.distroJobs ?? {}).includes("installing"),
installingCatalogDistro:
selectWslDialogInstallingCatalog(input.dialog) || Object.values(state?.distroJobs ?? {}).includes("installing"),
}
}
@ -190,15 +211,18 @@ function addServerPrimaryButton(input: {
state: WslServersState | undefined
selectedDistro: string | null
opencodeCheck: WslOpencodeCheck | null
/** Main runs a job the dialog waits for, or the dialog waits for its own call. */
busy: boolean
/** The dialog asked main to install OpenCode in the selected distro and waits for the answer. */
installingOpencode: boolean
adding: boolean
probingAddable: boolean
}): AddServerPrimaryButton {
const ready = !!input.selectedDistro && wslDistroReady(input.state, input.selectedDistro)
const probingSelected = input.probingAddable && !addServerSelectedDistroSettled(input.state, input.selectedDistro)
const job = input.selectedDistro ? input.state?.distroJobs[input.selectedDistro] : undefined
const busy = (!!input.state && dialogJobRunning(input.state)) || input.adding
const probingOpencode = probingSelected || (ready && (!input.opencodeCheck || job === "probing"))
const installingOpencode = job === "updating"
const installingOpencode = job === "updating" || input.installingOpencode
if (!ready || probingOpencode) {
return {
@ -221,7 +245,7 @@ function addServerPrimaryButton(input: {
: update
? { key: "onboarding.updateOpencode" }
: { key: "onboarding.installOpencode" },
disabled: busy,
disabled: input.busy,
action: "install-opencode",
loading: installingOpencode,
width: update ? "138px" : "129px",
@ -231,7 +255,7 @@ function addServerPrimaryButton(input: {
return {
variant: "contrast",
label: input.adding ? { key: "onboarding.adding" } : { key: "server.add" },
disabled: busy,
disabled: input.busy,
action: "add",
loading: input.adding,
width: null,
@ -281,13 +305,14 @@ function addServerCatalogTarget(target: string | null, distros: WslOnlineDistro[
function addableProbePlan(input: {
state: WslServersState | undefined
view: WslAddServerView
adding: boolean
busy: boolean
selectedDistro: string | null
addableInstalledDistros: WslInstalledDistro[]
}) {
const state = input.state
if (!state?.runtime?.available || state.pendingRestart || input.view !== "main" || input.adding) return
// `busy` also covers the dialog's own call, which starts before main's state shows it.
if (!state?.runtime?.available || state.pendingRestart || input.view !== "main" || input.busy) return
if (dialogJobRunning(state)) return
@ -331,7 +356,6 @@ function autoProbePlan(input: { state: WslServersState | undefined; busy: boolea
export function addServerProbePlan(input: {
state: WslServersState | undefined
view: WslAddServerView
adding: boolean
busy: boolean
selectedDistro: string | null
addableInstalledDistros: WslInstalledDistro[]

View file

@ -42,7 +42,6 @@ function input(state: WslServersState, selectedDistro: string | null = null): Ws
plan: addServerProbePlan({
state,
view: "main",
adding: false,
busy,
selectedDistro,
addableInstalledDistros: addable,

View file

@ -7,7 +7,7 @@ export const WSL_PROBE_TIMEOUT_MS = 60_000
export type WslProbeInput = {
/** The probe the dialog needs next, if any. */
readonly plan: AddServerProbePlan | undefined
/** Main runs a job the dialog waits for, or the dialog adds a server. */
/** Main runs a job the dialog waits for, or the dialog waits for its own call. */
readonly busy: boolean
/** WSL's main side answers. */
readonly available: boolean

View file

@ -18,7 +18,7 @@ export function useWslAddServerProbes(input: {
/** WSL's main side answers. */
available: Accessor<boolean>
view: Accessor<WslAddServerView>
adding: Accessor<boolean>
/** Main runs a job the dialog waits for, or the dialog waits for its own call. */
busy: Accessor<boolean>
selectedDistro: Accessor<string | null>
addableInstalledDistros: Accessor<WslInstalledDistro[]>
@ -34,7 +34,6 @@ export function useWslAddServerProbes(input: {
plan: addServerProbePlan({
state: input.state(),
view: input.view(),
adding: input.adding(),
busy: input.busy(),
selectedDistro: input.selectedDistro(),
addableInstalledDistros: input.addableInstalledDistros(),

View file

@ -11,10 +11,11 @@ import "@/settings/settings.css"
/**
* A local server and pairing main entry. Remote access starts on or off; turning it on takes a moment, after which
* the server advertises its tunnel, as the background service does once OpenTunnel attaches. `fails` makes turning
* it on fail, as when the tunnel cannot be created.
* it on fail, as when the tunnel cannot be created. `screenFails` makes the page's first read of the display sleep
* setting and its three retries fail; later reads and changes work.
*/
export function pairingRemote(input: { enabled: boolean; fails?: boolean }) {
const state = { enabled: input.enabled }
export function pairingRemote(input: { enabled: boolean; fails?: boolean; screenFails?: boolean }) {
const state = { enabled: input.enabled, screen: false, screenFailures: input.screenFails ? 4 : 0 }
const server: PairingServer = {
client: {
server: {
@ -36,8 +37,17 @@ export function pairingRemote(input: { enabled: boolean; fails?: boolean }) {
if (input.fails) throw new Error("Timed out creating the remote access tunnel")
state.enabled = enabled
},
screenActive: async () => false,
setScreenActive: async () => undefined,
screenActive: async () => {
if (state.screenFailures > 0) {
state.screenFailures -= 1
throw new Error("The display sleep setting could not be read")
}
return state.screen
},
setScreenActive: async (enabled) => {
state.screen = enabled
},
state: () => undefined,
on: () => () => undefined,
}