Skip to content

feat(updater): add update install mode preference (#4467)#4543

Open
cbeaulieu-gt wants to merge 4 commits into
nexu-io:mainfrom
cbeaulieu-gt:feat/4467-update-install-mode
Open

feat(updater): add update install mode preference (#4467)#4543
cbeaulieu-gt wants to merge 4 commits into
nexu-io:mainfrom
cbeaulieu-gt:feat/4467-update-install-mode

Conversation

@cbeaulieu-gt

@cbeaulieu-gt cbeaulieu-gt commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Fixes #4467

Why

Following up on #4467 after #4471 landed the payload-capability foundation. #4471 made capabilitiesFor artifact-aware (payload ⇒ in-app install) but intentionally deferred the user-facing preference. This PR adds that preference and the clearer manual-install copy, as scoped with @lefarcen in the issue thread.

The pain: on a packaged install there was no way to opt out of in-app payload updates back to the installer/DMG flow, and the "update ready" copy for the installer case read like a generic prompt rather than telling the user they need to run the installer themselves.

This is the cross-platform first slice (PR1). Per the agreement in #4467, macOS-native end-to-end acceptance (real payload extraction, DMG mount/Finder drag-install, signed-bundle identity) is deferred to a follow-up (PR2) that needs a Mac.

What users will see

  • Settings → About gains an "Update install mode" dropdown — Automatic (in-app) vs Manual (installer) — shown only on payload-capable packaged installs. Default is Automatic, so existing behavior is unchanged unless the user opts out.
  • The "update ready" popup now shows clearer copy when the update must be applied by running the installer: "Open Design {version} is ready. Open the downloaded installer to apply it." with an Open installer button, instead of the previous generic wording.
  • CLI parity: od config set updateInstallMode manual (and get/unset) via the existing generic od config command — no new subcommand.

Surface area

  • UI — Settings → About "Update install mode" dropdown; clearer manual-install copy in the updater popup
  • Keyboard shortcut
  • CLI / env varod config set updateInstallMode manual (rides the existing generic od config command; key added to the app-config allowlist — no new subcommand)
  • API / contract/api/app-config gains an optional updateInstallMode: 'automatic' | 'manual' field (additive, backward-safe)
  • Extension point
  • i18n keys — 3 updater + 3 settings keys, added across all locales
  • New top-level dependency
  • Default behavior change — none; absent/unset ⇒ Automatic, preserving fix(desktop): treat payload updates as in-app installs #4471's default
  • None

Screenshots

4467-shot-toggle 4467-shot-manual-install-copy

Bug fix verification

This PR also fixes a dead-code branch found while wiring the manual-install copy: capabilitiesFor couples canOpenInstaller and requiresManualInstall (both equal manualInstaller), so the popup's isManualInstallCase guard requiresManualInstall && !canOpenInstaller was always false — the manual-install copy never rendered. The guard now keys on requiresManualInstall alone.

  • Test path that reproduces the bug: apps/web/tests/components/UpdaterPopup-update-install-mode.test.tsx
  • Did the test go red before the fix and green after? Yes — verified by reverting the one-line guard change: the test fails for the right reason (standard copy renders; zh button 安装更新 instead of 打开安装程序), and passes with the fix. The earlier version of this test used an impossible capability combo (canOpenInstaller: false + requiresManualInstall: true) that capabilitiesFor never emits, which masked the bug; it now uses the real combo (canOpenInstaller: true).

Validation

  • pnpm typecheck — clean
  • pnpm --filter @open-design/web test — green (UpdaterPopup 12/12; pre-existing unrelated failures only: design-system style fixtures, sidecar-proxy socket)
  • pnpm --filter @open-design/desktop test (run locally; not in PR CI gate) — update-install-mode suites 9/9
  • pnpm --filter @open-design/daemon test — app-config round-trip green
  • pnpm guard — failures are pre-existing design-system component-fixture coverage only; zero overlap with the files in this PR
  • End-to-end: rebuilt the packaged Windows app and confirmed the manual-install copy renders live (screenshot above)

🤖 Generated by Claude Code on behalf of @cbeaulieu-gt

Add a user-facing Automatic vs Manual update-install preference, persist it
through /api/app-config, wire it into updater candidate selection, and add
clearer manual-install copy for the DMG/installer case.

- daemon: `updateInstallMode` config field (enum-validated, defaults to
  automatic; reachable via `od config set updateInstallMode manual`).
- desktop: lazy-read the pref at check time and force installer/DMG selection
  when manual; `readUpdateInstallMode` helper wired into createDesktopUpdater.
- web: Settings toggle (gated on canApplyInPlace), `requiresManualInstall`
  surfaced on UpdaterModel, and a manual-install copy branch in UpdaterPopup.
- i18n: new updater/settings keys across all locales.

Fix the dead manual-install copy branch: capabilitiesFor couples
canOpenInstaller and requiresManualInstall, so `requiresManualInstall &&
!canOpenInstaller` was always false and the manual-install copy never
rendered. Gate on requiresManualInstall alone. The frozen test now uses the
real capability combo capabilitiesFor emits (canOpenInstaller: true) instead
of an impossible one that masked the bug.

Note: this is cross-platform PR1. macOS-native end-to-end acceptance
(payload extraction, DMG drag-install, signed-bundle identity) defers to PR2.

Fixes nexu-io#4467
@cbeaulieu-gt cbeaulieu-gt requested a review from a team as a code owner June 19, 2026 00:33
@lefarcen

Copy link
Copy Markdown
Contributor

Hi @cbeaulieu-gt! The PR body here is very easy to follow — the PR1/PR2 split and the updater UX tradeoff are laid out clearly.

I've queued the reviewer routing and the follow-up checks this change needs on our side.

💡 To drive this PR to merge hands-free, paste this to your AI coding agent (Claude Code / Codex / opencode / Cursor …):
Take over nexu-io/open-design#4543 until it merges — read https://raw.githubusercontent.com/nexu-io/looper/main/skills/pr-takeover/SKILL.md and follow it.

@lefarcen lefarcen added size/XL PR changes 700-1500 lines risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps type/feature New feature needs-validation Runtime change detected; needs human or /explore agent validation. labels Jun 19, 2026
@lefarcen

Copy link
Copy Markdown
Contributor

🧪 This PR has changes that need a manual QA pass before merge — please hold off self-merging for now; we'll loop QA in once it's merge-ready (and design/product have signed off, where applicable).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 92509be316

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/daemon/src/app-config.ts

@PerishCode PerishCode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found two blockers in the install-mode wiring that need to be fixed before this can satisfy #4467: the new app-config field is not part of the shared contract/config sync path, and the Settings control is hidden unless the current selected update artifact is already a payload.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment thread apps/daemon/src/app-config.ts
Comment thread apps/web/src/components/SettingsDialog.tsx Outdated
@lefarcen

Copy link
Copy Markdown
Contributor

Hey @cbeaulieu-gt — following up on the review loop here: @PerishCode's blocking review covers the two changes that need to land on this head before this can move forward.

The current blockers are:

  • wiring updateInstallMode through the shared app-config contract + normal web config sync path, and
  • making the Settings preference visible from a stable payload-capable runtime signal instead of the currently selected artifact state.

Once those are addressed, the product/design/QA routing that's already queued on this PR can continue from there.

… gate (nexu-io#4543)

Resolves the two blockers from PerishCode's review on nexu-io#4543:

- Contract/sync: add `updateInstallMode` to the shared `AppConfigPrefs`
  (packages/contracts), merge it from daemon config in `mergeDaemonConfig`,
  and include it in `syncConfigToDaemon`. Previously the field lived only on
  the daemon-local shape and the Settings dialog's direct PUT, so the normal
  web merge/sync path dropped it — leaving the toggle and the daemon updater
  able to disagree after a daemon-hydrated load.

- Toggle visibility: gate the Settings "Update install mode" control on a new
  artifact-independent `payloadCapable` signal (derived in `deriveUpdaterModel`
  as host + supported + package-launcher) instead of `canApplyInPlace`.
  `canApplyInPlace` is artifact-aware and false until a payload candidate is
  selected, which hid the preference exactly when the user wants to opt out of
  payload updates before one is picked.

Tests: contract/sync round-trip in web config-state tests; SettingsDialog
visibility on idle and installer-pending payload-capable runtimes (toggle
shown) vs non-package-launcher runtimes (hidden).

@PerishCode PerishCode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found one remaining blocker in the install-mode config surface: the new preference can be set, but the advertised CLI unset path does not actually clear the stored value.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

if (value === 'automatic' || value === 'manual') {
target[key] = value;
} else {
delete target[key];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make the advertised od config unset updateInstallMode path clear this value, not only direct null writes. This branch deletes updateInstallMode when the key reaches writeAppConfig with an invalid/null value, but the existing generic CLI unset flow fetches the config, deletes the key from the object, and PUTs the remaining object. Because writeAppConfig is merge-style and only calls applyConfigValue for keys present in the request, the stored manual value survives and users cannot get back to the documented absent/unset => automatic state via the CLI. Please either change the CLI unset path to send { updateInstallMode: null } for this merge-patch API, or add equivalent daemon/API handling plus a CLI-level regression test for od config unset updateInstallMode.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@lefarcen

Copy link
Copy Markdown
Contributor

Hey @cbeaulieu-gt — quick follow-up on the new head: @PerishCode has narrowed this down to one remaining blocker on the current commit.

The remaining item is the advertised od config unset updateInstallMode path: the field can now be set and round-tripped, but the generic merge-style config update still needs to actually clear the stored value when the CLI unset flow runs.

Once that unset path is fixed on this head, the review can move forward from there.

…nexu-io#4543)

Addresses PerishCode's third review blocker on nexu-io#4543. `od config unset
<key>` deleted the key client-side then PUT the remaining object, but the
daemon's writeAppConfig is merge-style (only applies keys present in the
body), so the stored value survived — `od config unset updateInstallMode`
could not return to the documented unset⇒automatic state.

Send `{ ...cfg, [key]: null }` instead of omitting the key, matching the
daemon's merge-patch + null-clears convention (applyConfigValue already
deletes updateInstallMode on null; existing writeAppConfig({ agentId: null })
behaviour relies on the same idiom).

Regression test exercises the real CLI unset path against a stub daemon and
asserts the PUT body carries `{ updateInstallMode: null }`.

@PerishCode PerishCode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found one remaining blocker in the install-mode config merge path: clearing the preference through the CLI can still be undone by the web app's persisted local state.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/src/state/config.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 234b70ae66

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +4760 to +4765
setCfg((prev) => ({ ...prev, updateInstallMode: mode }));
await fetch('/api/app-config', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ updateInstallMode: mode }),
}).catch(() => {/* best-effort */});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reconcile pending payload when switching to manual

When a payload update has already been selected or downloaded, changing this setting to manual only updates React state and writes the daemon config. The desktop updater reads updateInstallMode only during checkForCandidate, while installUpdate still applies the current activeRelease, so a user who switches to Manual after an automatic payload download can still click the existing update prompt and get the in-place payload relaunch instead of the installer path. Please trigger/reconcile a new updater selection here, clear the active payload, or make install re-check the preference before applying a payload.

Useful? React with 👍 / 👎.

@lefarcen

Copy link
Copy Markdown
Contributor

Hey @cbeaulieu-gt — one more follow-up on the latest head: @PerishCode has reduced the current review to a single remaining blocker in the config merge path.

The remaining issue is that clearing updateInstallMode via the CLI can still be reintroduced by stale web local state during bootstrap, so the absent/unset ⇒ automatic behavior needs to win when the daemon omits this field.

Once that local-state merge case is fixed on this head, the review can move forward from there.

mergeDaemonConfig preserved the local updateInstallMode when the daemon
config omitted the field, so a browser holding 'manual' in localStorage
kept it. App.tsx bootstrap then re-synced that stale value back to the
daemon, undoing `od config unset updateInstallMode` and breaking the
absent/unset => automatic contract.

Treat omission from a present daemon config as a clear: the daemon is
authoritative for this key. Flip the merge round-trip test to assert the
local manual value is removed when the daemon omits the field.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@PerishCode PerishCode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found one blocker in the install-mode lifecycle: switching to Manual after a payload is already downloaded can still apply the payload instead of moving the user back to the installer path.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

if (root != null) scheduleBackCleanup(root.realRoot, logger);
const selected = selectUpdateCandidateWithFallback(body, config, await hasValidLauncherPayloadContext(config));
const pref = readUpdateInstallMode ? await readUpdateInstallMode() : undefined;
const preferPayload = pref === 'manual' ? false : await hasValidLauncherPayloadContext(config);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reconcile the active release when the install mode changes to manual. This new logic only reads updateInstallMode while checkForCandidate selects a candidate, but the Settings dropdown writes the daemon preference without triggering a new selection or clearing an existing activeRelease. If a payload was already selected/downloaded while Automatic was active, installUpdate() still branches on activeRelease.ref.artifact.type === "payload" and applies the prepared payload, so the user can opt out in Settings and still get the in-place payload relaunch from the existing prompt. That breaks the promised Manual (installer) behavior. Please either re-run/reconcile updater selection when the setting is changed to Manual, clear/reselect a payload active release as an installer release, or make installUpdate() re-read the preference before applying a payload; add a regression test for switching to Manual after a payload has already reached DOWNLOADED.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@lefarcen

Copy link
Copy Markdown
Contributor

Hey @cbeaulieu-gt — another quick follow-up on the latest head: @PerishCode has the review narrowed to one remaining lifecycle blocker on this commit.

The remaining issue is the Manual-mode switch after a payload is already downloaded: changing the preference in Settings still needs to force the updater back onto the installer/manual path instead of letting an already-prepared payload continue through the in-place apply flow.

Once that downloaded-payload handoff is reconciled on this head, the review can continue from there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-validation Runtime change detected; needs human or /explore agent validation. risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps size/XL PR changes 700-1500 lines type/feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve macOS update flow so payload updates are the default

3 participants