mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 09:43:38 +00:00
fix(gui-plugins): explicit machines for Keep screen active and the WSL add dialog (#62108)
This commit is contained in:
parent
17100fc529
commit
61695d15bd
15 changed files with 1353 additions and 247 deletions
26
packages/app/component-tests/pairing.spec.ts
Normal file
26
packages/app/component-tests/pairing.spec.ts
Normal 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()
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
299
packages/gui-plugins/src/wsl/dialog-machine.test.ts
Normal file
299
packages/gui-plugins/src/wsl/dialog-machine.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
322
packages/gui-plugins/src/wsl/dialog-machine.ts
Normal file
322
packages/gui-plugins/src/wsl/dialog-machine.ts
Normal 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
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ function input(state: WslServersState, selectedDistro: string | null = null): Ws
|
|||
plan: addServerProbePlan({
|
||||
state,
|
||||
view: "main",
|
||||
adding: false,
|
||||
busy,
|
||||
selectedDistro,
|
||||
addableInstalledDistros: addable,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue