Skip to content

Commit e72a38b

Browse files
Vrtak-CZclaude
andcommitted
feat(updater): show feedback when manually checking for updates
The "Check for Updates..." menu item silently discarded the result, leaving users with no feedback. Now sends the check result to the webview via a new checkForUpdatesResult message, which displays a temporary notification banner (auto-dismisses after 5s) showing "You're up to date", error details, or availability info. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 766fed5 commit e72a38b

7 files changed

Lines changed: 161 additions & 48 deletions

File tree

src/bun/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@ Electrobun.events.on("application-menu-clicked", (e) => {
200200
case "checkForUpdates":
201201
getUpdateManager()
202202
.check()
203+
.then((result) => {
204+
win.webview.rpc?.send.checkForUpdatesResult(result);
205+
})
203206
.catch(() => {});
204207
break;
205208
}

src/frontend/App.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ export function App() {
5454
const [settingsTab, setSettingsTab] = useState<SettingsTab>("general");
5555
const [searchOpen, setSearchOpen] = useState(false);
5656
const [searchSessions, setSearchSessions] = useState<GlobalSessionResult[]>([]);
57-
const { updateStatus, updateDismissed, dismissUpdate } = useUpdateStatus();
57+
const { updateStatus, updateDismissed, dismissUpdate, manualCheckResult, dismissManualCheck } =
58+
useUpdateStatus();
5859

5960
const fetchSearchSessions = useCallback(() => {
6061
getRPC()
@@ -247,6 +248,8 @@ export function App() {
247248
status={updateStatus}
248249
dismissed={updateDismissed}
249250
onDismiss={dismissUpdate}
251+
manualCheckResult={manualCheckResult}
252+
onDismissManualCheck={dismissManualCheck}
250253
/>
251254
<ErrorBoundary>
252255
{view.kind === "home" && (
Lines changed: 69 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,110 @@
11
import { afterEach, describe, expect, mock, test } from "bun:test";
22
import { cleanup, fireEvent, render } from "@testing-library/react";
3+
import type { UpdateStatus } from "../../shared/rpc-types.ts";
34
import { setupMockRPC } from "../test-helpers/mock-rpc.ts";
45
import { UpdateNotification } from "./UpdateNotification.tsx";
56

67
const VERSION_READY_PATTERN = /v2\.0\.0 is ready/;
78

9+
function defaultProps() {
10+
return {
11+
status: { status: "up-to-date", currentVersion: "1.0.0" } as UpdateStatus,
12+
dismissed: false,
13+
onDismiss: mock(),
14+
manualCheckResult: null as UpdateStatus | null,
15+
onDismissManualCheck: mock(),
16+
};
17+
}
18+
819
describe("UpdateNotification", () => {
920
afterEach(cleanup);
1021

1122
test("renders nothing when status is up-to-date", () => {
12-
const { container } = render(
13-
<UpdateNotification
14-
status={{ status: "up-to-date", currentVersion: "1.0.0" }}
15-
dismissed={false}
16-
onDismiss={() => {}}
17-
/>,
18-
);
23+
const { container } = render(<UpdateNotification {...defaultProps()} />);
1924
expect(container.innerHTML).toBe("");
2025
});
2126

2227
test("renders nothing when dismissed", () => {
2328
setupMockRPC();
24-
const { container } = render(
25-
<UpdateNotification
26-
status={{ status: "ready", currentVersion: "1.0.0", latestVersion: "2.0.0" }}
27-
dismissed={true}
28-
onDismiss={() => {}}
29-
/>,
30-
);
29+
const props = defaultProps();
30+
props.status = { status: "ready", currentVersion: "1.0.0", latestVersion: "2.0.0" };
31+
props.dismissed = true;
32+
const { container } = render(<UpdateNotification {...props} />);
3133
expect(container.innerHTML).toBe("");
3234
});
3335

3436
test("renders notification when status is ready", () => {
3537
setupMockRPC();
36-
const { getByText } = render(
37-
<UpdateNotification
38-
status={{ status: "ready", currentVersion: "1.0.0", latestVersion: "2.0.0" }}
39-
dismissed={false}
40-
onDismiss={() => {}}
41-
/>,
42-
);
38+
const props = defaultProps();
39+
props.status = { status: "ready", currentVersion: "1.0.0", latestVersion: "2.0.0" };
40+
const { getByText } = render(<UpdateNotification {...props} />);
4341
expect(getByText(VERSION_READY_PATTERN)).toBeDefined();
4442
});
4543

4644
test("renders Restart button when ready", () => {
4745
setupMockRPC();
48-
const { getByRole } = render(
49-
<UpdateNotification
50-
status={{ status: "ready", currentVersion: "1.0.0", latestVersion: "2.0.0" }}
51-
dismissed={false}
52-
onDismiss={() => {}}
53-
/>,
54-
);
46+
const props = defaultProps();
47+
props.status = { status: "ready", currentVersion: "1.0.0", latestVersion: "2.0.0" };
48+
const { getByRole } = render(<UpdateNotification {...props} />);
5549
expect(getByRole("button", { name: "Restart to update" })).toBeDefined();
5650
});
5751

5852
test("calls onDismiss when dismiss button clicked", () => {
5953
setupMockRPC();
60-
const onDismiss = mock();
61-
const { getByLabelText } = render(
62-
<UpdateNotification
63-
status={{ status: "ready", currentVersion: "1.0.0", latestVersion: "2.0.0" }}
64-
dismissed={false}
65-
onDismiss={onDismiss}
66-
/>,
67-
);
54+
const props = defaultProps();
55+
props.status = { status: "ready", currentVersion: "1.0.0", latestVersion: "2.0.0" };
56+
const { getByLabelText } = render(<UpdateNotification {...props} />);
6857
fireEvent.click(getByLabelText("Dismiss"));
69-
expect(onDismiss).toHaveBeenCalled();
58+
expect(props.onDismiss).toHaveBeenCalled();
7059
});
7160

7261
test("calls applyUpdate RPC when Restart clicked", () => {
7362
const applyUpdate = mock(() => Promise.resolve({ ok: true }));
7463
setupMockRPC({ applyUpdate });
75-
const { getByRole } = render(
76-
<UpdateNotification
77-
status={{ status: "ready", currentVersion: "1.0.0", latestVersion: "2.0.0" }}
78-
dismissed={false}
79-
onDismiss={() => {}}
80-
/>,
81-
);
64+
const props = defaultProps();
65+
props.status = { status: "ready", currentVersion: "1.0.0", latestVersion: "2.0.0" };
66+
const { getByRole } = render(<UpdateNotification {...props} />);
8267
fireEvent.click(getByRole("button", { name: "Restart to update" }));
8368
expect(applyUpdate).toHaveBeenCalled();
8469
});
70+
71+
test("shows up-to-date message for manual check result", () => {
72+
const props = defaultProps();
73+
props.manualCheckResult = { status: "up-to-date", currentVersion: "1.0.0" };
74+
const { getByText } = render(<UpdateNotification {...props} />);
75+
expect(getByText("You're up to date")).toBeDefined();
76+
});
77+
78+
test("shows error message for manual check result", () => {
79+
const props = defaultProps();
80+
props.manualCheckResult = {
81+
status: "error",
82+
currentVersion: "1.0.0",
83+
error: "Network timeout",
84+
};
85+
const { getByText } = render(<UpdateNotification {...props} />);
86+
expect(getByText(/Network timeout/)).toBeDefined();
87+
});
88+
89+
test("calls onDismissManualCheck when dismissing manual check result", () => {
90+
const props = defaultProps();
91+
props.manualCheckResult = { status: "up-to-date", currentVersion: "1.0.0" };
92+
const { getByLabelText } = render(<UpdateNotification {...props} />);
93+
fireEvent.click(getByLabelText("Dismiss"));
94+
expect(props.onDismissManualCheck).toHaveBeenCalled();
95+
});
96+
97+
test("manual check result with ready status falls through to normal notification", () => {
98+
setupMockRPC();
99+
const props = defaultProps();
100+
props.status = { status: "ready", currentVersion: "1.0.0", latestVersion: "2.0.0" };
101+
props.manualCheckResult = {
102+
status: "ready",
103+
currentVersion: "1.0.0",
104+
latestVersion: "2.0.0",
105+
};
106+
const { getByText, getByRole } = render(<UpdateNotification {...props} />);
107+
expect(getByText(VERSION_READY_PATTERN)).toBeDefined();
108+
expect(getByRole("button", { name: "Restart to update" })).toBeDefined();
109+
});
85110
});

src/frontend/components/UpdateNotification.tsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,55 @@ interface UpdateNotificationProps {
66
status: UpdateStatus;
77
dismissed: boolean;
88
onDismiss: () => void;
9+
manualCheckResult: UpdateStatus | null;
10+
onDismissManualCheck: () => void;
911
}
1012

11-
export function UpdateNotification({ status, dismissed, onDismiss }: UpdateNotificationProps) {
13+
function formatManualCheckResult(status: UpdateStatus): string {
14+
if (status.status === "up-to-date") {
15+
return "You're up to date";
16+
}
17+
if (status.status === "error") {
18+
return `Update check failed: ${status.error ?? "Unknown error"}`;
19+
}
20+
if (status.status === "available") {
21+
return `v${status.latestVersion} is available`;
22+
}
23+
if (status.status === "downloading") {
24+
return `Downloading v${status.latestVersion}...`;
25+
}
26+
if (status.status === "ready") {
27+
return `v${status.latestVersion} is ready to install`;
28+
}
29+
return "Check complete";
30+
}
31+
32+
export function UpdateNotification({
33+
status,
34+
dismissed,
35+
onDismiss,
36+
manualCheckResult,
37+
onDismissManualCheck,
38+
}: UpdateNotificationProps) {
39+
// Manual check result takes priority (temporary banner)
40+
if (manualCheckResult && manualCheckResult.status !== "ready") {
41+
return (
42+
<div className="update-notification">
43+
<span className="update-notification-text">
44+
{formatManualCheckResult(manualCheckResult)}
45+
</span>
46+
<button
47+
type="button"
48+
className="update-notification-dismiss"
49+
aria-label="Dismiss"
50+
onClick={onDismissManualCheck}
51+
>
52+
&times;
53+
</button>
54+
</div>
55+
);
56+
}
57+
1258
if (dismissed || status.status !== "ready" || !status.latestVersion) {
1359
return null;
1460
}
Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1-
import { useEffect, useState } from "react";
1+
import { useEffect, useRef, useState } from "react";
22
import type { UpdateStatus } from "../../shared/rpc-types.ts";
33

44
const DEFAULT_STATUS: UpdateStatus = {
55
status: "up-to-date",
66
currentVersion: "",
77
};
88

9+
const MANUAL_CHECK_DISMISS_MS = 5_000;
10+
911
export function useUpdateStatus() {
1012
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>(DEFAULT_STATUS);
1113
const [updateDismissed, setUpdateDismissed] = useState(false);
14+
const [manualCheckResult, setManualCheckResult] = useState<UpdateStatus | null>(null);
15+
const manualCheckTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1216

1317
useEffect(() => {
1418
const handleUpdateStatus = (e: Event) => {
@@ -18,13 +22,41 @@ export function useUpdateStatus() {
1822
setUpdateDismissed(false);
1923
}
2024
};
25+
const handleManualCheck = (e: Event) => {
26+
const detail = (e as CustomEvent).detail as UpdateStatus;
27+
if (detail) {
28+
setManualCheckResult(detail);
29+
if (manualCheckTimerRef.current) {
30+
clearTimeout(manualCheckTimerRef.current);
31+
}
32+
manualCheckTimerRef.current = setTimeout(() => {
33+
setManualCheckResult(null);
34+
manualCheckTimerRef.current = null;
35+
}, MANUAL_CHECK_DISMISS_MS);
36+
}
37+
};
2138
window.addEventListener("klovi:updateStatus", handleUpdateStatus);
22-
return () => window.removeEventListener("klovi:updateStatus", handleUpdateStatus);
39+
window.addEventListener("klovi:checkForUpdatesResult", handleManualCheck);
40+
return () => {
41+
window.removeEventListener("klovi:updateStatus", handleUpdateStatus);
42+
window.removeEventListener("klovi:checkForUpdatesResult", handleManualCheck);
43+
if (manualCheckTimerRef.current) {
44+
clearTimeout(manualCheckTimerRef.current);
45+
}
46+
};
2347
}, []);
2448

2549
return {
2650
updateStatus,
2751
updateDismissed,
2852
dismissUpdate: () => setUpdateDismissed(true),
53+
manualCheckResult,
54+
dismissManualCheck: () => {
55+
setManualCheckResult(null);
56+
if (manualCheckTimerRef.current) {
57+
clearTimeout(manualCheckTimerRef.current);
58+
manualCheckTimerRef.current = null;
59+
}
60+
},
2961
};
3062
}

src/shared/rpc-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export interface KloviRPC {
111111
togglePresentation: Record<string, never>;
112112
openSettings: Record<string, never>;
113113
updateStatus: UpdateStatus;
114+
checkForUpdatesResult: UpdateStatus;
114115
};
115116
}>;
116117
}

src/views/main/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ const rpc = Electroview.defineRPC<KloviRPC>({
3434
updateStatus: (data) => {
3535
window.dispatchEvent(new CustomEvent("klovi:updateStatus", { detail: data }));
3636
},
37+
checkForUpdatesResult: (data) => {
38+
window.dispatchEvent(new CustomEvent("klovi:checkForUpdatesResult", { detail: data }));
39+
},
3740
},
3841
},
3942
});

0 commit comments

Comments
 (0)