diff --git a/CHANGELOG.md b/CHANGELOG.md index b760266ed..0fd491e51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,15 @@ file is the human-readable summary of what changed and why. ### Desktop +- **A postponed update no longer hides the ones after it** — answering + **Later** to an update left the app holding on to that version: every later + check, including the one it runs every ten minutes, came back with the same + answer without asking the feed, so a release published while the app stayed + open was invisible until the next relaunch. Checks now ask the feed again, + and a newer version is announced the same way the first one was. A check + that fails while an update is already known keeps that update on offer + rather than withdrawing it. + - **Sign-in completes behind a corporate proxy** — on Windows machines that reach the internet through a corporate proxy, **Login with SSO** worked all the way to choosing a model and then landed back on **Set up CoDev**, with diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 4097191f4..17727c17e 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -254,7 +254,7 @@ from the squash message first, or use a plain merge. | `packages/app/src/context/server.tsx` | `createServerProjects.close()` wraps its two store writes — `remove(directory)` on `projects`, then the `recentlyClosed` record — in `batch()` (already imported). The recently-closed list is computed before the batch so the pair is one update. | The two writes are separate store keys and Solid flushes effects after each one, so between them a project is in neither list. Nothing upstream observes that gap, but the fork's `src/sandbox/projects.ts` seeding effect reconciles against **both** lists, saw a mounted folder that was neither open nor closed, and opened it straight back up — closing a Work folder was undone the instant it happened. | Keep the batch. On conflict take upstream's `close()` and re-wrap the two writes. Any future write pair that spans `projects` and `recentlyClosed` needs the same treatment; regression cover is `packages/app/test-browser/sandbox-projects.test.ts` ("closing a mounted folder sticks when the guest knows the project"). | | WSL auto-install from the sandbox dialogs: `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts` | i18n gains the `sandbox.wsl.*` family (install action/retry, the permission-and-size hint, download progress with and without a content-length, the elevated step, restart-required, the restart-Windows action with its confirm step, and the failure line). preload exposes five channels on the existing `api.sandboxes` object — `sandbox-wsl-install{,-cancel,-state}`, a `sandbox-wsl-install-subscribe`/`-event` pair, and `sandbox-wsl-restart` (runs `shutdown.exe /r /t 0` for the restart a restart-required install asks for; the renderer confirms first) — and re-exports `WslInstallState`. All the logic is fork-only: `src/main/sandbox/wsl-install.ts` (the state machine), `src/main/wsl/install-bundle.ts` (manifest-driven per-arch MSI acquisition), `src/app/sandbox/wsl-install.tsx` (the shared UI both create-sandbox dialogs render in place of their form). | A Windows machine without WSL could not create a sandbox and was told to go install it by hand — while the app already shipped an installer for the Code side's WSL servers. The sandbox needs only the runtime (it imports its own rootfs and mounts its own server), so this reuses `installWslRuntimeDualPath` and skips the distro and CoDev-in-distro steps entirely. Its own channel set rather than a second `sandbox-event` type because this is platform state that exists before any sandbox does, and it lives in main rather than in the dialog because a ~250MB download on a whitelisted-proxy link must survive the dialog closing. | Keep the key family and the five channels; take upstream's surrounding changes. The channel names are preload/renderer contract — renaming one side alone breaks the dialogs silently. If upstream reworks `api.sandboxes`, keep the install methods on it rather than promoting them to a new top-level namespace: the renderer reaches them through `platform.sandboxes`. | -| Update checking and download controls: `packages/app/src/updater.ts`, `packages/app/src/components/{updater-action.ts,settings-general.tsx,settings-v2/general.tsx}`, `packages/app/src/pages/layout.tsx`, `packages/app/src/i18n/{en,es,vi}.ts`, `packages/desktop/src/main/{updater.ts,updater-controller.ts,index.ts,ipc.ts}`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | `UpdaterState` gains `available` (found, not fetched) and the shared `UpdaterPrefs` — `autoCheck` defaulting true, `autoDownload` defaulting **false**. The controller takes `initialPrefs`/`savePrefs`, stops a check at `available` when `autoDownload` is off, gains `download()` for the consent step and `downloadAndInstall()` for "Update now" (joins a download already in flight rather than starting a second, re-checks its way out of the version-less error state so the dialog's retry is not inert, otherwise fetches, then installs), gains `getPrefs`/`setPrefs`/`subscribePrefs`, skips the launch check when `autoCheck` is off, and short-circuits a repeat check while `available`. `download`/`install` moved out of the returned object literal into consts above it so `downloadAndInstall` can call them. `main/updater.ts` reads and writes the prefs in the existing `codev.updater` store under a `prefs` key; `main/index.ts`'s ten-minute timer consults `getPrefs().autoCheck` at fire time; `ipc.ts` adds `updater-download`, `updater-download-install` and `updater-set-prefs` and registers a second listener on the existing `updater-subscribe` so prefs broadcast to every window; preload and `renderer/index.tsx` expose `download`, `downloadAndInstall`, `setPrefs`, `subscribePrefs`. The two pref switches are `SettingsRowV2` rows in **`settings-v2/general.tsx`** — the screen every settings entry point actually resolves to — guarded by `platform.updater`, since the Updates section is not desktop-gated and the web build must not render them; the matching `SettingsRow` pair in the legacy `settings-general.tsx` is kept in step but is unreachable (see the retired-legacy-layout row). The legacy layout's toast offers Download when the update is not on disk, and the titlebar pill deliberately stays `ready`-only — it installs, and an undownloaded update cannot be installed. i18n drops the dead `settings.updates.row.startup.*` keys (translated, never rendered) for `settings.updates.row.{autoCheck,autoDownload}.*`, `settings.updates.action.download` and `toast.update.description.available`. The `downloading` state additionally carries a whole-number `percent`: the controller gains `reportDownloadProgress(percent)` (floored, no-op unless currently `downloading` and the whole percent moved — the backend reports every chunk and each transition is a log line plus an IPC send to every window), and `main/updater.ts` feeds it from `autoUpdater.on("download-progress")`. The fork-only update dialog (`dialog-update.tsx`) is the consent flow itself: on the launch check it announces the found version and asks **Update now / Later**; **Update now** calls `downloadAndInstall()` once and the dialog reports the download percent and then the install while the app restarts itself; **Later** does not close the dialog but swaps its body for where to find the update again and its footer for a single OK. While the update is running the neutral slot becomes Close, since the download outlives the dialog. i18n adds `toast.update.description.{downloading,installing,later}`, `toast.update.action.{updateNow,later}` and `common.ok`. | Upstream checks and downloads unconditionally. The fleet is on metered, whitelist-only links where a several-hundred-megabyte installer arriving unannounced is a real cost, and the app already shipped the copy for a setting it never built. `autoDownload` ships **off** because that is the only ordering in which "do you want to update now?" can be asked: with it on, the launch check has already spent the bytes by the time any dialog opens. Prefs live in the main process because that is where the timer runs; putting them in the renderer's settings store would have meant main parsing the renderer's persisted format. The switches originally shipped in `settings-general.tsx` alone, which nothing in this fork can open, so both settings were unchangeable and the `available` state they gate was dead in every build — the pair reaching settings-v2 is what makes the rest of this row real rather than nominal. | Keep the `available` state and the prefs plumbing together — the state is unreachable without `autoDownload`, and the setting is meaningless without the state. Take upstream's other changes to these files. If upstream reworks `createUpdaterController`, re-apply the two gates (`autoCheck` in `start()`, `autoDownload` in `check()`) and `downloadAndInstall` rather than the whole diff; all three are covered by `updater-controller.test.ts`. **`autoDownload` must stay `false` and `autoCheck` `true`**: the update prompt announces what the launch check found and asks before spending anything, which needs the check to run and then stop at `available`. Do not "restore" both to true to match upstream — that silently turns the prompt back into a report of a download already underway. Any new updater switch goes in `settings-v2/general.tsx`; adding one only to `settings-general.tsx` ships nothing. | +| Update checking and download controls: `packages/app/src/updater.ts`, `packages/app/src/components/{updater-action.ts,settings-general.tsx,settings-v2/general.tsx}`, `packages/app/src/pages/layout.tsx`, `packages/app/src/i18n/{en,es,vi}.ts`, `packages/desktop/src/main/{updater.ts,updater-controller.ts,index.ts,ipc.ts}`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | `UpdaterState` gains `available` (found, not fetched) and the shared `UpdaterPrefs` — `autoCheck` defaulting true, `autoDownload` defaulting **false**. The controller takes `initialPrefs`/`savePrefs`, stops a check at `available` when `autoDownload` is off, gains `download()` for the consent step and `downloadAndInstall()` for "Update now" (joins a download already in flight rather than starting a second, re-checks its way out of the version-less error state so the dialog's retry is not inert, otherwise fetches, then installs), gains `getPrefs`/`setPrefs`/`subscribePrefs`, skips the launch check when `autoCheck` is off, and re-asks the feed from `available` rather than returning the known version (a newer version replaces it, a withdrawn one lands on `up-to-date`, and a re-check that fails keeps the known version instead of withdrawing it as `error`). `download`/`install` moved out of the returned object literal into consts above it so `downloadAndInstall` can call them. `main/updater.ts` reads and writes the prefs in the existing `codev.updater` store under a `prefs` key; `main/index.ts`'s ten-minute timer consults `getPrefs().autoCheck` at fire time; `ipc.ts` adds `updater-download`, `updater-download-install` and `updater-set-prefs` and registers a second listener on the existing `updater-subscribe` so prefs broadcast to every window; preload and `renderer/index.tsx` expose `download`, `downloadAndInstall`, `setPrefs`, `subscribePrefs`. The two pref switches are `SettingsRowV2` rows in **`settings-v2/general.tsx`** — the screen every settings entry point actually resolves to — guarded by `platform.updater`, since the Updates section is not desktop-gated and the web build must not render them; the matching `SettingsRow` pair in the legacy `settings-general.tsx` is kept in step but is unreachable (see the retired-legacy-layout row). The legacy layout's toast offers Download when the update is not on disk, and the titlebar pill deliberately stays `ready`-only — it installs, and an undownloaded update cannot be installed. i18n drops the dead `settings.updates.row.startup.*` keys (translated, never rendered) for `settings.updates.row.{autoCheck,autoDownload}.*`, `settings.updates.action.download` and `toast.update.description.available`. The `downloading` state additionally carries a whole-number `percent`: the controller gains `reportDownloadProgress(percent)` (floored, no-op unless currently `downloading` and the whole percent moved — the backend reports every chunk and each transition is a log line plus an IPC send to every window), and `main/updater.ts` feeds it from `autoUpdater.on("download-progress")`. The fork-only update dialog (`dialog-update.tsx`) is the consent flow itself: on the launch check it announces the found version and asks **Update now / Later**, once per version per app run, so a newer release the periodic re-check finds after a **Later** is announced again; **Update now** calls `downloadAndInstall()` once and the dialog reports the download percent and then the install while the app restarts itself; **Later** does not close the dialog but swaps its body for where to find the update again and its footer for a single OK. While the update is running the neutral slot becomes Close, since the download outlives the dialog. i18n adds `toast.update.description.{downloading,installing,later}`, `toast.update.action.{updateNow,later}` and `common.ok`. | Upstream checks and downloads unconditionally. The fleet is on metered, whitelist-only links where a several-hundred-megabyte installer arriving unannounced is a real cost, and the app already shipped the copy for a setting it never built. `autoDownload` ships **off** because that is the only ordering in which "do you want to update now?" can be asked: with it on, the launch check has already spent the bytes by the time any dialog opens. Prefs live in the main process because that is where the timer runs; putting them in the renderer's settings store would have meant main parsing the renderer's persisted format. The switches originally shipped in `settings-general.tsx` alone, which nothing in this fork can open, so both settings were unchangeable and the `available` state they gate was dead in every build — the pair reaching settings-v2 is what makes the rest of this row real rather than nominal. | Keep the `available` state and the prefs plumbing together — the state is unreachable without `autoDownload`, and the setting is meaningless without the state. Take upstream's other changes to these files. If upstream reworks `createUpdaterController`, re-apply the two gates (`autoCheck` in `start()`, `autoDownload` in `check()`), the `available` re-check and `downloadAndInstall` rather than the whole diff; all four are covered by `updater-controller.test.ts`. Do not reinstate an early return from `check()` while `available` — it made a postponed update hide every release after it until relaunch. **`autoDownload` must stay `false` and `autoCheck` `true`**: the update prompt announces what the launch check found and asks before spending anything, which needs the check to run and then stop at `available`. Do not "restore" both to true to match upstream — that silently turns the prompt back into a report of a download already underway. Any new updater switch goes in `settings-v2/general.tsx`; adding one only to `settings-general.tsx` ships nothing. | | `packages/app/src/context/global.tsx` | Two lines: imports `seedSandboxProjects` from the fork-only `packages/app/src/sandbox/projects.ts` and calls it with `server.list` + `ensureServerCtx`, beside the memo that already creates a ctx per connection. All the logic lives in the fork-only module. | A sandbox exposes exactly one directory (`/workspace` on vz/bwrap, the wslpath translation on WSL) and the guest registers it as its own project, but the app's per-server project list starts empty and nothing ever fills it. With no project, every "New session" entry point — `home-model.openNewSession`, the titlebar's `openNewTab` fallback chain — finds nothing for that server and falls back to another one, so the sandbox looks selected while the session runs on the local server. The seed reads `sync.data.path.directory`, which every server ctx already fetches, so it stays backend-agnostic. The same module also names that project after the sandbox's host folder (via `client.project.update`, the call behind rename-project), because the guest sees only its mount point and every sandbox would otherwise read "workspace" in the sidebar, tabs and palette. | Keep both lines; take upstream's other changes to the file. The call must stay inside the Global context init (it needs `ensureServerCtx`) — `SandboxesProvider` is mounted above `GlobalProvider` in `app.tsx`, so it cannot live there. | | `packages/app/src/pages/layout/project-avatar-state.ts` | Both `ensureServerState` lookups are gated on `connection()`: `unread` before `notification.ensureServerState`, and the `permissionState` memo before `permission.ensureServerState` (which also moves below `connection`, and its consumer in `hasPermissions` handles the undefined state). Matches the guard `hasPermissions`/`hasQuestions`/`loading` already apply to `sync()`. | `ensureServerState` throws on a key absent from the connection list, and the throw propagates out of a render-time memo, killing the whole renderer. Sidecar connections are listed only while healthy — `readySandboxConnections`/`readyWslConnections` publish a sandbox or WSL server only in the `ready` state — so any removal, restart, or crash of one drops its key while its session tabs are still mounted. Reproduced by removing a sandbox that had an open session tab: `Error: Notification server not found: sandbox:`. The permission lookup is the sharper of the two because `createMemo` computes eagerly: for a tab mounted after its server disappeared it throws during construction, before any downstream guard runs — `Error: Permission server not found: sandbox:`, reproduced by deleting a sandbox from Settings while the Work side was open. | Keep the guard; take upstream's other changes to the hook. This is an upstream bug (WSL servers hit the identical path), so if upstream fixes it their way, drop ours in favour of theirs. | @@ -338,7 +338,7 @@ from the squash message first, or use a plain merge. | Startup update prompt (terminal): `packages/opencode/src/cli/cmd/tui.ts`, `packages/opencode/src/cli/upgrade.ts`, `packages/tui/src/app.tsx` | `TuiInput` gains `onReady?: () => void`; `run` forwards it to ``, which calls it once its `event.on(...)` handlers are registered. `cmd/tui.ts` drops the `setTimeout(..., 1000)` around `client.call("checkUpgrade")` and passes that call as `onReady` instead. `upgrade()` replaces `const kind = getReleaseType(...)` / `if (config.autoupdate === "notify" || kind !== "patch")` with `const silent = config.autoupdate === true && getReleaseType(...) === "patch"` / `if (!silent)`, so a newer version notifies unless the user opted into automatic patch installs. `autoupdate: false` and `CODEV_DISABLE_AUTOUPDATE` still skip the check, and `CODEV_ALWAYS_NOTIFY_UPDATE` is untouched. | The startup check announced its result with the `installation.update-available` event, which the TUI has had a confirm dialog for all along — but nobody ever saw it. `Rpc.emit` is a bare `postMessage`, the TUI's handlers are registered while it renders, and the check on a one-second timer resolved a few hundred milliseconds before that: the event was delivered into an empty handler set and lost, every launch. Ordering the check behind the TUI instead of behind a timer is the only version of this that cannot race. The second change is about consent: upstream's default installs a patch release in place, unasked, which on this fork's fleet is a metered download and a version change nobody agreed to. | Keep `onReady` and the `onReady`-triggered `checkUpgrade`; take upstream's other changes to all three files. Do not restore the timer — it is the bug. If upstream moves the App's event handlers, keep the `props.onReady?.()` call after the last of them. | -| `packages/app/src/pages/layout-new.tsx` (update prompt) | One import and one element: mounts the fork-only `src/components/dialog-update.tsx`, whose `` renders nothing and opens the update dialog the first time the desktop updater reports `available` or `ready`. | The main process has always checked for updates at launch (`updater.start()`), but on the new layout the only thing that ever said so was a small Update button in the titlebar, and only once the download had finished. Users went weeks without noticing a waiting update. The legacy layout's `UpdateAvailableToast` in `pages/layout.tsx` is unreachable — `newLayoutDesigns` is always true. | Keep the import and the element; take upstream's other changes. If upstream reworks this file's layout, the element can sit anywhere inside the shell: it renders nothing. | +| `packages/app/src/pages/layout-new.tsx` (update prompt) | One import and one element: mounts the fork-only `src/components/dialog-update.tsx`, whose `` renders nothing and opens the update dialog the first time the desktop updater reports each version as `available` or `ready` (tracked per version, not once per run, so a newer release found after **Later** is announced too). | The main process has always checked for updates at launch (`updater.start()`), but on the new layout the only thing that ever said so was a small Update button in the titlebar, and only once the download had finished. Users went weeks without noticing a waiting update. The legacy layout's `UpdateAvailableToast` in `pages/layout.tsx` is unreachable — `newLayoutDesigns` is always true. | Keep the import and the element; take upstream's other changes. If upstream reworks this file's layout, the element can sit anywhere inside the shell: it renders nothing. | | Home-directory crawl guard: `packages/core/src/filesystem/search.ts`, `packages/app/src/components/directory-picker-domain.ts`, `packages/desktop/src/main/index.ts`, `packages/core/test/filesystem/search.test.ts`, `packages/app/src/components/directory-picker-domain.test.ts` | search.ts: new exported `unindexableDirectory(directory)` (true for `Global.Path.home` — honoring `CODEV_TEST_HOME` — and filesystem roots) and a new `shallowLayer`; the layer chooser becomes `Layer.unwrap(Effect.gen(...))` reading `Location.Service` and picks `shallowLayer` for unindexable directories, fff/ripgrep otherwise. `shallowLayer.find` answers from one `readDirectoryEntries` of the location itself (direct children only, symlinks/specials dropped, same fuzzysort/empty-query/limit semantics as the index layers); its glob/grep are the on-demand ripgrep implementations, extracted from `ripgrepLayer` into a shared `ripgrepGlobGrep` effect (ripgrepLayer now spreads it — no behavior change there). directory-picker-domain.ts: `createDirectorySearch`'s bare-word branch (no `/`, `~`, or drive root in the input) matches direct children via the existing `file.list`-based `match()` when the base is the picker's home or a filesystem root, instead of `find.files({directory: base})`. desktop index.ts: the startup `process.chdir(homedir())` becomes `process.chdir(join(app.getPath("userData"), "cwd"))` (mkdir first, after `app.setPath("userData", ...)` so it lands under the channel's own folder; still inside try/catch, `homedir` import dropped). | Materializing the search service walks everything under its location — fff at creation, ripgrep as an eagerly forked scan. Rooted at `~` that walk opens files inside Downloads, Desktop, Documents and the photo/music library bundles, so macOS raised a permission prompt for each (confirmed in the TCC database: Desktop, Media Library, Downloads granted; Photos denied — the last two one second apart, one walk). The desktop app reached a home-rooted location two ways: the picker's bare-word search runs `find.files` against its `~` base, and any server request naming no directory falls back to `process.cwd()` (workspace-routing), which the sidecar inherited as `~` from the main process's chdir. Reproduced end to end against a decoy home: one directoryless `find/file` request read sentinel files inside `Downloads/` and `Photos Library.photoslibrary/`; with the guard the same request returns home's direct children and no sentinel is touched. | Keep all three pieces and take upstream's surrounding changes. If upstream reworks the search layers, preserve the invariant: no eager index (fff create or ripgrep scan) is ever built for the home directory or a filesystem root — `find` there must answer from a single readdir of the location itself. If upstream moves the desktop chdir, keep it pointed at an app-owned empty folder, never `homedir()` (and never leave it in `/`, which breaks ripgrep). The picker guard is belt-and-braces for older/remote servers; do not "simplify" it away because the server also guards. | | Prompt permission mode: `packages/app/src/components/prompt-input-v2.tsx`, `packages/app/src/context/permission.tsx`, `packages/app/src/pages/session/use-session-commands.tsx`, `packages/app/src/components/settings-general.tsx`, `packages/app/src/components/settings-v2/general.tsx`, `packages/app/src/i18n/{en,es,vi}.ts` | prompt-input-v2.tsx: the `modelControl` slot now carries a fragment — upstream's `` plus the fork-only ``, an Auto/Manual select built on session-ui's exported `PromptInputV2Select` (riding the model slot avoids a new slot in session-ui; it leads the fragment, so it sits between the agent select and the model select). It reads and writes the same client-side auto-accept state the `permissions.autoaccept` command toggles: a session takes an explicit override, a draft steers the directory default (`src/components/prompt-input/permission-mode.ts`, fork-only, holds that decision). A directory whose server config says `permission: "allow"` renders a non-interactive Auto with a tooltip, since such a server never asks. permission.tsx: `enableConfiguredDirectory` gains an `autoByDefault` parameter, passed as `sideForServerKey(activeServer()) === "work"`, so Work directories seed to auto-accept, and exposes `side()` so the consent gate agrees with it. All three paths that turn auto-accept ON — the select, the `permissions.autoaccept` command in use-session-commands.tsx, and the Settings switch in both general.tsx files — now await `useAutoAcceptConsent()` (fork-only `src/components/dialog-auto-accept.tsx`): on Code, once per app run, a confirm dialog focused on Cancel plus a warning toast, matching the TUI's `enableAutoApprove`; Work never asks, since auto is the default there that nobody picked. Turning it off never asks. i18n: the `prompt.permission.*` and `dialog.autoAccept.*` families. | Users asked to pick Auto/Manual per session, with Code defaulting to manual and Work to auto. The Work default used to come from the sandbox guest config (`permission: "allow"`, dropped in `packages/desktop/src/main/sandbox/egress.ts` — fork-only, untracked here): a server told to allow everything never emits `permission.asked`, so Manual had nothing to gate. Moving the default to the host makes both modes real on both sides while leaving the walk-away behaviour unchanged. | Keep the fork's fragment and re-apply upstream's changes to the `` inside it. The three enable paths each gained one `await consent(...)` line — keep them together, or the warning becomes theatre that one door skips. If upstream adds its own permission or mode control to the prompt toolbar, drop ours and re-point the Work default at whatever state theirs uses. `enableConfiguredDirectory`'s extra parameter is additive — if upstream rewrites that function, keep the `autoByDefault` branch or the Work side loses its auto default. | diff --git a/packages/app/src/components/dialog-update.tsx b/packages/app/src/components/dialog-update.tsx index 9668a12a7..37d58bf44 100644 --- a/packages/app/src/components/dialog-update.tsx +++ b/packages/app/src/components/dialog-update.tsx @@ -8,28 +8,29 @@ import { createEffect, createSignal, Show } from "solid-js" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" -// Once per app run rather than once per mount: the layout remounts when the -// server changes, and an update the user has already answered for must not -// come back at them. -let prompted = false +// Once per version per app run rather than once per mount: the layout remounts +// when the server changes, and an update the user has already answered for +// must not come back at them. A different version is a different question — +// the periodic re-check can find a newer release after the one they declined. +let prompted: string | undefined // The launch check the main process runs (updater.start) had nowhere to // surface: the titlebar grows an Update button once an update is downloaded, -// which is easy to spend a week not noticing. This asks instead, once, the -// first time a check finds something. +// which is easy to spend a week not noticing. This asks instead, once per +// version, the first time a check finds it. export function UpdatePrompt() { const platform = usePlatform() const dialog = useDialog() createEffect(() => { - const status = platform.updater?.state().status + const state = platform.updater?.state() // "available" is the normal path: automatic downloading is off by default // so the check stops at the discovery and this dialog is the ask. The other // two are what a user who turned automatic downloading back on sees — the // download already running, or already finished. - if (status !== "available" && status !== "downloading" && status !== "ready") return - if (prompted) return - prompted = true + if (state?.status !== "available" && state?.status !== "downloading" && state?.status !== "ready") return + if (prompted === state.version) return + prompted = state.version // push, not show: a dialog the user opened themselves must survive a check // that happens to land while they are in it. dialog.push(() => ) diff --git a/packages/desktop/src/main/updater-controller.test.ts b/packages/desktop/src/main/updater-controller.test.ts index 3d15ea2cc..ca1bcd185 100644 --- a/packages/desktop/src/main/updater-controller.test.ts +++ b/packages/desktop/src/main/updater-controller.test.ts @@ -11,12 +11,16 @@ function setup(input?: { ready?: UpdaterReadyRecord prefs?: Partial download?: () => Promise + // What the feed answers, mutable so a test can move it between checks. + feed?: { version: string; fail?: Error } }) { const calls: string[] = [] const backend: UpdaterBackend = { async checkForUpdates() { calls.push("check") - return { isUpdateAvailable: true, updateInfo: { version: "2.0.0" } } + if (input?.feed?.fail) throw input.feed.fail + const version = input?.feed?.version ?? "2.0.0" + return { isUpdateAvailable: version !== (input?.currentVersion ?? "1.0.0"), updateInfo: { version } } }, async downloadUpdate() { calls.push("download") @@ -221,13 +225,40 @@ describe("updater controller", () => { expect(states.map((state) => state.status)).toEqual(["idle", "checking", "available", "downloading", "ready"]) }) - // Re-checking would ask the feed a question it has already answered, and the - // 10-minute timer would do it forever while the user leaves the toast up. - test("a further check while an update is available is a no-op", async () => { - const app = setup({ prefs: { autoDownload: false } }) + // The feed keeps moving while the user sits on "Later": the 10-minute timer + // has to be able to find the release after the one they declined. + test("a further check while an update is available picks up a newer version", async () => { + const feed = { version: "2.0.0" } + const app = setup({ prefs: { autoDownload: false }, feed }) await app.controller.start() + expect(app.controller.getState()).toEqual({ status: "available", version: "2.0.0" }) + + feed.version = "2.1.0" await app.controller.check() - expect(app.calls).toEqual(["check"]) + expect(app.calls).toEqual(["check", "check"]) + expect(app.controller.getState()).toEqual({ status: "available", version: "2.1.0" }) + }) + + test("a re-check that fails keeps the update the user already knows about", async () => { + const feed: { version: string; fail?: Error } = { version: "2.0.0" } + const app = setup({ prefs: { autoDownload: false }, feed }) + await app.controller.start() + + feed.fail = new Error("feed unreachable") + expect(await app.controller.check()).toEqual({ status: "available", version: "2.0.0" }) + + // A failed first check has no answer to keep. + const fresh = setup({ feed }) + expect((await fresh.controller.check()).status).toBe("error") + }) + + test("a re-check that finds the update withdrawn ends up up-to-date", async () => { + const feed = { version: "2.0.0" } + const app = setup({ prefs: { autoDownload: false }, feed }) + await app.controller.start() + + feed.version = "1.0.0" + expect(await app.controller.check()).toEqual({ status: "up-to-date" }) }) test("downloading is only possible from available", async () => { diff --git a/packages/desktop/src/main/updater-controller.ts b/packages/desktop/src/main/updater-controller.ts index e252fd216..afae62c58 100644 --- a/packages/desktop/src/main/updater-controller.ts +++ b/packages/desktop/src/main/updater-controller.ts @@ -65,16 +65,30 @@ export function createUpdaterController(input: { const check = () => { if (!input.enabled) return Promise.resolve(state) if (state.status === "ready") return Promise.resolve(state) - // Already found and waiting to be downloaded: checking again would only - // ask the feed the same question. - if (state.status === "available") return Promise.resolve(state) if (pending) return pending + // An update found but not fetched is re-asked, not remembered: the feed + // can move on while the user sits on "Later", and a check that returned + // the known version would hide every release after it until relaunch. + const known = state.status === "available" ? state.version : undefined return run(async () => { transition({ status: "checking" }) - const result = await input.backend.checkForUpdates() - const version = result?.updateInfo?.version - if (!result?.isUpdateAvailable || !version || version === input.currentVersion) { + const outcome = await input.backend.checkForUpdates().then( + (result) => ({ result }), + (error: unknown) => { + // The timer re-checks every few minutes; a feed that is briefly + // unreachable must not withdraw an answer the user already has. + if (known === undefined) throw error + input.log?.("update check failed; keeping the known update", { + version: known, + message: error instanceof Error ? error.message : String(error), + }) + return { keep: known } + }, + ) + if ("keep" in outcome) return transition({ status: "available", version: outcome.keep }) + const version = outcome.result?.updateInfo?.version + if (!outcome.result?.isUpdateAvailable || !version || version === input.currentVersion) { await input.persistence.clear() return transition({ status: "up-to-date" }) }