From 3e3c4037cdf173df94e21b7fe00058fcea7e19a6 Mon Sep 17 00:00:00 2001
From: AKHIL
Date: Thu, 23 Jul 2026 21:27:06 +0530
Subject: [PATCH 01/11] docs: spec for update progress modal
Phase-narrated, dismissible-with-corner-pill update install UX to
replace the fire-and-forget toast path.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
...2026-07-23-update-progress-modal-design.md | 162 ++++++++++++++++++
1 file changed, 162 insertions(+)
create mode 100644 docs/superpowers/specs/2026-07-23-update-progress-modal-design.md
diff --git a/docs/superpowers/specs/2026-07-23-update-progress-modal-design.md b/docs/superpowers/specs/2026-07-23-update-progress-modal-design.md
new file mode 100644
index 00000000..27710ff6
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-23-update-progress-modal-design.md
@@ -0,0 +1,162 @@
+# Update Progress Modal — Design
+
+**Date:** 2026-07-23
+**Scope:** `apps/desktop-ui` (Tauri desktop UI). No Rust changes.
+
+## Problem
+
+When a new signed build is available the app surfaces a persistent toast
+(`desktop-init.tsx`). Clicking **Restart & update** calls `installUpdate()`
+with **no progress callback** — the toast flips to a static "Downloading…"
+line, then 10–60s later the app relaunches with no further signal. To the user
+this reads as a frozen / buggy app: no motion, no percentage, then a sudden
+disappearance and restart.
+
+The settings "Check for updates" dialog *does* draw a progress bar, but it
+stops at the download percentage and then goes silent through install +
+relaunch. The About-card check button is also fire-and-forget.
+
+**Goal:** every path that installs an update shows a continuously-animated,
+phase-narrated progress UI so the user always knows the app is working and is
+about to restart.
+
+## Current flow (map)
+
+| File | Role |
+|---|---|
+| `src/lib/desktop/updater.ts` | `checkForUpdate()` / `installUpdate(onProgress)` / `currentAppVersion()`. Sees every Tauri `DownloadEvent` (`Started`/`Progress`/`Finished`) then `relaunch()`. |
+| `src/components/desktop/desktop-init.tsx` | App-wide launch + 6h auto-check → persistent toast. **Fire-and-forget install (the bug).** Mounted via `app/layout.tsx:123`. |
+| `src/components/desktop/desktop-update-dialog.tsx` | Settings "Check for updates" modal + hand-rolled download-only bar. |
+| `src/components/desktop/app-version-label.tsx` | Settings About card check button. Fire-and-forget install. |
+| `src/components/ui/progress.tsx` | Radix progress bar, already in repo, currently unused by updater. |
+
+## Design (Approach A — shared app-wide modal)
+
+### 1. Phase model in `updater.ts`
+
+Replace the `onProgress?: (p: DownloadProgress)` callback with a single status
+callback that narrates the whole install:
+
+```ts
+export type UpdatePhase = "downloading" | "installing" | "restarting";
+export type UpdateStatus = {
+ phase: UpdatePhase;
+ downloaded: number;
+ total: number | null;
+};
+
+export async function installUpdate(
+ onStatus?: (s: UpdateStatus) => void
+): Promise
+```
+
+Phase transitions inside the existing `downloadAndInstall` callback:
+
+- `Started` → capture `total`, emit `downloading` (downloaded 0).
+- `Progress` → accumulate, emit `downloading`.
+- `Finished` → emit `installing` (Tauri is now swapping the bundle in place).
+- After `downloadAndInstall` resolves → emit `restarting`, then `relaunch()`.
+
+`total` may be `null` (server sends no `Content-Length`) → the UI shows an
+indeterminate bar, never a fabricated percentage.
+
+### 2. `useUpdateInstall()` hook (new: `src/lib/desktop/use-update-install.ts`)
+
+Owns install state for the whole app. Returns:
+
+```ts
+{
+ status: UpdateStatus | null, // null = idle / not installing
+ error: string | null,
+ start: () => void, // idempotent: no-op if already running
+ dismiss: () => void, // hides modal, keeps corner pill (see §4)
+ reopen: () => void, // corner pill → reopen modal
+ visible: boolean, // modal open?
+}
+```
+
+`start()` calls `installUpdate(setStatus)` and catches errors into `error`.
+Because success ends in `relaunch()`, there is no "done" state — the process
+dies. State lives in a module-level store (tiny `useSyncExternalStore` or a
+2-field zustand-free pub/sub) so the app-wide modal and any trigger share one
+instance regardless of where in the tree they mount.
+
+### 3. `` (new: `src/components/desktop/update-progress-modal.tsx`)
+
+Mounted once, app-wide, next to `DesktopInit` in `app/layout.tsx`. Reads the
+hook. Renders nothing when `status` is null.
+
+Per phase (framer-motion crossfade between phase labels):
+
+| Phase | Bar | Copy |
+|---|---|---|
+| `downloading` (total known) | determinate `` | `Downloading update… {pct}%` |
+| `downloading` (total null) | indeterminate shimmer | `Downloading update…` |
+| `installing` | indeterminate shimmer | `Installing update…` |
+| `restarting` | indeterminate shimmer | `Restarting MyDevTools…` |
+| error | none | error text + **Retry** / **Close** |
+
+- All copy through `useTranslations("DesktopUpdate")` (new namespace) — no
+ hardcoded strings (project rule).
+- Dialog enter animation via framer-motion; indeterminate bar is a CSS
+ keyframe shimmer (no JS).
+- **Dismissible while `downloading`/`installing`** (close X + backdrop) per
+ user choice. **`restarting` forces the modal open** (no dismiss) — the
+ restart is imminent, so the user must see it; dismissing a 0.5s phase would
+ just flash.
+- Header line: "Version X is available" style + reassurance ("Your offline
+ data is never touched.").
+
+### 4. Corner return pill (new, in the same modal file or a sibling)
+
+When the user dismisses the modal mid-install, a small fixed bottom-right pill
+stays: spinner + `Updating… {pct}%`, clickable to `reopen()`. Visible whenever
+`status !== null && !visible`. Uses the same hook. This is the "dismissible +
+corner return" flow.
+
+### 5. Wire the three triggers to the hook
+
+- **`desktop-init.tsx`** — toast action `onClick` → `start()` (via the shared
+ store's imperative `startUpdate()` export, since the toast callback isn't a
+ React child of the provider). Drop the fire-and-forget `installUpdate()` +
+ manual `toast.loading`. Toast dismisses; modal takes over.
+- **`desktop-update-dialog.tsx`** — replace the hand-rolled bar; call
+ `start()` and let the app-wide modal render progress. Dialog just triggers
+ and closes. (Removes `pct()` + inline bar markup.)
+- **`app-version-label.tsx`** — check button, on update-found → `start()`.
+
+### Error handling
+
+- `installUpdate` throws (download/verify/IO fail) → hook sets `error`, modal
+ shows Retry/Close. Retry re-runs `start()`. Close clears state.
+- Signature verification failure surfaces as the thrown error message (already
+ the case in the Tauri plugin) — shown verbatim, no silent swallow.
+
+## What we are NOT building (YAGNI)
+
+- No full-screen takeover overlay (user chose contained modal).
+- No Rust-side changes — the plugin already emits everything needed.
+- No zustand store / global state lib — one tiny module-level pub/sub is
+ enough for a single ephemeral install.
+- No "update history" / changelog viewer beyond the existing `notes` line.
+- No background silent-install — install stays user-initiated (existing
+ product behavior; relaunch is never sprung silently).
+
+## Testing
+
+- Unit: `installUpdate` phase sequencing — feed a fake `Update` whose
+ `downloadAndInstall` invokes the callback with `Started`→`Progress`×N→
+ `Finished`, assert emitted phases are `downloading…`, then `installing`,
+ then `restarting`, and `relaunch` called last. (`updater` mockable via the
+ dynamic-import boundary.)
+- Unit: `pct` / percentage helper — `total: null` → indeterminate (no number);
+ clamps at 100.
+- Manual: real update against the release endpoint — verify each phase label
+ renders, dismiss→pill→reopen works, error path shows Retry.
+
+## Files
+
+**New:** `use-update-install.ts`, `update-progress-modal.tsx`, `DesktopUpdate`
+i18n namespace (en + 26 locales).
+**Edit:** `updater.ts`, `desktop-init.tsx`, `desktop-update-dialog.tsx`,
+`app-version-label.tsx`, `app/layout.tsx`.
From 06f71befbecfc6169ced437a0fec07920bf1bf81 Mon Sep 17 00:00:00 2001
From: AKHIL
Date: Thu, 23 Jul 2026 21:54:20 +0530
Subject: [PATCH 02/11] docs: implementation plan for update progress modal
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../plans/2026-07-23-update-progress-modal.md | 744 ++++++++++++++++++
1 file changed, 744 insertions(+)
create mode 100644 docs/superpowers/plans/2026-07-23-update-progress-modal.md
diff --git a/docs/superpowers/plans/2026-07-23-update-progress-modal.md b/docs/superpowers/plans/2026-07-23-update-progress-modal.md
new file mode 100644
index 00000000..6a5a45f4
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-23-update-progress-modal.md
@@ -0,0 +1,744 @@
+# Update Progress Modal Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace the fire-and-forget update-install paths with one app-wide, phase-narrated, animated progress modal so the user always sees the app downloading, installing, and restarting instead of a silent freeze-then-relaunch.
+
+**Architecture:** `installUpdate()` gains a phase-aware status callback (downloading → installing → restarting) mapped from the Tauri `DownloadEvent`s it already receives. A module-level install store (`use-update-install.ts`) owns install state and exposes both an imperative `startUpdate()` (for the sonner toast callback, which lives outside the React tree) and a `useUpdateInstall()` hook. A single `` mounted app-wide in `layout.tsx` renders progress; dismissing it leaves a corner pill that reopens it. All three triggers (auto-check toast, settings dialog, About card) route through the store.
+
+**Tech Stack:** Next.js (static export for Tauri), React, TypeScript, `@tauri-apps/plugin-updater`, `@tauri-apps/plugin-process`, sonner, framer-motion (installed), Radix `ui/progress.tsx`, next-intl, jest.
+
+## Global Constraints
+
+- Work only in `apps/desktop-ui`. No Rust / `src-tauri` changes — the plugin already emits every event needed.
+- Build/verify with repo-local bins: `./node_modules/.bin/tsc --noEmit`, `./node_modules/.bin/jest`. `pnpm exec` is broken.
+- All new user-visible modal/pill strings via `useTranslations("DesktopUpdate")` — no hardcoded UI text in the new components. Existing sibling updater strings (toast/dialog/About English text) are NOT migrated (out of scope).
+- New `DesktopUpdate` namespace added to `messages/en.json` **and all 26 other locales**. A missing key falls back to English via the `deepMerge` in `src/i18n/request.ts`, so en is load-bearing; the 26 locales get translated copies.
+- Desktop-only: every trigger and the modal gate on `isDesktop()` (or render null on web).
+- Edit files with Read/Edit tools, not shell `sed`/`cat`.
+- Commit after each task.
+
+---
+
+### Task 1: Phase-aware status in `updater.ts`
+
+**Files:**
+- Modify: `apps/desktop-ui/src/lib/desktop/updater.ts`
+- Test: `apps/desktop-ui/src/lib/__tests__/updater-status.test.ts`
+
+**Interfaces:**
+- Consumes: `DownloadEvent`, `Update` from `@tauri-apps/plugin-updater`.
+- Produces:
+ - `type UpdatePhase = "downloading" | "installing" | "restarting"`
+ - `type UpdateStatus = { phase: UpdatePhase; downloaded: number; total: number | null }`
+ - `function reduceDownloadEvent(prev: { downloaded: number; total: number | null }, event: DownloadEvent): UpdateStatus` (pure, exported for tests)
+ - `function pctOf(s: { downloaded: number; total: number | null }): number | null` (clamped 0–100, null when total falsy)
+ - `installUpdate(onStatus?: (s: UpdateStatus) => void): Promise` (callback signature CHANGED from `DownloadProgress` to `UpdateStatus`)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `apps/desktop-ui/src/lib/__tests__/updater-status.test.ts`:
+
+```ts
+import { reduceDownloadEvent, pctOf } from "@/lib/desktop/updater";
+
+describe("reduceDownloadEvent", () => {
+ it("Started captures total and resets downloaded", () => {
+ const s = reduceDownloadEvent(
+ { downloaded: 999, total: null },
+ { event: "Started", data: { contentLength: 500 } } as any
+ );
+ expect(s).toEqual({ phase: "downloading", downloaded: 0, total: 500 });
+ });
+
+ it("Started with no contentLength → total null (indeterminate)", () => {
+ const s = reduceDownloadEvent(
+ { downloaded: 0, total: null },
+ { event: "Started", data: {} } as any
+ );
+ expect(s.total).toBeNull();
+ expect(s.phase).toBe("downloading");
+ });
+
+ it("Progress accumulates chunk lengths, keeps total", () => {
+ const s = reduceDownloadEvent(
+ { downloaded: 100, total: 500 },
+ { event: "Progress", data: { chunkLength: 50 } } as any
+ );
+ expect(s).toEqual({ phase: "downloading", downloaded: 150, total: 500 });
+ });
+
+ it("Finished switches phase to installing", () => {
+ const s = reduceDownloadEvent(
+ { downloaded: 500, total: 500 },
+ { event: "Finished", data: {} } as any
+ );
+ expect(s.phase).toBe("installing");
+ });
+});
+
+describe("pctOf", () => {
+ it("null total → null (indeterminate, no fake number)", () => {
+ expect(pctOf({ downloaded: 10, total: null })).toBeNull();
+ });
+ it("rounds and clamps at 100", () => {
+ expect(pctOf({ downloaded: 250, total: 500 })).toBe(50);
+ expect(pctOf({ downloaded: 600, total: 500 })).toBe(100);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `./node_modules/.bin/jest src/lib/__tests__/updater-status.test.ts`
+Expected: FAIL — `reduceDownloadEvent`/`pctOf` not exported.
+
+- [ ] **Step 3: Implement in `updater.ts`**
+
+Replace the `DownloadProgress` type (line 24) and the `installUpdate` body (lines 48-77). Keep `checkForUpdate`, `currentAppVersion`, `pending` unchanged.
+
+```ts
+import type { DownloadEvent, Update } from "@tauri-apps/plugin-updater";
+
+export type UpdatePhase = "downloading" | "installing" | "restarting";
+export type UpdateStatus = {
+ phase: UpdatePhase;
+ downloaded: number;
+ total: number | null;
+};
+
+/** Map a Tauri DownloadEvent to the next status. Pure — unit tested. */
+export function reduceDownloadEvent(
+ prev: { downloaded: number; total: number | null },
+ event: DownloadEvent
+): UpdateStatus {
+ switch (event.event) {
+ case "Started":
+ return {
+ phase: "downloading",
+ downloaded: 0,
+ total: event.data.contentLength ?? null,
+ };
+ case "Progress":
+ return {
+ phase: "downloading",
+ downloaded: prev.downloaded + event.data.chunkLength,
+ total: prev.total,
+ };
+ case "Finished":
+ return { phase: "installing", downloaded: prev.downloaded, total: prev.total };
+ }
+}
+
+/** Download percentage, or null when the server sent no Content-Length. */
+export function pctOf(s: { downloaded: number; total: number | null }): number | null {
+ if (!s.total) return null;
+ return Math.min(100, Math.round((s.downloaded / s.total) * 100));
+}
+```
+
+And rewrite `installUpdate`:
+
+```ts
+export async function installUpdate(
+ onStatus?: (s: UpdateStatus) => void
+): Promise {
+ if (!pending) {
+ await checkForUpdate();
+ if (!pending) throw new Error("No update available to install");
+ }
+ const update = pending;
+ let acc: { downloaded: number; total: number | null } = {
+ downloaded: 0,
+ total: null,
+ };
+
+ await update.downloadAndInstall((event) => {
+ const s = reduceDownloadEvent(acc, event);
+ acc = { downloaded: s.downloaded, total: s.total };
+ onStatus?.(s);
+ });
+
+ // Bundle swapped in place; now relaunch into the new version.
+ onStatus?.({ phase: "restarting", downloaded: acc.downloaded, total: acc.total });
+ const { relaunch } = await import("@tauri-apps/plugin-process");
+ await relaunch();
+}
+```
+
+Remove the old `DownloadProgress` export (its only consumers are updated in Tasks 4–5).
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `./node_modules/.bin/jest src/lib/__tests__/updater-status.test.ts`
+Expected: PASS (6 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/desktop-ui/src/lib/desktop/updater.ts apps/desktop-ui/src/lib/__tests__/updater-status.test.ts
+git commit -m "feat(updater): phase-aware install status callback"
+```
+
+---
+
+### Task 2: Install store + `useUpdateInstall` hook
+
+**Files:**
+- Create: `apps/desktop-ui/src/lib/desktop/use-update-install.ts`
+- Test: `apps/desktop-ui/src/lib/__tests__/use-update-install.test.ts`
+
+**Interfaces:**
+- Consumes: `installUpdate`, `UpdateStatus` from Task 1.
+- Produces:
+ - `function startUpdate(): void` — imperative; idempotent (no-op while a run is active); begins install, drives status.
+ - `function dismissUpdate(): void` — hides modal, keeps run active (corner pill).
+ - `function reopenUpdate(): void` — shows modal again.
+ - `function retryUpdate(): void` — clears error+status, restarts.
+ - `function closeUpdate(): void` — clears everything (error dismissal).
+ - `function useUpdateInstall(): { status: UpdateStatus | null; error: string | null; visible: boolean; dismiss: () => void; reopen: () => void; retry: () => void; close: () => void }`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `apps/desktop-ui/src/lib/__tests__/use-update-install.test.ts`. Mock `installUpdate` so we control status emission without Tauri:
+
+```ts
+let emit: ((s: any) => void) | undefined;
+let resolveInstall: (() => void) | undefined;
+
+jest.mock("@/lib/desktop/updater", () => ({
+ installUpdate: jest.fn((onStatus?: (s: any) => void) => {
+ emit = onStatus;
+ return new Promise((res) => {
+ resolveInstall = res;
+ });
+ }),
+}));
+
+import { startUpdate, dismissUpdate, reopenUpdate, closeUpdate } from "@/lib/desktop/use-update-install";
+// read module state through a tiny getter the module exposes for tests:
+import { __getState } from "@/lib/desktop/use-update-install";
+
+beforeEach(() => {
+ emit = undefined;
+ resolveInstall = undefined;
+ closeUpdate(); // reset store between tests
+});
+
+describe("install store", () => {
+ it("startUpdate opens modal and enters downloading", async () => {
+ startUpdate();
+ expect(__getState().visible).toBe(true);
+ expect(__getState().status?.phase).toBe("downloading");
+ });
+
+ it("status emissions update the store", async () => {
+ startUpdate();
+ await Promise.resolve(); // let dynamic import().then microtask flush
+ emit?.({ phase: "installing", downloaded: 5, total: 5 });
+ expect(__getState().status?.phase).toBe("installing");
+ });
+
+ it("startUpdate is idempotent while running", async () => {
+ startUpdate();
+ const first = __getState().status;
+ startUpdate();
+ expect(__getState().status).toBe(first);
+ });
+
+ it("dismiss hides modal but keeps the run; reopen shows it", () => {
+ startUpdate();
+ dismissUpdate();
+ expect(__getState().visible).toBe(false);
+ expect(__getState().status).not.toBeNull();
+ reopenUpdate();
+ expect(__getState().visible).toBe(true);
+ });
+
+ it("a rejected install sets error", async () => {
+ const { installUpdate } = require("@/lib/desktop/updater");
+ (installUpdate as jest.Mock).mockImplementationOnce(() => Promise.reject(new Error("boom")));
+ startUpdate();
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(__getState().error).toBe("boom");
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `./node_modules/.bin/jest src/lib/__tests__/use-update-install.test.ts`
+Expected: FAIL — module does not exist.
+
+- [ ] **Step 3: Implement `use-update-install.ts`**
+
+```ts
+"use client";
+
+import { useSyncExternalStore } from "react";
+
+import type { UpdateStatus } from "@/lib/desktop/updater";
+
+type State = {
+ status: UpdateStatus | null;
+ error: string | null;
+ visible: boolean;
+};
+
+let state: State = { status: null, error: null, visible: false };
+const subscribers = new Set<() => void>();
+
+function set(patch: Partial): void {
+ state = { ...state, ...patch };
+ subscribers.forEach((fn) => fn());
+}
+
+/** Imperative entry point — usable from the sonner toast callback (no React tree). */
+export function startUpdate(): void {
+ if (state.status) return; // already running
+ set({
+ status: { phase: "downloading", downloaded: 0, total: null },
+ error: null,
+ visible: true,
+ });
+ void import("@/lib/desktop/updater")
+ .then(({ installUpdate }) => installUpdate((s) => set({ status: s })))
+ .catch((e) =>
+ set({ error: e instanceof Error ? e.message : "Update failed to install" })
+ );
+ // On success the process relaunches — there is no resolve branch to handle.
+}
+
+export function dismissUpdate(): void {
+ set({ visible: false });
+}
+export function reopenUpdate(): void {
+ set({ visible: true });
+}
+export function retryUpdate(): void {
+ set({ status: null, error: null });
+ startUpdate();
+}
+export function closeUpdate(): void {
+ set({ status: null, error: null, visible: false });
+}
+
+/** Test-only accessor for the module store. */
+export function __getState(): State {
+ return state;
+}
+
+function subscribe(cb: () => void): () => void {
+ subscribers.add(cb);
+ return () => {
+ subscribers.delete(cb);
+ };
+}
+
+export function useUpdateInstall() {
+ const snap = useSyncExternalStore(
+ subscribe,
+ () => state,
+ () => state
+ );
+ return {
+ ...snap,
+ dismiss: dismissUpdate,
+ reopen: reopenUpdate,
+ retry: retryUpdate,
+ close: closeUpdate,
+ };
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `./node_modules/.bin/jest src/lib/__tests__/use-update-install.test.ts`
+Expected: PASS (5 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/desktop-ui/src/lib/desktop/use-update-install.ts apps/desktop-ui/src/lib/__tests__/use-update-install.test.ts
+git commit -m "feat(updater): app-wide install store + useUpdateInstall hook"
+```
+
+---
+
+### Task 3: `` + corner pill
+
+**Files:**
+- Create: `apps/desktop-ui/src/components/desktop/update-progress-modal.tsx`
+
+**Interfaces:**
+- Consumes: `useUpdateInstall` (Task 2), `pctOf` (Task 1), `useTranslations("DesktopUpdate")` (Task 6 adds keys; en fallback covers it), `Dialog*` from `ui/dialog`, `Progress` from `ui/progress`, `motion`/`AnimatePresence` from `framer-motion`, lucide `Loader2`.
+- Produces: `export function UpdateProgressModal(): JSX.Element | null` — self-contained; reads the store, renders nothing when idle.
+
+Behavior:
+- Idle (`status === null`) → render null.
+- `restarting` phase → modal is forced open regardless of `visible` (imminent relaunch must be seen) and is non-dismissible.
+- `downloading` / `installing` while `visible` → dismissible modal (close button + backdrop call `dismiss()`).
+- Not visible but run active → corner pill (fixed bottom-right) with spinner + `Updating… {pct}%`, click → `reopen()`.
+- `error !== null` → modal shows error text + Retry (`retry()`) / Close (`close()`); pill hidden.
+- Determinate bar via ` ` when `pctOf` returns a number; otherwise a framer-motion indeterminate sweep.
+
+- [ ] **Step 1: Implement the component**
+
+```tsx
+"use client";
+
+import { AnimatePresence, motion } from "framer-motion";
+import { Loader2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Progress } from "@/components/ui/progress";
+import { pctOf, type UpdatePhase } from "@/lib/desktop/updater";
+import { useUpdateInstall } from "@/lib/desktop/use-update-install";
+
+function IndeterminateBar() {
+ return (
+
+
+
+ );
+}
+
+export function UpdateProgressModal() {
+ const t = useTranslations("DesktopUpdate");
+ const { status, error, visible, dismiss, reopen, retry, close } = useUpdateInstall();
+
+ if (!status) return null;
+
+ const pct = pctOf(status);
+ const phase: UpdatePhase = status.phase;
+ const locked = phase === "restarting"; // force-open, non-dismissible
+ const open = locked || (visible && !error) || (!!error && visible);
+
+ const label =
+ error != null
+ ? t("failed")
+ : phase === "installing"
+ ? t("installing")
+ : phase === "restarting"
+ ? t("restarting")
+ : pct !== null
+ ? t("downloadingPct", { pct })
+ : t("downloading");
+
+ // Corner pill when the run is active but the modal is dismissed (no error).
+ if (!open && !error) {
+ return (
+
+
+ {pct !== null ? t("pillPct", { pct }) : t("pill")}
+
+ );
+ }
+
+ return (
+ {
+ if (locked || error) return; // restart & errors don't backdrop-dismiss
+ if (!o) dismiss();
+ }}
+ >
+
+
+ {t("title")}
+
+
+ {error != null ? (
+
+
{error}
+
+ {t("retry")}
+ {t("close")}
+
+
+ ) : (
+
+ {pct !== null && phase === "downloading" ? (
+
+ ) : (
+
+ )}
+
+
+ {label}
+
+
+
{t("reassurance")}
+
+ )}
+
+
+ );
+}
+```
+
+Note on `hideClose`: check `ui/dialog.tsx` for whether `DialogContent` accepts a prop to hide the close button. If it does, pass it truthy when `locked`. If it does NOT, delete the `hideClose` line — the `onOpenChange` guard already blocks dismissal during restart, and the close button clicking through is acceptable (restart is sub-second). Do not invent the prop.
+
+- [ ] **Step 2: Typecheck**
+
+Run: `./node_modules/.bin/tsc --noEmit`
+Expected: no new errors from `update-progress-modal.tsx`. (Namespace keys resolve at runtime via en.json — added in Task 6; tsc does not type-check message keys.)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add apps/desktop-ui/src/components/desktop/update-progress-modal.tsx
+git commit -m "feat(updater): app-wide update progress modal + corner pill"
+```
+
+---
+
+### Task 4: Mount modal app-wide + rewire the auto-check toast
+
+**Files:**
+- Modify: `apps/desktop-ui/src/app/layout.tsx` (near line 123, where ` ` mounts)
+- Modify: `apps/desktop-ui/src/components/desktop/desktop-init.tsx:69-80`
+
+**Interfaces:**
+- Consumes: `UpdateProgressModal` (Task 3), `startUpdate` (Task 2).
+
+- [ ] **Step 1: Mount the modal in `layout.tsx`**
+
+Add the import and render it right after ` `:
+
+```tsx
+import { UpdateProgressModal } from "@/components/desktop/update-progress-modal";
+// ...
+
+
+```
+
+- [ ] **Step 2: Rewire the toast action in `desktop-init.tsx`**
+
+Replace the `onClick` (lines 69-80) so it hands off to the store instead of fire-and-forget. The toast text stays English (sibling, not migrated). Remove the `toast.loading`/`toast.error` install handling — the modal now owns progress and errors.
+
+```tsx
+action: {
+ label: "Restart & update",
+ onClick: () => {
+ toast.dismiss("desktop-update");
+ void import("@/lib/desktop/use-update-install").then((m) => m.startUpdate());
+ },
+},
+```
+
+(The `import("@/lib/desktop/updater")` at line 58 for `checkForUpdate` stays; only the install branch changes.)
+
+- [ ] **Step 3: Typecheck**
+
+Run: `./node_modules/.bin/tsc --noEmit`
+Expected: no new errors.
+
+- [ ] **Step 4: Verify existing suites still green**
+
+Run: `./node_modules/.bin/jest src/lib/__tests__/updater-status.test.ts src/lib/__tests__/use-update-install.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/desktop-ui/src/app/layout.tsx apps/desktop-ui/src/components/desktop/desktop-init.tsx
+git commit -m "feat(updater): route auto-check toast into the progress modal"
+```
+
+---
+
+### Task 5: Rewire settings dialog + About card to the store
+
+**Files:**
+- Modify: `apps/desktop-ui/src/components/desktop/desktop-update-dialog.tsx`
+- Modify: `apps/desktop-ui/src/components/desktop/app-version-label.tsx:33-49`
+
+**Interfaces:**
+- Consumes: `startUpdate` (Task 2).
+
+- [ ] **Step 1: `desktop-update-dialog.tsx` — delegate install to the store**
+
+Remove the hand-rolled bar and local progress state; the app-wide modal renders progress. Delete the `pct` helper (lines 17-20), the `installing`/`progress` state (lines 32-33), the `percent` calc (line 63), and the inline bar block (lines 97-108). Replace `install` (lines 50-61) with:
+
+```tsx
+const install = async () => {
+ const { startUpdate } = await import("@/lib/desktop/use-update-install");
+ startUpdate();
+ setOpen(false); // hand off to the app-wide progress modal
+};
+```
+
+Drop the `DownloadProgress` import (line 15 → keep only `UpdateInfo`). The update card keeps its "Download & install" button (lines 109-114) which now calls `install`; remove the `installing ? ... : ...` conditional (lines 97-114) and always render the button, since progress moved out. The dialog's `onOpenChange` guard `!installing && setOpen(o)` (line 72) → just `setOpen(o)`.
+
+- [ ] **Step 2: `app-version-label.tsx` — delegate install**
+
+Replace `handleCheck` (lines 33-49) install branch:
+
+```tsx
+const handleCheck = async () => {
+ setChecking(true);
+ try {
+ const { checkForUpdate } = await import("@/lib/desktop/updater");
+ const update = await checkForUpdate();
+ if (!update) {
+ toast.success("You're on the latest version");
+ return;
+ }
+ const { startUpdate } = await import("@/lib/desktop/use-update-install");
+ startUpdate();
+ } catch {
+ toast.error("Couldn't check for updates");
+ } finally {
+ setChecking(false);
+ }
+};
+```
+
+(Toast strings stay English — sibling, not migrated.)
+
+- [ ] **Step 3: Typecheck**
+
+Run: `./node_modules/.bin/tsc --noEmit`
+Expected: no new errors; no dangling `DownloadProgress` / `pct` references.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add apps/desktop-ui/src/components/desktop/desktop-update-dialog.tsx apps/desktop-ui/src/components/desktop/app-version-label.tsx
+git commit -m "feat(updater): route settings dialog + about card into progress modal"
+```
+
+---
+
+### Task 6: `DesktopUpdate` i18n namespace (27 locales)
+
+**Files:**
+- Modify: `apps/desktop-ui/messages/en.json` (+ 26 others: `af, ar, ca, cs, da, de, el, es, fa, fr, id, it, ja, ko, ms, nb, nl, pl, pt, pt-BR, ru, sv, tr, uk, vi, zh`)
+
+**Interfaces:**
+- Consumes: nothing. Produces the `DesktopUpdate` namespace consumed by Task 3.
+
+- [ ] **Step 1: Add the English block to `en.json`**
+
+Add a top-level `"DesktopUpdate"` namespace (place it alphabetically-ish near other Desktop* / Settings blocks; exact position doesn't matter):
+
+```json
+"DesktopUpdate": {
+ "title": "Updating MyDevTools",
+ "downloading": "Downloading update…",
+ "downloadingPct": "Downloading update… {pct}%",
+ "installing": "Installing update…",
+ "restarting": "Restarting MyDevTools…",
+ "reassurance": "Your offline data is never touched.",
+ "failed": "Update failed to install",
+ "retry": "Retry",
+ "close": "Close",
+ "pill": "Updating…",
+ "pillPct": "Updating… {pct}%"
+}
+```
+
+- [ ] **Step 2: Add translated blocks to the 26 locale files**
+
+For each locale file, add the same `DesktopUpdate` namespace with the values translated into that language. Keep the `{pct}` ICU placeholder verbatim (no space changes inside braces), keep "MyDevTools" as the untranslated product name. Example (`fr.json`):
+
+```json
+"DesktopUpdate": {
+ "title": "Mise à jour de MyDevTools",
+ "downloading": "Téléchargement de la mise à jour…",
+ "downloadingPct": "Téléchargement de la mise à jour… {pct} %",
+ "installing": "Installation de la mise à jour…",
+ "restarting": "Redémarrage de MyDevTools…",
+ "reassurance": "Vos données hors ligne ne sont jamais touchées.",
+ "failed": "Échec de l'installation de la mise à jour",
+ "retry": "Réessayer",
+ "close": "Fermer",
+ "pill": "Mise à jour…",
+ "pillPct": "Mise à jour… {pct} %"
+}
+```
+
+There are no ICU plural forms in this namespace (only a `{pct}` number substitution), so ru/uk/pl/cs/ar need no few/many/plural categories here — plain translated strings suffice.
+
+- [ ] **Step 3: Validate all 27 files parse and contain the namespace**
+
+Run:
+```bash
+cd apps/desktop-ui && for f in messages/*.json; do node -e "const j=require('./$f'); if(!j.DesktopUpdate||!j.DesktopUpdate.title){console.error('MISSING DesktopUpdate in $f');process.exit(1)}" || exit 1; done && echo "all 27 OK"
+```
+Expected: `all 27 OK` (also proves every file is still valid JSON).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add apps/desktop-ui/messages/*.json
+git commit -m "i18n(updater): add DesktopUpdate namespace across 27 locales"
+```
+
+---
+
+### Task 7: Full verification pass
+
+**Files:** none (verification only).
+
+- [ ] **Step 1: Typecheck the whole app**
+
+Run: `cd apps/desktop-ui && ./node_modules/.bin/tsc --noEmit`
+Expected: no errors introduced by this work.
+
+- [ ] **Step 2: Run the new tests + confirm no regressions in touched areas**
+
+Run: `cd apps/desktop-ui && ./node_modules/.bin/jest src/lib/__tests__/updater-status.test.ts src/lib/__tests__/use-update-install.test.ts`
+Expected: PASS. (Known pre-existing failing suites — react-window, pending-invitations-badge, encrypted-tool-placeholder, workspace-store — are unrelated; do not fix here.)
+
+- [ ] **Step 3: Manual smoke (documented, run in a real desktop build)**
+
+Against the release endpoint with a newer build available:
+1. Launch → auto-check toast appears → click **Restart & update** → toast dismisses, modal opens showing a moving Downloading bar with %, then **Installing update…**, then **Restarting MyDevTools…**, then relaunch.
+2. During download, close the modal → corner pill shows `Updating… N%` → click it → modal reopens.
+3. Settings → Check for updates → Download & install → dialog closes, same modal takes over.
+4. Force an error (offline mid-download) → modal shows the error + Retry/Close; Retry restarts.
+
+- [ ] **Step 4: Commit (if any doc/notes changed)** — otherwise nothing to commit.
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- Phase model (downloading/installing/restarting/error) → Task 1 (phases) + Task 3 (error UI). ✓
+- `useUpdateInstall` hook + imperative `startUpdate` for the toast → Task 2. ✓
+- App-wide `` mounted in layout → Tasks 3–4. ✓
+- Corner return pill / dismissible flow → Task 3. ✓
+- `restarting` forces modal open → Task 3 (`locked`). ✓
+- Indeterminate bar when `total` null, no fake percent → Task 1 `pctOf` + Task 3 `IndeterminateBar`. ✓
+- Rewire all three triggers → Tasks 4 (toast) + 5 (dialog, About). ✓
+- Reuse `ui/progress.tsx`, drop hand-rolled bar → Task 5. ✓
+- 27-locale `DesktopUpdate` namespace, siblings not migrated → Task 6. ✓
+- No Rust changes → honored throughout. ✓
+
+**Placeholder scan:** No TBD/TODO. The only conditional instruction (`hideClose` in Task 3) is explicitly resolved: check the prop, use it or delete the line — not left open. Locale translations in Task 6 are content the implementer produces per-locale, with a worked `fr` example and a validation gate; the key set and English source are fully specified.
+
+**Type consistency:** `UpdateStatus`/`UpdatePhase`/`reduceDownloadEvent`/`pctOf` defined in Task 1, consumed by the same names in Tasks 2–3. `startUpdate`/`dismiss`/`reopen`/`retry`/`close` defined in Task 2, used by the same names in Tasks 3–5. Callback signature change (`DownloadProgress` → `UpdateStatus`) is propagated to every consumer (Tasks 4–5 remove the old references). ✓
From 83bc903ddc0d33fa81fc759074384942a6bbeb34 Mon Sep 17 00:00:00 2001
From: AKHIL
Date: Thu, 23 Jul 2026 21:58:39 +0530
Subject: [PATCH 03/11] feat(updater): phase-aware install status callback
---
.../src/lib/__tests__/updater-status.test.ts | 46 +++++++++++++
apps/desktop-ui/src/lib/desktop/updater.ts | 64 +++++++++++++------
2 files changed, 92 insertions(+), 18 deletions(-)
create mode 100644 apps/desktop-ui/src/lib/__tests__/updater-status.test.ts
diff --git a/apps/desktop-ui/src/lib/__tests__/updater-status.test.ts b/apps/desktop-ui/src/lib/__tests__/updater-status.test.ts
new file mode 100644
index 00000000..d285221d
--- /dev/null
+++ b/apps/desktop-ui/src/lib/__tests__/updater-status.test.ts
@@ -0,0 +1,46 @@
+import { reduceDownloadEvent, pctOf } from "@/lib/desktop/updater";
+
+describe("reduceDownloadEvent", () => {
+ it("Started captures total and resets downloaded", () => {
+ const s = reduceDownloadEvent(
+ { downloaded: 999, total: null },
+ { event: "Started", data: { contentLength: 500 } } as any
+ );
+ expect(s).toEqual({ phase: "downloading", downloaded: 0, total: 500 });
+ });
+
+ it("Started with no contentLength → total null (indeterminate)", () => {
+ const s = reduceDownloadEvent(
+ { downloaded: 0, total: null },
+ { event: "Started", data: {} } as any
+ );
+ expect(s.total).toBeNull();
+ expect(s.phase).toBe("downloading");
+ });
+
+ it("Progress accumulates chunk lengths, keeps total", () => {
+ const s = reduceDownloadEvent(
+ { downloaded: 100, total: 500 },
+ { event: "Progress", data: { chunkLength: 50 } } as any
+ );
+ expect(s).toEqual({ phase: "downloading", downloaded: 150, total: 500 });
+ });
+
+ it("Finished switches phase to installing", () => {
+ const s = reduceDownloadEvent(
+ { downloaded: 500, total: 500 },
+ { event: "Finished", data: {} } as any
+ );
+ expect(s.phase).toBe("installing");
+ });
+});
+
+describe("pctOf", () => {
+ it("null total → null (indeterminate, no fake number)", () => {
+ expect(pctOf({ downloaded: 10, total: null })).toBeNull();
+ });
+ it("rounds and clamps at 100", () => {
+ expect(pctOf({ downloaded: 250, total: 500 })).toBe(50);
+ expect(pctOf({ downloaded: 600, total: 500 })).toBe(100);
+ });
+});
diff --git a/apps/desktop-ui/src/lib/desktop/updater.ts b/apps/desktop-ui/src/lib/desktop/updater.ts
index 5274b0e5..0b82e532 100644
--- a/apps/desktop-ui/src/lib/desktop/updater.ts
+++ b/apps/desktop-ui/src/lib/desktop/updater.ts
@@ -10,7 +10,7 @@
* Every update is verified against the updater public key baked into
* tauri.conf.json before it's allowed to install.
*/
-import type { Update } from "@tauri-apps/plugin-updater";
+import type { DownloadEvent, Update } from "@tauri-apps/plugin-updater";
import { isDesktop } from "./is-desktop";
@@ -21,7 +21,41 @@ export type UpdateInfo = {
date?: string;
};
-export type DownloadProgress = { downloaded: number; total: number | null };
+export type UpdatePhase = "downloading" | "installing" | "restarting";
+export type UpdateStatus = {
+ phase: UpdatePhase;
+ downloaded: number;
+ total: number | null;
+};
+
+/** Map a Tauri DownloadEvent to the next status. Pure — unit tested. */
+export function reduceDownloadEvent(
+ prev: { downloaded: number; total: number | null },
+ event: DownloadEvent
+): UpdateStatus {
+ switch (event.event) {
+ case "Started":
+ return {
+ phase: "downloading",
+ downloaded: 0,
+ total: event.data.contentLength ?? null,
+ };
+ case "Progress":
+ return {
+ phase: "downloading",
+ downloaded: prev.downloaded + event.data.chunkLength,
+ total: prev.total,
+ };
+ case "Finished":
+ return { phase: "installing", downloaded: prev.downloaded, total: prev.total };
+ }
+}
+
+/** Download percentage, or null when the server sent no Content-Length. */
+export function pctOf(s: { downloaded: number; total: number | null }): number | null {
+ if (!s.total) return null;
+ return Math.min(100, Math.round((s.downloaded / s.total) * 100));
+}
// Handle to the Update object between check() and install so we don't re-check.
let pending: Update | null = null;
@@ -46,32 +80,26 @@ export async function checkForUpdate(): Promise {
* Re-checks first if nothing is pending (e.g. called directly).
*/
export async function installUpdate(
- onProgress?: (p: DownloadProgress) => void
+ onStatus?: (s: UpdateStatus) => void
): Promise {
if (!pending) {
await checkForUpdate();
if (!pending) throw new Error("No update available to install");
}
const update = pending;
- let downloaded = 0;
- let total: number | null = null;
+ let acc: { downloaded: number; total: number | null } = {
+ downloaded: 0,
+ total: null,
+ };
await update.downloadAndInstall((event) => {
- switch (event.event) {
- case "Started":
- total = event.data.contentLength ?? null;
- break;
- case "Progress":
- downloaded += event.data.chunkLength;
- onProgress?.({ downloaded, total });
- break;
- case "Finished":
- onProgress?.({ downloaded, total });
- break;
- }
+ const s = reduceDownloadEvent(acc, event);
+ acc = { downloaded: s.downloaded, total: s.total };
+ onStatus?.(s);
});
- // Restart into the freshly installed version.
+ // Bundle swapped in place; now relaunch into the new version.
+ onStatus?.({ phase: "restarting", downloaded: acc.downloaded, total: acc.total });
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
}
From f666503dfca47f4330d26b4954a3dc065f75e558 Mon Sep 17 00:00:00 2001
From: AKHIL
Date: Thu, 23 Jul 2026 22:10:43 +0530
Subject: [PATCH 04/11] feat(updater): app-wide install store +
useUpdateInstall hook
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../lib/__tests__/use-update-install.test.ts | 61 +++++++++++++++
.../src/lib/desktop/use-update-install.ts | 76 +++++++++++++++++++
2 files changed, 137 insertions(+)
create mode 100644 apps/desktop-ui/src/lib/__tests__/use-update-install.test.ts
create mode 100644 apps/desktop-ui/src/lib/desktop/use-update-install.ts
diff --git a/apps/desktop-ui/src/lib/__tests__/use-update-install.test.ts b/apps/desktop-ui/src/lib/__tests__/use-update-install.test.ts
new file mode 100644
index 00000000..d91c8705
--- /dev/null
+++ b/apps/desktop-ui/src/lib/__tests__/use-update-install.test.ts
@@ -0,0 +1,61 @@
+let emit: ((s: any) => void) | undefined;
+let resolveInstall: (() => void) | undefined;
+
+jest.mock("@/lib/desktop/updater", () => ({
+ installUpdate: jest.fn((onStatus?: (s: any) => void) => {
+ emit = onStatus;
+ return new Promise((res) => {
+ resolveInstall = res;
+ });
+ }),
+}));
+
+import { startUpdate, dismissUpdate, reopenUpdate, closeUpdate } from "@/lib/desktop/use-update-install";
+// read module state through a tiny getter the module exposes for tests:
+import { __getState } from "@/lib/desktop/use-update-install";
+
+beforeEach(() => {
+ emit = undefined;
+ resolveInstall = undefined;
+ closeUpdate(); // reset store between tests
+});
+
+describe("install store", () => {
+ it("startUpdate opens modal and enters downloading", async () => {
+ startUpdate();
+ expect(__getState().visible).toBe(true);
+ expect(__getState().status?.phase).toBe("downloading");
+ });
+
+ it("status emissions update the store", async () => {
+ startUpdate();
+ await Promise.resolve(); // let dynamic import().then microtask flush
+ emit?.({ phase: "installing", downloaded: 5, total: 5 });
+ expect(__getState().status?.phase).toBe("installing");
+ });
+
+ it("startUpdate is idempotent while running", async () => {
+ startUpdate();
+ const first = __getState().status;
+ startUpdate();
+ expect(__getState().status).toBe(first);
+ });
+
+ it("dismiss hides modal but keeps the run; reopen shows it", () => {
+ startUpdate();
+ dismissUpdate();
+ expect(__getState().visible).toBe(false);
+ expect(__getState().status).not.toBeNull();
+ reopenUpdate();
+ expect(__getState().visible).toBe(true);
+ });
+
+ it("a rejected install sets error", async () => {
+ const { installUpdate } = require("@/lib/desktop/updater");
+ (installUpdate as jest.Mock).mockImplementationOnce(() => Promise.reject(new Error("boom")));
+ startUpdate();
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(__getState().error).toBe("boom");
+ });
+});
diff --git a/apps/desktop-ui/src/lib/desktop/use-update-install.ts b/apps/desktop-ui/src/lib/desktop/use-update-install.ts
new file mode 100644
index 00000000..01a09e83
--- /dev/null
+++ b/apps/desktop-ui/src/lib/desktop/use-update-install.ts
@@ -0,0 +1,76 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+
+import type { UpdateStatus } from "@/lib/desktop/updater";
+
+type State = {
+ status: UpdateStatus | null;
+ error: string | null;
+ visible: boolean;
+};
+
+let state: State = { status: null, error: null, visible: false };
+const subscribers = new Set<() => void>();
+
+function set(patch: Partial): void {
+ state = { ...state, ...patch };
+ subscribers.forEach((fn) => fn());
+}
+
+/** Imperative entry point — usable from the sonner toast callback (no React tree). */
+export function startUpdate(): void {
+ if (state.status) return; // already running
+ set({
+ status: { phase: "downloading", downloaded: 0, total: null },
+ error: null,
+ visible: true,
+ });
+ void import("@/lib/desktop/updater")
+ .then(({ installUpdate }) => installUpdate((s) => set({ status: s })))
+ .catch((e) =>
+ set({ error: e instanceof Error ? e.message : "Update failed to install" })
+ );
+ // On success the process relaunches — there is no resolve branch to handle.
+}
+
+export function dismissUpdate(): void {
+ set({ visible: false });
+}
+export function reopenUpdate(): void {
+ set({ visible: true });
+}
+export function retryUpdate(): void {
+ set({ status: null, error: null });
+ startUpdate();
+}
+export function closeUpdate(): void {
+ set({ status: null, error: null, visible: false });
+}
+
+/** Test-only accessor for the module store. */
+export function __getState(): State {
+ return state;
+}
+
+function subscribe(cb: () => void): () => void {
+ subscribers.add(cb);
+ return () => {
+ subscribers.delete(cb);
+ };
+}
+
+export function useUpdateInstall() {
+ const snap = useSyncExternalStore(
+ subscribe,
+ () => state,
+ () => state
+ );
+ return {
+ ...snap,
+ dismiss: dismissUpdate,
+ reopen: reopenUpdate,
+ retry: retryUpdate,
+ close: closeUpdate,
+ };
+}
From e3734ff2595573749e9457dd005236db831db102 Mon Sep 17 00:00:00 2001
From: AKHIL
Date: Thu, 23 Jul 2026 22:13:20 +0530
Subject: [PATCH 05/11] test(updater): fix jest mock hoisting in install-store
test
---
.../lib/__tests__/use-update-install.test.ts | 43 ++++++++++---------
1 file changed, 23 insertions(+), 20 deletions(-)
diff --git a/apps/desktop-ui/src/lib/__tests__/use-update-install.test.ts b/apps/desktop-ui/src/lib/__tests__/use-update-install.test.ts
index d91c8705..0959748a 100644
--- a/apps/desktop-ui/src/lib/__tests__/use-update-install.test.ts
+++ b/apps/desktop-ui/src/lib/__tests__/use-update-install.test.ts
@@ -1,27 +1,32 @@
-let emit: ((s: any) => void) | undefined;
-let resolveInstall: (() => void) | undefined;
+import { installUpdate } from "@/lib/desktop/updater";
jest.mock("@/lib/desktop/updater", () => ({
- installUpdate: jest.fn((onStatus?: (s: any) => void) => {
- emit = onStatus;
- return new Promise((res) => {
- resolveInstall = res;
- });
- }),
+ installUpdate: jest.fn(),
}));
-import { startUpdate, dismissUpdate, reopenUpdate, closeUpdate } from "@/lib/desktop/use-update-install";
-// read module state through a tiny getter the module exposes for tests:
-import { __getState } from "@/lib/desktop/use-update-install";
+import {
+ startUpdate,
+ dismissUpdate,
+ reopenUpdate,
+ closeUpdate,
+ __getState,
+} from "@/lib/desktop/use-update-install";
+
+const mockInstall = installUpdate as jest.Mock;
+let emit: ((s: any) => void) | undefined;
beforeEach(() => {
emit = undefined;
- resolveInstall = undefined;
- closeUpdate(); // reset store between tests
+ closeUpdate(); // reset the module store between tests
+ mockInstall.mockReset();
+ mockInstall.mockImplementation((onStatus?: (s: any) => void) => {
+ emit = onStatus;
+ return new Promise(() => {}); // capture callback, never resolve
+ });
});
describe("install store", () => {
- it("startUpdate opens modal and enters downloading", async () => {
+ it("startUpdate opens modal and enters downloading", () => {
startUpdate();
expect(__getState().visible).toBe(true);
expect(__getState().status?.phase).toBe("downloading");
@@ -29,12 +34,12 @@ describe("install store", () => {
it("status emissions update the store", async () => {
startUpdate();
- await Promise.resolve(); // let dynamic import().then microtask flush
+ await new Promise((r) => setTimeout(r, 0)); // let dynamic import().then flush
emit?.({ phase: "installing", downloaded: 5, total: 5 });
expect(__getState().status?.phase).toBe("installing");
});
- it("startUpdate is idempotent while running", async () => {
+ it("startUpdate is idempotent while running", () => {
startUpdate();
const first = __getState().status;
startUpdate();
@@ -51,11 +56,9 @@ describe("install store", () => {
});
it("a rejected install sets error", async () => {
- const { installUpdate } = require("@/lib/desktop/updater");
- (installUpdate as jest.Mock).mockImplementationOnce(() => Promise.reject(new Error("boom")));
+ mockInstall.mockImplementationOnce(() => Promise.reject(new Error("boom")));
startUpdate();
- await Promise.resolve();
- await Promise.resolve();
+ await new Promise((r) => setTimeout(r, 0));
expect(__getState().error).toBe("boom");
});
});
From ccdcb3c4fa4b1d06e1f30954b39da44f50fc305f Mon Sep 17 00:00:00 2001
From: AKHIL
Date: Thu, 23 Jul 2026 22:18:17 +0530
Subject: [PATCH 06/11] feat(updater): app-wide update progress modal + corner
pill
---
.../desktop/update-progress-modal.tsx | 107 ++++++++++++++++++
1 file changed, 107 insertions(+)
create mode 100644 apps/desktop-ui/src/components/desktop/update-progress-modal.tsx
diff --git a/apps/desktop-ui/src/components/desktop/update-progress-modal.tsx b/apps/desktop-ui/src/components/desktop/update-progress-modal.tsx
new file mode 100644
index 00000000..56c5dac9
--- /dev/null
+++ b/apps/desktop-ui/src/components/desktop/update-progress-modal.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import { AnimatePresence, motion } from "framer-motion";
+import { Loader2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Progress } from "@/components/ui/progress";
+import { pctOf, type UpdatePhase } from "@/lib/desktop/updater";
+import { useUpdateInstall } from "@/lib/desktop/use-update-install";
+
+function IndeterminateBar() {
+ return (
+
+
+
+ );
+}
+
+export function UpdateProgressModal() {
+ const t = useTranslations("DesktopUpdate");
+ const { status, error, visible, dismiss, reopen, retry, close } = useUpdateInstall();
+
+ if (!status) return null;
+
+ const pct = pctOf(status);
+ const phase: UpdatePhase = status.phase;
+ const locked = phase === "restarting"; // force-open, non-dismissible
+ const open = locked || (visible && !error) || (!!error && visible);
+
+ const label =
+ error != null
+ ? t("failed")
+ : phase === "installing"
+ ? t("installing")
+ : phase === "restarting"
+ ? t("restarting")
+ : pct !== null
+ ? t("downloadingPct", { pct })
+ : t("downloading");
+
+ // Corner pill when the run is active but the modal is dismissed (no error).
+ if (!open && !error) {
+ return (
+
+
+ {pct !== null ? t("pillPct", { pct }) : t("pill")}
+
+ );
+ }
+
+ return (
+ {
+ if (locked || error) return; // restart & errors don't backdrop-dismiss
+ if (!o) dismiss();
+ }}
+ >
+
+
+ {t("title")}
+
+
+ {error != null ? (
+
+
{error}
+
+ {t("retry")}
+ {t("close")}
+
+
+ ) : (
+
+ {pct !== null && phase === "downloading" ? (
+
+ ) : (
+
+ )}
+
+
+ {label}
+
+
+
{t("reassurance")}
+
+ )}
+
+
+ );
+}
From b9f8264ed3708e3d3d93dfdd9b026cdbd76fb4e5 Mon Sep 17 00:00:00 2001
From: AKHIL
Date: Thu, 23 Jul 2026 22:23:26 +0530
Subject: [PATCH 07/11] fix(updater): error state always shows modal; steadier
progress animation
---
.../src/components/desktop/update-progress-modal.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/apps/desktop-ui/src/components/desktop/update-progress-modal.tsx b/apps/desktop-ui/src/components/desktop/update-progress-modal.tsx
index 56c5dac9..86078f79 100644
--- a/apps/desktop-ui/src/components/desktop/update-progress-modal.tsx
+++ b/apps/desktop-ui/src/components/desktop/update-progress-modal.tsx
@@ -31,7 +31,7 @@ export function UpdateProgressModal() {
const pct = pctOf(status);
const phase: UpdatePhase = status.phase;
const locked = phase === "restarting"; // force-open, non-dismissible
- const open = locked || (visible && !error) || (!!error && visible);
+ const open = locked || visible || error != null;
const label =
error != null
@@ -45,7 +45,7 @@ export function UpdateProgressModal() {
: t("downloading");
// Corner pill when the run is active but the modal is dismissed (no error).
- if (!open && !error) {
+ if (!open) {
return (
-
+
{t("title")}
@@ -88,7 +88,7 @@ export function UpdateProgressModal() {
)}
Date: Thu, 23 Jul 2026 22:26:58 +0530
Subject: [PATCH 08/11] feat(updater): route auto-check toast into the progress
modal
---
apps/desktop-ui/src/app/layout.tsx | 2 ++
.../src/components/desktop/desktop-init.tsx | 12 ++----------
2 files changed, 4 insertions(+), 10 deletions(-)
diff --git a/apps/desktop-ui/src/app/layout.tsx b/apps/desktop-ui/src/app/layout.tsx
index db39dab9..8dc9555f 100644
--- a/apps/desktop-ui/src/app/layout.tsx
+++ b/apps/desktop-ui/src/app/layout.tsx
@@ -8,6 +8,7 @@ import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';
import { ClientShell } from "@/components/client-shell";
import { DesktopInit } from "@/components/desktop/desktop-init";
+import { UpdateProgressModal } from "@/components/desktop/update-progress-modal";
import "./globals.css";
@@ -121,6 +122,7 @@ export default async function RootLayout({
>
+
{children}
diff --git a/apps/desktop-ui/src/components/desktop/desktop-init.tsx b/apps/desktop-ui/src/components/desktop/desktop-init.tsx
index a01bac1c..8451f37d 100644
--- a/apps/desktop-ui/src/components/desktop/desktop-init.tsx
+++ b/apps/desktop-ui/src/components/desktop/desktop-init.tsx
@@ -67,16 +67,8 @@ export function DesktopInit() {
action: {
label: "Restart & update",
onClick: () => {
- toast.loading("Downloading update…", {
- id: "desktop-update",
- duration: Infinity,
- });
- m.installUpdate().catch((e) => {
- toast.error(
- e instanceof Error ? e.message : "Update failed to install",
- { id: "desktop-update" }
- );
- });
+ toast.dismiss("desktop-update");
+ void import("@/lib/desktop/use-update-install").then((m) => m.startUpdate());
},
},
});
From a383d199da7bc750a2c2162a192780427c9ca10a Mon Sep 17 00:00:00 2001
From: AKHIL
Date: Thu, 23 Jul 2026 22:30:36 +0530
Subject: [PATCH 09/11] feat(updater): route settings dialog + about card into
progress modal
---
.../components/desktop/app-version-label.tsx | 6 +--
.../desktop/desktop-update-dialog.tsx | 48 ++++---------------
2 files changed, 12 insertions(+), 42 deletions(-)
diff --git a/apps/desktop-ui/src/components/desktop/app-version-label.tsx b/apps/desktop-ui/src/components/desktop/app-version-label.tsx
index 36a102bb..a18c6015 100644
--- a/apps/desktop-ui/src/components/desktop/app-version-label.tsx
+++ b/apps/desktop-ui/src/components/desktop/app-version-label.tsx
@@ -33,14 +33,14 @@ export function AppVersionLabel() {
const handleCheck = async () => {
setChecking(true);
try {
- const { checkForUpdate, installUpdate } = await import("@/lib/desktop/updater");
+ const { checkForUpdate } = await import("@/lib/desktop/updater");
const update = await checkForUpdate();
if (!update) {
toast.success("You're on the latest version");
return;
}
- toast.info(`Updating to v${update.version}…`);
- await installUpdate();
+ const { startUpdate } = await import("@/lib/desktop/use-update-install");
+ startUpdate();
} catch {
toast.error("Couldn't check for updates");
} finally {
diff --git a/apps/desktop-ui/src/components/desktop/desktop-update-dialog.tsx b/apps/desktop-ui/src/components/desktop/desktop-update-dialog.tsx
index e1a3a3d1..48f904a1 100644
--- a/apps/desktop-ui/src/components/desktop/desktop-update-dialog.tsx
+++ b/apps/desktop-ui/src/components/desktop/desktop-update-dialog.tsx
@@ -12,12 +12,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
-import type { DownloadProgress, UpdateInfo } from "@/lib/desktop/updater";
-
-function pct(p: DownloadProgress): number | null {
- if (!p.total) return null;
- return Math.min(100, Math.round((p.downloaded / p.total) * 100));
-}
+import type { UpdateInfo } from "@/lib/desktop/updater";
/**
* "Check for updates" button + modal. Shows the running version and, when a
@@ -29,8 +24,6 @@ export function DesktopUpdateDialog() {
const [version, setVersion] = useState("");
const [checking, setChecking] = useState(false);
const [update, setUpdate] = useState(null);
- const [installing, setInstalling] = useState(false);
- const [progress, setProgress] = useState(null);
const check = async () => {
setOpen(true);
@@ -48,20 +41,11 @@ export function DesktopUpdateDialog() {
};
const install = async () => {
- setInstalling(true);
- try {
- const { installUpdate } = await import("@/lib/desktop/updater");
- await installUpdate(setProgress);
- // On success the app relaunches into the new version; nothing to do here.
- } catch (e) {
- setInstalling(false);
- setProgress(null);
- toast.error(e instanceof Error ? e.message : "Update failed to install");
- }
+ const { startUpdate } = await import("@/lib/desktop/use-update-install");
+ startUpdate();
+ setOpen(false); // hand off to the app-wide progress modal
};
- const percent = progress ? pct(progress) : null;
-
return (
<>
void check()} disabled={checking}>
@@ -69,7 +53,7 @@ export function DesktopUpdateDialog() {
Check for updates
- !installing && setOpen(o)}>
+ setOpen(o)}>
Updates
@@ -94,24 +78,10 @@ export function DesktopUpdateDialog() {
{update.notes}
) : null}
- {installing ? (
-
-
-
- {percent !== null ? `Downloading… ${percent}%` : "Downloading…"}
-
-
- ) : (
- void install()}>
-
- Download & install
-
- )}
+ void install()}>
+
+ Download & install
+
) : (
From 1aaf302e1054a020ba69f4e259e376010d7da84b Mon Sep 17 00:00:00 2001
From: AKHIL
Date: Thu, 23 Jul 2026 22:36:55 +0530
Subject: [PATCH 10/11] i18n(updater): add DesktopUpdate namespace across 27
locales
---
apps/desktop-ui/messages/af.json | 13 +++++++++++++
apps/desktop-ui/messages/ar.json | 13 +++++++++++++
apps/desktop-ui/messages/ca.json | 13 +++++++++++++
apps/desktop-ui/messages/cs.json | 13 +++++++++++++
apps/desktop-ui/messages/da.json | 13 +++++++++++++
apps/desktop-ui/messages/de.json | 13 +++++++++++++
apps/desktop-ui/messages/el.json | 13 +++++++++++++
apps/desktop-ui/messages/en.json | 13 +++++++++++++
apps/desktop-ui/messages/es.json | 13 +++++++++++++
apps/desktop-ui/messages/fa.json | 13 +++++++++++++
apps/desktop-ui/messages/fr.json | 13 +++++++++++++
apps/desktop-ui/messages/id.json | 13 +++++++++++++
apps/desktop-ui/messages/it.json | 13 +++++++++++++
apps/desktop-ui/messages/ja.json | 13 +++++++++++++
apps/desktop-ui/messages/ko.json | 13 +++++++++++++
apps/desktop-ui/messages/ms.json | 13 +++++++++++++
apps/desktop-ui/messages/nb.json | 13 +++++++++++++
apps/desktop-ui/messages/nl.json | 13 +++++++++++++
apps/desktop-ui/messages/pl.json | 13 +++++++++++++
apps/desktop-ui/messages/pt-BR.json | 13 +++++++++++++
apps/desktop-ui/messages/pt.json | 13 +++++++++++++
apps/desktop-ui/messages/ru.json | 13 +++++++++++++
apps/desktop-ui/messages/sv.json | 13 +++++++++++++
apps/desktop-ui/messages/tr.json | 13 +++++++++++++
apps/desktop-ui/messages/uk.json | 13 +++++++++++++
apps/desktop-ui/messages/vi.json | 13 +++++++++++++
apps/desktop-ui/messages/zh.json | 13 +++++++++++++
27 files changed, 351 insertions(+)
diff --git a/apps/desktop-ui/messages/af.json b/apps/desktop-ui/messages/af.json
index 3768e067..a1c5e292 100644
--- a/apps/desktop-ui/messages/af.json
+++ b/apps/desktop-ui/messages/af.json
@@ -4788,5 +4788,18 @@
},
"ToolPageHeader": {
"offline": "100% aflyn"
+ },
+ "DesktopUpdate": {
+ "title": "MyDevTools word bygewerk",
+ "downloading": "Opdatering word afgelaai…",
+ "downloadingPct": "Opdatering word afgelaai… {pct}%",
+ "installing": "Opdatering word geïnstalleer…",
+ "restarting": "MyDevTools word herbegin…",
+ "reassurance": "Jou vanlyn-data word nooit aangeraak nie.",
+ "failed": "Opdatering kon nie geïnstalleer word nie",
+ "retry": "Probeer weer",
+ "close": "Sluit",
+ "pill": "Word bygewerk…",
+ "pillPct": "Word bygewerk… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/ar.json b/apps/desktop-ui/messages/ar.json
index 71bda17a..6a70afa1 100644
--- a/apps/desktop-ui/messages/ar.json
+++ b/apps/desktop-ui/messages/ar.json
@@ -4873,5 +4873,18 @@
},
"ToolPageHeader": {
"offline": "100% دون اتصال"
+ },
+ "DesktopUpdate": {
+ "title": "جارٍ تحديث MyDevTools",
+ "downloading": "جارٍ تنزيل التحديث…",
+ "downloadingPct": "جارٍ تنزيل التحديث… {pct}%",
+ "installing": "جارٍ تثبيت التحديث…",
+ "restarting": "جارٍ إعادة تشغيل MyDevTools…",
+ "reassurance": "بياناتك دون اتصال لا يتم المساس بها أبدًا.",
+ "failed": "فشل تثبيت التحديث",
+ "retry": "إعادة المحاولة",
+ "close": "إغلاق",
+ "pill": "جارٍ التحديث…",
+ "pillPct": "جارٍ التحديث… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/ca.json b/apps/desktop-ui/messages/ca.json
index 80267ab9..400d3e11 100644
--- a/apps/desktop-ui/messages/ca.json
+++ b/apps/desktop-ui/messages/ca.json
@@ -4873,5 +4873,18 @@
},
"ToolPageHeader": {
"offline": "100% fora de línia"
+ },
+ "DesktopUpdate": {
+ "title": "S'està actualitzant MyDevTools",
+ "downloading": "Baixant l'actualització…",
+ "downloadingPct": "Baixant l'actualització… {pct}%",
+ "installing": "Instal·lant l'actualització…",
+ "restarting": "Reiniciant MyDevTools…",
+ "reassurance": "Les teves dades sense connexió mai es toquen.",
+ "failed": "No s'ha pogut instal·lar l'actualització",
+ "retry": "Torna-ho a provar",
+ "close": "Tanca",
+ "pill": "S'està actualitzant…",
+ "pillPct": "S'està actualitzant… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/cs.json b/apps/desktop-ui/messages/cs.json
index 023c2625..fc80c3c1 100644
--- a/apps/desktop-ui/messages/cs.json
+++ b/apps/desktop-ui/messages/cs.json
@@ -4873,5 +4873,18 @@
},
"ToolPageHeader": {
"offline": "100% offline"
+ },
+ "DesktopUpdate": {
+ "title": "Aktualizace MyDevTools",
+ "downloading": "Stahování aktualizace…",
+ "downloadingPct": "Stahování aktualizace… {pct}%",
+ "installing": "Instalace aktualizace…",
+ "restarting": "Restartování MyDevTools…",
+ "reassurance": "Vašich offline dat se nikdy nedotkneme.",
+ "failed": "Aktualizaci se nepodařilo nainstalovat",
+ "retry": "Zkusit znovu",
+ "close": "Zavřít",
+ "pill": "Aktualizace…",
+ "pillPct": "Aktualizace… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/da.json b/apps/desktop-ui/messages/da.json
index 33eea23b..ae9a71f6 100644
--- a/apps/desktop-ui/messages/da.json
+++ b/apps/desktop-ui/messages/da.json
@@ -4873,5 +4873,18 @@
},
"ToolPageHeader": {
"offline": "100 % offline"
+ },
+ "DesktopUpdate": {
+ "title": "Opdaterer MyDevTools",
+ "downloading": "Downloader opdatering…",
+ "downloadingPct": "Downloader opdatering… {pct} %",
+ "installing": "Installerer opdatering…",
+ "restarting": "Genstarter MyDevTools…",
+ "reassurance": "Dine offline-data bliver aldrig rørt.",
+ "failed": "Opdateringen kunne ikke installeres",
+ "retry": "Prøv igen",
+ "close": "Luk",
+ "pill": "Opdaterer…",
+ "pillPct": "Opdaterer… {pct} %"
}
}
diff --git a/apps/desktop-ui/messages/de.json b/apps/desktop-ui/messages/de.json
index 75cbaa04..433b93f6 100644
--- a/apps/desktop-ui/messages/de.json
+++ b/apps/desktop-ui/messages/de.json
@@ -4873,5 +4873,18 @@
},
"ToolPageHeader": {
"offline": "100 % offline"
+ },
+ "DesktopUpdate": {
+ "title": "MyDevTools wird aktualisiert",
+ "downloading": "Update wird heruntergeladen…",
+ "downloadingPct": "Update wird heruntergeladen… {pct} %",
+ "installing": "Update wird installiert…",
+ "restarting": "MyDevTools wird neu gestartet…",
+ "reassurance": "Deine Offline-Daten werden niemals verändert.",
+ "failed": "Update konnte nicht installiert werden",
+ "retry": "Erneut versuchen",
+ "close": "Schließen",
+ "pill": "Wird aktualisiert…",
+ "pillPct": "Wird aktualisiert… {pct} %"
}
}
diff --git a/apps/desktop-ui/messages/el.json b/apps/desktop-ui/messages/el.json
index 37da4c00..2bf66c30 100644
--- a/apps/desktop-ui/messages/el.json
+++ b/apps/desktop-ui/messages/el.json
@@ -4873,5 +4873,18 @@
},
"ToolPageHeader": {
"offline": "100% εκτός σύνδεσης"
+ },
+ "DesktopUpdate": {
+ "title": "Ενημέρωση MyDevTools",
+ "downloading": "Λήψη ενημέρωσης…",
+ "downloadingPct": "Λήψη ενημέρωσης… {pct}%",
+ "installing": "Εγκατάσταση ενημέρωσης…",
+ "restarting": "Επανεκκίνηση του MyDevTools…",
+ "reassurance": "Τα δεδομένα σας εκτός σύνδεσης δεν επηρεάζονται ποτέ.",
+ "failed": "Η εγκατάσταση της ενημέρωσης απέτυχε",
+ "retry": "Επανάληψη",
+ "close": "Κλείσιμο",
+ "pill": "Ενημέρωση…",
+ "pillPct": "Ενημέρωση… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json
index 9129035e..141a11f6 100644
--- a/apps/desktop-ui/messages/en.json
+++ b/apps/desktop-ui/messages/en.json
@@ -4813,5 +4813,18 @@
},
"ToolPageHeader": {
"offline": "100% offline"
+ },
+ "DesktopUpdate": {
+ "title": "Updating MyDevTools",
+ "downloading": "Downloading update…",
+ "downloadingPct": "Downloading update… {pct}%",
+ "installing": "Installing update…",
+ "restarting": "Restarting MyDevTools…",
+ "reassurance": "Your offline data is never touched.",
+ "failed": "Update failed to install",
+ "retry": "Retry",
+ "close": "Close",
+ "pill": "Updating…",
+ "pillPct": "Updating… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/es.json b/apps/desktop-ui/messages/es.json
index a5fbed68..a4436952 100644
--- a/apps/desktop-ui/messages/es.json
+++ b/apps/desktop-ui/messages/es.json
@@ -4788,5 +4788,18 @@
},
"ToolPageHeader": {
"offline": "100% sin conexión"
+ },
+ "DesktopUpdate": {
+ "title": "Actualizando MyDevTools",
+ "downloading": "Descargando actualización…",
+ "downloadingPct": "Descargando actualización… {pct}%",
+ "installing": "Instalando actualización…",
+ "restarting": "Reiniciando MyDevTools…",
+ "reassurance": "Tus datos sin conexión nunca se modifican.",
+ "failed": "No se pudo instalar la actualización",
+ "retry": "Reintentar",
+ "close": "Cerrar",
+ "pill": "Actualizando…",
+ "pillPct": "Actualizando… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/fa.json b/apps/desktop-ui/messages/fa.json
index acd2fe02..d8d0dcff 100644
--- a/apps/desktop-ui/messages/fa.json
+++ b/apps/desktop-ui/messages/fa.json
@@ -4788,5 +4788,18 @@
},
"ToolPageHeader": {
"offline": "۱۰۰٪ آفلاین"
+ },
+ "DesktopUpdate": {
+ "title": "در حال بهروزرسانی MyDevTools",
+ "downloading": "در حال دانلود بهروزرسانی…",
+ "downloadingPct": "در حال دانلود بهروزرسانی… {pct}٪",
+ "installing": "در حال نصب بهروزرسانی…",
+ "restarting": "در حال راهاندازی مجدد MyDevTools…",
+ "reassurance": "دادههای آفلاین شما هرگز دستکاری نمیشوند.",
+ "failed": "نصب بهروزرسانی ناموفق بود",
+ "retry": "تلاش دوباره",
+ "close": "بستن",
+ "pill": "در حال بهروزرسانی…",
+ "pillPct": "در حال بهروزرسانی… {pct}٪"
}
}
diff --git a/apps/desktop-ui/messages/fr.json b/apps/desktop-ui/messages/fr.json
index faf08b59..49af343c 100644
--- a/apps/desktop-ui/messages/fr.json
+++ b/apps/desktop-ui/messages/fr.json
@@ -4788,5 +4788,18 @@
},
"ToolPageHeader": {
"offline": "100 % hors ligne"
+ },
+ "DesktopUpdate": {
+ "title": "Mise à jour de MyDevTools",
+ "downloading": "Téléchargement de la mise à jour…",
+ "downloadingPct": "Téléchargement de la mise à jour… {pct} %",
+ "installing": "Installation de la mise à jour…",
+ "restarting": "Redémarrage de MyDevTools…",
+ "reassurance": "Vos données hors ligne ne sont jamais touchées.",
+ "failed": "Échec de l'installation de la mise à jour",
+ "retry": "Réessayer",
+ "close": "Fermer",
+ "pill": "Mise à jour…",
+ "pillPct": "Mise à jour… {pct} %"
}
}
diff --git a/apps/desktop-ui/messages/id.json b/apps/desktop-ui/messages/id.json
index a8581aa4..361df748 100644
--- a/apps/desktop-ui/messages/id.json
+++ b/apps/desktop-ui/messages/id.json
@@ -4873,5 +4873,18 @@
},
"ToolPageHeader": {
"offline": "100% offline"
+ },
+ "DesktopUpdate": {
+ "title": "Memperbarui MyDevTools",
+ "downloading": "Mengunduh pembaruan…",
+ "downloadingPct": "Mengunduh pembaruan… {pct}%",
+ "installing": "Menginstal pembaruan…",
+ "restarting": "Memulai ulang MyDevTools…",
+ "reassurance": "Data offline Anda tidak akan pernah disentuh.",
+ "failed": "Pembaruan gagal diinstal",
+ "retry": "Coba lagi",
+ "close": "Tutup",
+ "pill": "Memperbarui…",
+ "pillPct": "Memperbarui… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/it.json b/apps/desktop-ui/messages/it.json
index d103eb86..0ee93ac9 100644
--- a/apps/desktop-ui/messages/it.json
+++ b/apps/desktop-ui/messages/it.json
@@ -4788,5 +4788,18 @@
},
"ToolPageHeader": {
"offline": "100% offline"
+ },
+ "DesktopUpdate": {
+ "title": "Aggiornamento di MyDevTools",
+ "downloading": "Download dell'aggiornamento…",
+ "downloadingPct": "Download dell'aggiornamento… {pct}%",
+ "installing": "Installazione dell'aggiornamento…",
+ "restarting": "Riavvio di MyDevTools…",
+ "reassurance": "I tuoi dati offline non vengono mai toccati.",
+ "failed": "Installazione dell'aggiornamento non riuscita",
+ "retry": "Riprova",
+ "close": "Chiudi",
+ "pill": "Aggiornamento…",
+ "pillPct": "Aggiornamento… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/ja.json b/apps/desktop-ui/messages/ja.json
index 85f79c0f..ab30105b 100644
--- a/apps/desktop-ui/messages/ja.json
+++ b/apps/desktop-ui/messages/ja.json
@@ -4873,5 +4873,18 @@
},
"ToolPageHeader": {
"offline": "完全オフライン"
+ },
+ "DesktopUpdate": {
+ "title": "MyDevTools を更新しています",
+ "downloading": "更新をダウンロードしています…",
+ "downloadingPct": "更新をダウンロードしています… {pct}%",
+ "installing": "更新をインストールしています…",
+ "restarting": "MyDevTools を再起動しています…",
+ "reassurance": "オフラインデータが変更されることはありません。",
+ "failed": "更新のインストールに失敗しました",
+ "retry": "再試行",
+ "close": "閉じる",
+ "pill": "更新中…",
+ "pillPct": "更新中… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/ko.json b/apps/desktop-ui/messages/ko.json
index 5bf43be3..7aaffd99 100644
--- a/apps/desktop-ui/messages/ko.json
+++ b/apps/desktop-ui/messages/ko.json
@@ -4873,5 +4873,18 @@
},
"ToolPageHeader": {
"offline": "100% 오프라인"
+ },
+ "DesktopUpdate": {
+ "title": "MyDevTools 업데이트 중",
+ "downloading": "업데이트 다운로드 중…",
+ "downloadingPct": "업데이트 다운로드 중… {pct}%",
+ "installing": "업데이트 설치 중…",
+ "restarting": "MyDevTools 재시작 중…",
+ "reassurance": "오프라인 데이터는 절대 변경되지 않습니다.",
+ "failed": "업데이트 설치에 실패했습니다",
+ "retry": "다시 시도",
+ "close": "닫기",
+ "pill": "업데이트 중…",
+ "pillPct": "업데이트 중… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/ms.json b/apps/desktop-ui/messages/ms.json
index efdb7c80..a416ab36 100644
--- a/apps/desktop-ui/messages/ms.json
+++ b/apps/desktop-ui/messages/ms.json
@@ -4788,5 +4788,18 @@
},
"ToolPageHeader": {
"offline": "100% luar talian"
+ },
+ "DesktopUpdate": {
+ "title": "Mengemas kini MyDevTools",
+ "downloading": "Memuat turun kemas kini…",
+ "downloadingPct": "Memuat turun kemas kini… {pct}%",
+ "installing": "Memasang kemas kini…",
+ "restarting": "Memulakan semula MyDevTools…",
+ "reassurance": "Data luar talian anda tidak pernah disentuh.",
+ "failed": "Kemas kini gagal dipasang",
+ "retry": "Cuba semula",
+ "close": "Tutup",
+ "pill": "Mengemas kini…",
+ "pillPct": "Mengemas kini… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/nb.json b/apps/desktop-ui/messages/nb.json
index 43291f64..245d980d 100644
--- a/apps/desktop-ui/messages/nb.json
+++ b/apps/desktop-ui/messages/nb.json
@@ -4873,5 +4873,18 @@
},
"ToolPageHeader": {
"offline": "100 % offline"
+ },
+ "DesktopUpdate": {
+ "title": "Oppdaterer MyDevTools",
+ "downloading": "Laster ned oppdatering…",
+ "downloadingPct": "Laster ned oppdatering… {pct} %",
+ "installing": "Installerer oppdatering…",
+ "restarting": "Starter MyDevTools på nytt…",
+ "reassurance": "De offline dataene dine blir aldri rørt.",
+ "failed": "Oppdateringen kunne ikke installeres",
+ "retry": "Prøv igjen",
+ "close": "Lukk",
+ "pill": "Oppdaterer…",
+ "pillPct": "Oppdaterer… {pct} %"
}
}
diff --git a/apps/desktop-ui/messages/nl.json b/apps/desktop-ui/messages/nl.json
index f3e4dc0f..5bb1d08b 100644
--- a/apps/desktop-ui/messages/nl.json
+++ b/apps/desktop-ui/messages/nl.json
@@ -4873,5 +4873,18 @@
},
"ToolPageHeader": {
"offline": "100% offline"
+ },
+ "DesktopUpdate": {
+ "title": "MyDevTools wordt bijgewerkt",
+ "downloading": "Update wordt gedownload…",
+ "downloadingPct": "Update wordt gedownload… {pct}%",
+ "installing": "Update wordt geïnstalleerd…",
+ "restarting": "MyDevTools wordt opnieuw opgestart…",
+ "reassurance": "Je offline gegevens worden nooit aangeraakt.",
+ "failed": "Update kon niet worden geïnstalleerd",
+ "retry": "Opnieuw proberen",
+ "close": "Sluiten",
+ "pill": "Bijwerken…",
+ "pillPct": "Bijwerken… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/pl.json b/apps/desktop-ui/messages/pl.json
index 55901787..3c49c6b4 100644
--- a/apps/desktop-ui/messages/pl.json
+++ b/apps/desktop-ui/messages/pl.json
@@ -4873,5 +4873,18 @@
},
"ToolPageHeader": {
"offline": "100% offline"
+ },
+ "DesktopUpdate": {
+ "title": "Aktualizowanie MyDevTools",
+ "downloading": "Pobieranie aktualizacji…",
+ "downloadingPct": "Pobieranie aktualizacji… {pct}%",
+ "installing": "Instalowanie aktualizacji…",
+ "restarting": "Ponowne uruchamianie MyDevTools…",
+ "reassurance": "Twoje dane offline nigdy nie są naruszane.",
+ "failed": "Nie udało się zainstalować aktualizacji",
+ "retry": "Spróbuj ponownie",
+ "close": "Zamknij",
+ "pill": "Aktualizowanie…",
+ "pillPct": "Aktualizowanie… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/pt-BR.json b/apps/desktop-ui/messages/pt-BR.json
index 46c8b9d8..c208a94d 100644
--- a/apps/desktop-ui/messages/pt-BR.json
+++ b/apps/desktop-ui/messages/pt-BR.json
@@ -4873,5 +4873,18 @@
},
"ToolPageHeader": {
"offline": "100% offline"
+ },
+ "DesktopUpdate": {
+ "title": "Atualizando o MyDevTools",
+ "downloading": "Baixando atualização…",
+ "downloadingPct": "Baixando atualização… {pct}%",
+ "installing": "Instalando atualização…",
+ "restarting": "Reiniciando o MyDevTools…",
+ "reassurance": "Seus dados offline nunca são alterados.",
+ "failed": "Falha ao instalar a atualização",
+ "retry": "Tentar novamente",
+ "close": "Fechar",
+ "pill": "Atualizando…",
+ "pillPct": "Atualizando… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/pt.json b/apps/desktop-ui/messages/pt.json
index d208f63f..243ed3dc 100644
--- a/apps/desktop-ui/messages/pt.json
+++ b/apps/desktop-ui/messages/pt.json
@@ -4873,5 +4873,18 @@
},
"ToolPageHeader": {
"offline": "100% offline"
+ },
+ "DesktopUpdate": {
+ "title": "A atualizar o MyDevTools",
+ "downloading": "A transferir a atualização…",
+ "downloadingPct": "A transferir a atualização… {pct}%",
+ "installing": "A instalar a atualização…",
+ "restarting": "A reiniciar o MyDevTools…",
+ "reassurance": "Os seus dados offline nunca são alterados.",
+ "failed": "Falha ao instalar a atualização",
+ "retry": "Tentar novamente",
+ "close": "Fechar",
+ "pill": "A atualizar…",
+ "pillPct": "A atualizar… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/ru.json b/apps/desktop-ui/messages/ru.json
index a1e95768..81272a0e 100644
--- a/apps/desktop-ui/messages/ru.json
+++ b/apps/desktop-ui/messages/ru.json
@@ -4873,5 +4873,18 @@
},
"ToolPageHeader": {
"offline": "100% офлайн"
+ },
+ "DesktopUpdate": {
+ "title": "Обновление MyDevTools",
+ "downloading": "Загрузка обновления…",
+ "downloadingPct": "Загрузка обновления… {pct}%",
+ "installing": "Установка обновления…",
+ "restarting": "Перезапуск MyDevTools…",
+ "reassurance": "Ваши офлайн-данные никогда не затрагиваются.",
+ "failed": "Не удалось установить обновление",
+ "retry": "Повторить",
+ "close": "Закрыть",
+ "pill": "Обновление…",
+ "pillPct": "Обновление… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/sv.json b/apps/desktop-ui/messages/sv.json
index 65237956..4dda261b 100644
--- a/apps/desktop-ui/messages/sv.json
+++ b/apps/desktop-ui/messages/sv.json
@@ -4788,5 +4788,18 @@
},
"ToolPageHeader": {
"offline": "100 % offline"
+ },
+ "DesktopUpdate": {
+ "title": "Uppdaterar MyDevTools",
+ "downloading": "Laddar ner uppdatering…",
+ "downloadingPct": "Laddar ner uppdatering… {pct} %",
+ "installing": "Installerar uppdatering…",
+ "restarting": "Startar om MyDevTools…",
+ "reassurance": "Din offlinedata rörs aldrig.",
+ "failed": "Det gick inte att installera uppdateringen",
+ "retry": "Försök igen",
+ "close": "Stäng",
+ "pill": "Uppdaterar…",
+ "pillPct": "Uppdaterar… {pct} %"
}
}
diff --git a/apps/desktop-ui/messages/tr.json b/apps/desktop-ui/messages/tr.json
index 7b87022b..d1d6a5fb 100644
--- a/apps/desktop-ui/messages/tr.json
+++ b/apps/desktop-ui/messages/tr.json
@@ -4788,5 +4788,18 @@
},
"ToolPageHeader": {
"offline": "%100 çevrimdışı"
+ },
+ "DesktopUpdate": {
+ "title": "MyDevTools güncelleniyor",
+ "downloading": "Güncelleme indiriliyor…",
+ "downloadingPct": "Güncelleme indiriliyor… %{pct}",
+ "installing": "Güncelleme yükleniyor…",
+ "restarting": "MyDevTools yeniden başlatılıyor…",
+ "reassurance": "Çevrimdışı verileriniz asla değiştirilmez.",
+ "failed": "Güncelleme yüklenemedi",
+ "retry": "Yeniden dene",
+ "close": "Kapat",
+ "pill": "Güncelleniyor…",
+ "pillPct": "Güncelleniyor… %{pct}"
}
}
diff --git a/apps/desktop-ui/messages/uk.json b/apps/desktop-ui/messages/uk.json
index 5a2791f5..3582164d 100644
--- a/apps/desktop-ui/messages/uk.json
+++ b/apps/desktop-ui/messages/uk.json
@@ -4788,5 +4788,18 @@
},
"ToolPageHeader": {
"offline": "100% офлайн"
+ },
+ "DesktopUpdate": {
+ "title": "Оновлення MyDevTools",
+ "downloading": "Завантаження оновлення…",
+ "downloadingPct": "Завантаження оновлення… {pct}%",
+ "installing": "Встановлення оновлення…",
+ "restarting": "Перезапуск MyDevTools…",
+ "reassurance": "Ваші офлайн-дані ніколи не змінюються.",
+ "failed": "Не вдалося встановити оновлення",
+ "retry": "Повторити",
+ "close": "Закрити",
+ "pill": "Оновлення…",
+ "pillPct": "Оновлення… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/vi.json b/apps/desktop-ui/messages/vi.json
index 3793b53e..60580e20 100644
--- a/apps/desktop-ui/messages/vi.json
+++ b/apps/desktop-ui/messages/vi.json
@@ -4873,5 +4873,18 @@
},
"ToolPageHeader": {
"offline": "100% ngoại tuyến"
+ },
+ "DesktopUpdate": {
+ "title": "Đang cập nhật MyDevTools",
+ "downloading": "Đang tải bản cập nhật…",
+ "downloadingPct": "Đang tải bản cập nhật… {pct}%",
+ "installing": "Đang cài đặt bản cập nhật…",
+ "restarting": "Đang khởi động lại MyDevTools…",
+ "reassurance": "Dữ liệu ngoại tuyến của bạn không bao giờ bị ảnh hưởng.",
+ "failed": "Cài đặt bản cập nhật không thành công",
+ "retry": "Thử lại",
+ "close": "Đóng",
+ "pill": "Đang cập nhật…",
+ "pillPct": "Đang cập nhật… {pct}%"
}
}
diff --git a/apps/desktop-ui/messages/zh.json b/apps/desktop-ui/messages/zh.json
index dd70045f..5088ac61 100644
--- a/apps/desktop-ui/messages/zh.json
+++ b/apps/desktop-ui/messages/zh.json
@@ -4873,5 +4873,18 @@
},
"ToolPageHeader": {
"offline": "100% 离线"
+ },
+ "DesktopUpdate": {
+ "title": "正在更新 MyDevTools",
+ "downloading": "正在下载更新…",
+ "downloadingPct": "正在下载更新… {pct}%",
+ "installing": "正在安装更新…",
+ "restarting": "正在重新启动 MyDevTools…",
+ "reassurance": "您的离线数据永远不会被修改。",
+ "failed": "更新安装失败",
+ "retry": "重试",
+ "close": "关闭",
+ "pill": "正在更新…",
+ "pillPct": "正在更新… {pct}%"
}
}
From 645dc7d0aa5af3cf2fbc5380a287c41fc31d2485 Mon Sep 17 00:00:00 2001
From: AKHIL
Date: Thu, 23 Jul 2026 22:45:37 +0530
Subject: [PATCH 11/11] fix(updater): surface translated failure title; drop
bogus install-phase pill %
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../desktop/update-progress-modal.tsx | 22 +++++++++----------
1 file changed, 10 insertions(+), 12 deletions(-)
diff --git a/apps/desktop-ui/src/components/desktop/update-progress-modal.tsx b/apps/desktop-ui/src/components/desktop/update-progress-modal.tsx
index 86078f79..b86d0e3c 100644
--- a/apps/desktop-ui/src/components/desktop/update-progress-modal.tsx
+++ b/apps/desktop-ui/src/components/desktop/update-progress-modal.tsx
@@ -34,15 +34,13 @@ export function UpdateProgressModal() {
const open = locked || visible || error != null;
const label =
- error != null
- ? t("failed")
- : phase === "installing"
- ? t("installing")
- : phase === "restarting"
- ? t("restarting")
- : pct !== null
- ? t("downloadingPct", { pct })
- : t("downloading");
+ phase === "installing"
+ ? t("installing")
+ : phase === "restarting"
+ ? t("restarting")
+ : pct !== null
+ ? t("downloadingPct", { pct })
+ : t("downloading");
// Corner pill when the run is active but the modal is dismissed (no error).
if (!open) {
@@ -53,7 +51,7 @@ export function UpdateProgressModal() {
className="fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-full border border-border bg-card px-3.5 py-2 text-xs font-medium shadow-lg"
>
- {pct !== null ? t("pillPct", { pct }) : t("pill")}
+ {pct !== null && phase === "downloading" ? t("pillPct", { pct }) : t("pill")}
);
}
@@ -68,7 +66,7 @@ export function UpdateProgressModal() {
>
- {t("title")}
+ {error != null ? t("failed") : t("title")}
{error != null ? (
@@ -88,7 +86,7 @@ export function UpdateProgressModal() {
)}