@peers-app/peers-ui 0.26.0 → 0.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/app-updates/app-update-controls.d.ts +23 -0
  2. package/dist/app-updates/app-update-controls.js +40 -0
  3. package/dist/app-updates/app-updates-consumer.d.ts +41 -0
  4. package/dist/app-updates/app-updates-consumer.js +96 -0
  5. package/dist/app-updates/app-updates-consumer.test.d.ts +1 -0
  6. package/dist/app-updates/app-updates-consumer.test.js +56 -0
  7. package/dist/app-updates/app-updates-settings.d.ts +5 -0
  8. package/dist/app-updates/app-updates-settings.js +44 -0
  9. package/dist/app-updates/app-updates-settings.test.d.ts +1 -0
  10. package/dist/app-updates/app-updates-settings.test.js +93 -0
  11. package/dist/app-updates/remote-app-update-panel.d.ts +18 -0
  12. package/dist/app-updates/remote-app-update-panel.js +125 -0
  13. package/dist/app-updates/remote-app-update-panel.test.d.ts +1 -0
  14. package/dist/app-updates/remote-app-update-panel.test.js +165 -0
  15. package/dist/app-updates/use-app-update-status.d.ts +8 -0
  16. package/dist/app-updates/use-app-update-status.js +17 -0
  17. package/dist/screens/devices/devices-list.js +2 -1
  18. package/dist/screens/network-viewer/device-details-modal.js +2 -1
  19. package/dist/screens/settings/settings-page.js +5 -0
  20. package/dist/tabs-layout/network-status-indicator.d.ts +12 -0
  21. package/dist/tabs-layout/network-status-indicator.js +33 -5
  22. package/dist/tabs-layout/network-status-indicator.test.js +90 -0
  23. package/package.json +3 -3
  24. package/src/app-updates/app-update-controls.tsx +73 -0
  25. package/src/app-updates/app-updates-consumer.test.ts +83 -0
  26. package/src/app-updates/app-updates-consumer.ts +121 -0
  27. package/src/app-updates/app-updates-settings.test.tsx +109 -0
  28. package/src/app-updates/app-updates-settings.tsx +136 -0
  29. package/src/app-updates/remote-app-update-panel.test.tsx +205 -0
  30. package/src/app-updates/remote-app-update-panel.tsx +210 -0
  31. package/src/app-updates/use-app-update-status.ts +16 -0
  32. package/src/screens/devices/devices-list.tsx +4 -0
  33. package/src/screens/network-viewer/device-details-modal.tsx +6 -0
  34. package/src/screens/settings/settings-page.tsx +5 -0
  35. package/src/tabs-layout/network-status-indicator.test.tsx +119 -1
  36. package/src/tabs-layout/network-status-indicator.tsx +56 -5
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const jsx_runtime_1 = require("react/jsx-runtime");
4
+ const react_1 = require("@testing-library/react");
5
+ const remote_app_update_panel_1 = require("./remote-app-update-panel");
6
+ const getDeviceUpdateStatus = jest.fn();
7
+ const installUpdateAndRestartOnDevice = jest.fn();
8
+ jest.mock("./app-updates-consumer", () => ({
9
+ ...jest.requireActual("./app-updates-consumer"),
10
+ getAppUpdatesConsumer: () => Promise.resolve({
11
+ tools: {
12
+ getDeviceUpdateStatus: (...args) => getDeviceUpdateStatus(...args),
13
+ installUpdateAndRestartOnDevice: (...args) => installUpdateAndRestartOnDevice(...args),
14
+ },
15
+ }),
16
+ }));
17
+ function remoteStatus(overrides = {}) {
18
+ return {
19
+ supported: true,
20
+ canRestart: true,
21
+ platform: "win32",
22
+ currentVersion: "1.0.0",
23
+ latestVersion: "1.1.0",
24
+ downloadedVersion: "1.1.0",
25
+ state: "downloaded",
26
+ installOnRestart: true,
27
+ installOnQuitArmed: true,
28
+ remoteRestartAllowed: true,
29
+ ...overrides,
30
+ };
31
+ }
32
+ describe("RemoteAppUpdatePanel", () => {
33
+ beforeEach(() => {
34
+ getDeviceUpdateStatus.mockReset();
35
+ installUpdateAndRestartOnDevice.mockReset();
36
+ installUpdateAndRestartOnDevice.mockResolvedValue({ action: "installing" });
37
+ jest.spyOn(window, "confirm").mockReturnValue(true);
38
+ });
39
+ afterEach(() => {
40
+ jest.restoreAllMocks();
41
+ });
42
+ it("updates the other device after confirmation", async () => {
43
+ getDeviceUpdateStatus.mockResolvedValue(remoteStatus());
44
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop", deviceName: "Desktop" }));
45
+ const button = await view.findByRole("button", { name: /Update & restart/ });
46
+ expect(view.getByTestId("remote-app-update")).toHaveTextContent("Version 1.0.0 · Version 1.1.0 is ready to install");
47
+ expect(getDeviceUpdateStatus).toHaveBeenCalledWith({ deviceId: "desktop" });
48
+ react_1.fireEvent.click(button);
49
+ expect(window.confirm).toHaveBeenCalledWith("Update Desktop to 1.1.0 and restart it?");
50
+ await (0, react_1.waitFor)(() => expect(installUpdateAndRestartOnDevice).toHaveBeenCalledWith({ deviceId: "desktop" }));
51
+ await view.findByText("Request sent");
52
+ });
53
+ it("offers a plain restart when the device is up to date", async () => {
54
+ getDeviceUpdateStatus.mockResolvedValue(remoteStatus({ state: "up-to-date" }));
55
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop" }));
56
+ react_1.fireEvent.click(await view.findByRole("button", { name: /^Restart$/ }));
57
+ expect(window.confirm).toHaveBeenCalledWith("Restart desktop? It will check for an update first.");
58
+ await (0, react_1.waitFor)(() => expect(installUpdateAndRestartOnDevice).toHaveBeenCalled());
59
+ });
60
+ it("does nothing when the confirmation is declined", async () => {
61
+ window.confirm.mockReturnValue(false);
62
+ getDeviceUpdateStatus.mockResolvedValue(remoteStatus());
63
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop" }));
64
+ react_1.fireEvent.click(await view.findByRole("button", { name: /Update & restart/ }));
65
+ expect(installUpdateAndRestartOnDevice).not.toHaveBeenCalled();
66
+ });
67
+ it("explains why the action is disabled", async () => {
68
+ getDeviceUpdateStatus.mockResolvedValue(remoteStatus({ remoteRestartAllowed: false }));
69
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop" }));
70
+ expect(await view.findByRole("button", { name: /Update & restart/ })).toBeDisabled();
71
+ expect(view.getByTestId("remote-app-update")).toHaveTextContent("does not allow remote restarts");
72
+ getDeviceUpdateStatus.mockResolvedValue(remoteStatus({ installNeedsElevation: true }));
73
+ const elevated = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "linux-box" }));
74
+ await (0, react_1.waitFor)(() => expect(elevated.getAllByRole("button", { name: /Update & restart/ })[1]).toBeDisabled());
75
+ expect(elevated.getAllByTestId("remote-app-update")[1]).toHaveTextContent("administrator password");
76
+ });
77
+ it.each([
78
+ [
79
+ "a device that cannot restart itself",
80
+ () => Promise.resolve(remoteStatus({ canRestart: false })),
81
+ ],
82
+ [
83
+ "a device that does not trust this user",
84
+ () => Promise.reject(new Error("Permission denied: tool 'getUpdateStatus'.run on contract x")),
85
+ ],
86
+ [
87
+ "a build without App Updates",
88
+ () => Promise.reject(new Error("No contract provider registered for x v1")),
89
+ ],
90
+ ])("renders nothing for %s", async (_label, result) => {
91
+ getDeviceUpdateStatus.mockImplementation(result);
92
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop" }));
93
+ await (0, react_1.waitFor)(() => expect(view.queryByTestId("remote-app-update")).toBeNull());
94
+ });
95
+ it("reports an unreachable device", async () => {
96
+ getDeviceUpdateStatus.mockRejectedValue(new Error("Device desktop is not connected"));
97
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop" }));
98
+ await view.findByText("Not reachable: Device desktop is not connected");
99
+ getDeviceUpdateStatus.mockResolvedValue(remoteStatus());
100
+ react_1.fireEvent.click(view.getByRole("button", { name: "Retry" }));
101
+ await view.findByRole("button", { name: /Update & restart/ });
102
+ });
103
+ it("clears the previous device's action while a new target loads", async () => {
104
+ getDeviceUpdateStatus.mockResolvedValueOnce(remoteStatus());
105
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop" }));
106
+ await view.findByRole("button", { name: /Update & restart/ });
107
+ let finish;
108
+ getDeviceUpdateStatus.mockImplementationOnce(() => new Promise((resolve) => {
109
+ finish = resolve;
110
+ }));
111
+ view.rerender((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "another-desktop" }));
112
+ expect(view.queryByRole("button", { name: /Update & restart/ })).toBeNull();
113
+ expect(view.getByText("Checking this device…")).toBeTruthy();
114
+ await (0, react_1.act)(async () => {
115
+ await Promise.resolve();
116
+ finish(remoteStatus({ remoteRestartAllowed: false }));
117
+ });
118
+ expect(view.getByRole("button", { name: /Update & restart/ })).toBeDisabled();
119
+ });
120
+ it("accepts slow poll responses without overlapping requests and cleans up timers", async () => {
121
+ jest.useFakeTimers();
122
+ try {
123
+ getDeviceUpdateStatus.mockResolvedValue(remoteStatus());
124
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop" }));
125
+ await (0, react_1.act)(async () => { });
126
+ let finish;
127
+ getDeviceUpdateStatus.mockImplementationOnce(() => new Promise((resolve) => {
128
+ finish = resolve;
129
+ }));
130
+ react_1.fireEvent.click(view.getByRole("button", { name: /Update & restart/ }));
131
+ await (0, react_1.act)(async () => { });
132
+ await (0, react_1.act)(async () => {
133
+ jest.advanceTimersByTime(remote_app_update_panel_1.REMOTE_UPDATE_POLL_MS * 3);
134
+ });
135
+ expect(getDeviceUpdateStatus).toHaveBeenCalledTimes(2);
136
+ await (0, react_1.act)(async () => {
137
+ finish(remoteStatus({ currentVersion: "1.1.0", state: "up-to-date" }));
138
+ });
139
+ expect(view.getByText("Version 1.1.0 · Up to date")).toBeTruthy();
140
+ await (0, react_1.act)(async () => {
141
+ jest.advanceTimersByTime(remote_app_update_panel_1.REMOTE_UPDATE_POLL_MS);
142
+ });
143
+ expect(getDeviceUpdateStatus).toHaveBeenCalledTimes(3);
144
+ view.unmount();
145
+ expect(jest.getTimerCount()).toBe(0);
146
+ }
147
+ finally {
148
+ jest.useRealTimers();
149
+ }
150
+ });
151
+ it("confirms the newer downloading version instead of the older cached version", async () => {
152
+ getDeviceUpdateStatus.mockResolvedValue(remoteStatus({ state: "downloading", latestVersion: "1.2.0", downloadedVersion: "1.1.0" }));
153
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop", deviceName: "Desktop" }));
154
+ react_1.fireEvent.click(await view.findByRole("button", { name: /Update & restart/ }));
155
+ expect(window.confirm).toHaveBeenCalledWith("Update Desktop to 1.2.0 and restart it?");
156
+ await view.findByText("Request sent");
157
+ });
158
+ it("shows a failed request", async () => {
159
+ getDeviceUpdateStatus.mockResolvedValue(remoteStatus());
160
+ installUpdateAndRestartOnDevice.mockRejectedValue(new Error("Permission denied: this device does not allow other devices to update"));
161
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: "desktop" }));
162
+ react_1.fireEvent.click(await view.findByRole("button", { name: /Update & restart/ }));
163
+ await view.findByText(/does not allow other devices to update/);
164
+ });
165
+ });
@@ -0,0 +1,8 @@
1
+ import type { IAppUpdateStatus } from "@peers-app/peers-sdk";
2
+ /**
3
+ * Subscribe to the local host's application update status.
4
+ *
5
+ * @returns The live status, or `undefined` while the host transport is down. Hosts that
6
+ * cannot update themselves (PWA, headless) report `supported: false`.
7
+ */
8
+ export declare function useAppUpdateStatus(): IAppUpdateStatus | undefined;
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useAppUpdateStatus = useAppUpdateStatus;
4
+ const hooks_1 = require("../hooks");
5
+ const use_host_contract_1 = require("../hooks/use-host-contract");
6
+ const app_updates_consumer_1 = require("./app-updates-consumer");
7
+ /**
8
+ * Subscribe to the local host's application update status.
9
+ *
10
+ * @returns The live status, or `undefined` while the host transport is down. Hosts that
11
+ * cannot update themselves (PWA, headless) report `supported: false`.
12
+ */
13
+ function useAppUpdateStatus() {
14
+ const { consumer } = (0, use_host_contract_1.useHostContract)(app_updates_consumer_1.appUpdatesContract);
15
+ const [status] = (0, hooks_1.useObservable)(consumer?.observables.status);
16
+ return status;
17
+ }
@@ -6,6 +6,7 @@ exports.devicesForUser = devicesForUser;
6
6
  const jsx_runtime_1 = require("react/jsx-runtime");
7
7
  const peers_sdk_1 = require("@peers-app/peers-sdk");
8
8
  const react_1 = require("react");
9
+ const remote_app_update_panel_1 = require("../../app-updates/remote-app-update-panel");
9
10
  const input_1 = require("../../components/input");
10
11
  const loading_indicator_1 = require("../../components/loading-indicator");
11
12
  const globals_1 = require("../../globals");
@@ -133,7 +134,7 @@ function DevicesSection(props) {
133
134
  return ((0, jsx_runtime_1.jsxs)("div", { className: "container-xxl p-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsxs)("h4", { className: "mb-1", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-laptop me-2" }), "My devices"] }), (0, jsx_runtime_1.jsxs)("div", { className: "small text-muted", children: ["Installations signed in to this account. Connection diagnostics stay in", " ", (0, jsx_runtime_1.jsx)("a", { href: "#network-viewer", children: "Network Viewer" }), "."] })] }), (0, jsx_runtime_1.jsx)(activity_view_1.PendingWorkLink, { type: "device" }), (0, jsx_runtime_1.jsxs)("div", { className: "list-group", children: [mine ? ((0, jsx_runtime_1.jsx)(DeviceRow, { device: mine, connection: byId.get(mine.deviceId), isThisDevice: true })) : null, others.map((device) => ((0, jsx_runtime_1.jsx)(DeviceRow, { device: device, connection: byId.get(device.deviceId), isThisDevice: false }, device.deviceId)))] }), !mine && others.length === 0 ? ((0, jsx_runtime_1.jsxs)("div", { className: "border rounded p-4 text-center", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-laptop fs-2 text-muted" }), (0, jsx_runtime_1.jsx)("h6", { className: "mt-2", children: "No devices are registered yet" }), (0, jsx_runtime_1.jsx)("a", { href: `#${identity_routes_1.IDENTITY_PAIR_PATH}`, className: "btn btn-primary btn-sm", children: "Add a device" })] })) : null, mine && others.length === 0 ? ((0, jsx_runtime_1.jsxs)("p", { className: "small text-muted mt-3", children: ["This is your only device. Use ", (0, jsx_runtime_1.jsx)("a", { href: `#${identity_routes_1.IDENTITY_PAIR_PATH}`, children: "Add device" }), " to sign in somewhere else."] })) : null] }));
134
135
  }
135
136
  function DeviceDetail(props) {
136
- return ((0, jsx_runtime_1.jsxs)("div", { className: "container-xxl p-3", children: [(0, jsx_runtime_1.jsx)("a", { href: `#${identity_routes_1.IDENTITY_DEVICES_PATH}`, className: "btn btn-link btn-sm px-0 mb-2", children: "Back to devices" }), (0, jsx_runtime_1.jsx)("h4", { className: "mb-3", children: props.device.name || "Unnamed device" }), props.isThisDevice ? (0, jsx_runtime_1.jsx)(ThisDeviceCard, { device: props.device }) : null, (0, jsx_runtime_1.jsxs)("dl", { className: "row small mb-3", children: [(0, jsx_runtime_1.jsx)("dt", { className: "col-sm-4", children: "Trust" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-sm-8", children: trustLabel(props.device.trustLevel) }), (0, jsx_runtime_1.jsx)("dt", { className: "col-sm-4", children: "Connection" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-sm-8", children: connectionLabel(props.connection) }), (0, jsx_runtime_1.jsx)("dt", { className: "col-sm-4", children: "Last seen" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-sm-8", children: props.device.lastSeen ? new Date(props.device.lastSeen).toLocaleString() : "Unknown" }), (0, jsx_runtime_1.jsx)("dt", { className: "col-sm-4", children: "Device ID" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-sm-8 text-break", children: props.device.deviceId })] }), (0, jsx_runtime_1.jsx)("a", { href: "#network-viewer", className: "btn btn-outline-secondary btn-sm me-2", children: "Open Network Viewer" }), props.isThisDevice ? null : ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-danger btn-sm", onClick: () => void forgetDevice(props.device), children: "Forget" }))] }));
137
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "container-xxl p-3", children: [(0, jsx_runtime_1.jsx)("a", { href: `#${identity_routes_1.IDENTITY_DEVICES_PATH}`, className: "btn btn-link btn-sm px-0 mb-2", children: "Back to devices" }), (0, jsx_runtime_1.jsx)("h4", { className: "mb-3", children: props.device.name || "Unnamed device" }), props.isThisDevice ? (0, jsx_runtime_1.jsx)(ThisDeviceCard, { device: props.device }) : null, (0, jsx_runtime_1.jsxs)("dl", { className: "row small mb-3", children: [(0, jsx_runtime_1.jsx)("dt", { className: "col-sm-4", children: "Trust" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-sm-8", children: trustLabel(props.device.trustLevel) }), (0, jsx_runtime_1.jsx)("dt", { className: "col-sm-4", children: "Connection" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-sm-8", children: connectionLabel(props.connection) }), (0, jsx_runtime_1.jsx)("dt", { className: "col-sm-4", children: "Last seen" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-sm-8", children: props.device.lastSeen ? new Date(props.device.lastSeen).toLocaleString() : "Unknown" }), (0, jsx_runtime_1.jsx)("dt", { className: "col-sm-4", children: "Device ID" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-sm-8 text-break", children: props.device.deviceId })] }), (0, jsx_runtime_1.jsx)("a", { href: "#network-viewer", className: "btn btn-outline-secondary btn-sm me-2", children: "Open Network Viewer" }), props.isThisDevice ? null : ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-danger btn-sm", onClick: () => void forgetDevice(props.device), children: "Forget" })), props.isThisDevice ? null : ((0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: props.device.deviceId, deviceName: props.device.name }))] }));
137
138
  }
138
139
  async function forgetDevice(device) {
139
140
  const confirmed = confirm(`Forget ${device.name || device.deviceId}?`);
@@ -4,6 +4,7 @@ exports.DeviceDetailsModal = DeviceDetailsModal;
4
4
  const jsx_runtime_1 = require("react/jsx-runtime");
5
5
  const peers_sdk_1 = require("@peers-app/peers-sdk");
6
6
  const react_1 = require("react");
7
+ const remote_app_update_panel_1 = require("../../app-updates/remote-app-update-panel");
7
8
  const loading_indicator_1 = require("../../components/loading-indicator");
8
9
  const trust_level_badge_1 = require("../../components/trust-level-badge");
9
10
  const network_viewer_ipc_1 = require("./network-viewer-ipc");
@@ -84,7 +85,7 @@ function DeviceDetailsModal({ deviceId, onClose, onDisconnect }) {
84
85
  overflowY: "auto",
85
86
  paddingTop: "2rem",
86
87
  paddingBottom: "2rem",
87
- }, onClick: onClose, children: (0, jsx_runtime_1.jsxs)("div", { className: "card", style: { maxWidth: "900px", width: "90%", maxHeight: "90vh" }, onClick: (e) => e.stopPropagation(), children: [(0, jsx_runtime_1.jsxs)("div", { className: "card-header d-flex justify-content-between align-items-center", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h5", { className: "mb-0", children: loading ? "Loading..." : device?.userName || device?.userId || "Device Details" }), device?.userName && ((0, jsx_runtime_1.jsx)("small", { className: "text-muted", children: (0, jsx_runtime_1.jsx)("code", { children: device.userId }) }))] }), (0, jsx_runtime_1.jsxs)("div", { children: [device && ((0, jsx_runtime_1.jsxs)("button", { className: "btn btn-sm btn-danger me-2", onClick: handleDisconnect, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-x-circle" }), " Disconnect"] })), (0, jsx_runtime_1.jsx)("button", { className: "btn-close", onClick: onClose, "aria-label": "Close" })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "card-body", style: { overflowY: "auto", maxHeight: "calc(90vh - 120px)" }, children: [loading && (0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {}), !loading && (notFound || !device) && ((0, jsx_runtime_1.jsx)("div", { className: "alert alert-warning", children: "Device not found or connection has been closed." })), !loading && device && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("div", { className: "mb-4", children: [(0, jsx_runtime_1.jsx)("h6", { className: "border-bottom pb-2 mb-3", children: "Connection Information" }), (0, jsx_runtime_1.jsxs)("div", { className: "row", children: [device.deviceName && ((0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Device Name:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { children: device.deviceName })] })), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Device ID:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("code", { className: "text-muted small", children: device.deviceId })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Connection State:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { className: "badge bg-success", children: device.connectionState })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "User Trust:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)(trust_level_badge_1.TrustLevelBadge, { level: device.userTrustLevel || peers_sdk_1.TrustLevel.Unknown })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Device Trust:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)(trust_level_badge_1.TrustLevelBadge, { level: device.deviceTrustLevel || peers_sdk_1.TrustLevel.Unknown })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Path Type:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { className: `badge bg-${device.pathType === "direct" ? "success" : "info"}`, children: device.pathType })] }), device.throughDeviceIds && device.throughDeviceIds.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "col-md-12 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Path:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("small", { className: "text-muted", children: device.throughDeviceIds.join(" → ") })] }))] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-4", children: [(0, jsx_runtime_1.jsx)("h6", { className: "border-bottom pb-2 mb-3", children: "Throughput" }), (0, jsx_runtime_1.jsxs)("div", { className: "row", children: [(0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Send Rate:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsxs)("span", { className: "text-success", children: ["\u2191 ", formatRate(device.sendRateMBps)] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Receive Rate:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsxs)("span", { className: "text-primary", children: ["\u2193 ", formatRate(device.receiveRateMBps)] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Total Sent:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { children: formatBytes(device.bytesSent || 0) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Total Received:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { children: formatBytes(device.bytesReceived || 0) })] })] })] }), device.canReadRemoteLogs && (0, jsx_runtime_1.jsx)(remote_logs_panel_1.RemoteLogsPanel, { deviceId: device.deviceId }), device.sharedGroups.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "mb-4", children: [(0, jsx_runtime_1.jsxs)("h6", { className: "border-bottom pb-2 mb-3", children: ["Shared Groups (", device.sharedGroups.length, ")"] }), (0, jsx_runtime_1.jsx)("div", { className: "table-responsive", children: (0, jsx_runtime_1.jsxs)("table", { className: "table table-sm table-hover", children: [(0, jsx_runtime_1.jsx)("thead", { children: (0, jsx_runtime_1.jsxs)("tr", { children: [(0, jsx_runtime_1.jsx)("th", { children: "Group ID" }), (0, jsx_runtime_1.jsx)("th", { children: "Role" }), (0, jsx_runtime_1.jsx)("th", { children: "Sync Status" })] }) }), (0, jsx_runtime_1.jsx)("tbody", { children: device.sharedGroups.map((groupId) => {
88
+ }, onClick: onClose, children: (0, jsx_runtime_1.jsxs)("div", { className: "card", style: { maxWidth: "900px", width: "90%", maxHeight: "90vh" }, onClick: (e) => e.stopPropagation(), children: [(0, jsx_runtime_1.jsxs)("div", { className: "card-header d-flex justify-content-between align-items-center", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h5", { className: "mb-0", children: loading ? "Loading..." : device?.userName || device?.userId || "Device Details" }), device?.userName && ((0, jsx_runtime_1.jsx)("small", { className: "text-muted", children: (0, jsx_runtime_1.jsx)("code", { children: device.userId }) }))] }), (0, jsx_runtime_1.jsxs)("div", { children: [device && ((0, jsx_runtime_1.jsxs)("button", { className: "btn btn-sm btn-danger me-2", onClick: handleDisconnect, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-x-circle" }), " Disconnect"] })), (0, jsx_runtime_1.jsx)("button", { className: "btn-close", onClick: onClose, "aria-label": "Close" })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "card-body", style: { overflowY: "auto", maxHeight: "calc(90vh - 120px)" }, children: [loading && (0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {}), !loading && (notFound || !device) && ((0, jsx_runtime_1.jsx)("div", { className: "alert alert-warning", children: "Device not found or connection has been closed." })), !loading && device && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("div", { className: "mb-4", children: [(0, jsx_runtime_1.jsx)("h6", { className: "border-bottom pb-2 mb-3", children: "Connection Information" }), (0, jsx_runtime_1.jsxs)("div", { className: "row", children: [device.deviceName && ((0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Device Name:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { children: device.deviceName })] })), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Device ID:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("code", { className: "text-muted small", children: device.deviceId })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Connection State:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { className: "badge bg-success", children: device.connectionState })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "User Trust:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)(trust_level_badge_1.TrustLevelBadge, { level: device.userTrustLevel || peers_sdk_1.TrustLevel.Unknown })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Device Trust:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)(trust_level_badge_1.TrustLevelBadge, { level: device.deviceTrustLevel || peers_sdk_1.TrustLevel.Unknown })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Path Type:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { className: `badge bg-${device.pathType === "direct" ? "success" : "info"}`, children: device.pathType })] }), device.throughDeviceIds && device.throughDeviceIds.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "col-md-12 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Path:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("small", { className: "text-muted", children: device.throughDeviceIds.join(" → ") })] }))] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-4", children: [(0, jsx_runtime_1.jsx)("h6", { className: "border-bottom pb-2 mb-3", children: "Throughput" }), (0, jsx_runtime_1.jsxs)("div", { className: "row", children: [(0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Send Rate:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsxs)("span", { className: "text-success", children: ["\u2191 ", formatRate(device.sendRateMBps)] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Receive Rate:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsxs)("span", { className: "text-primary", children: ["\u2193 ", formatRate(device.receiveRateMBps)] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Total Sent:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { children: formatBytes(device.bytesSent || 0) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsx)("strong", { children: "Total Received:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsx)("span", { children: formatBytes(device.bytesReceived || 0) })] })] })] }), device.canReadRemoteLogs && (0, jsx_runtime_1.jsx)(remote_logs_panel_1.RemoteLogsPanel, { deviceId: device.deviceId }), device.canReadRemoteLogs && ((0, jsx_runtime_1.jsx)("div", { className: "mb-4", children: (0, jsx_runtime_1.jsx)(remote_app_update_panel_1.RemoteAppUpdatePanel, { deviceId: device.deviceId, deviceName: device.deviceName }) })), device.sharedGroups.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "mb-4", children: [(0, jsx_runtime_1.jsxs)("h6", { className: "border-bottom pb-2 mb-3", children: ["Shared Groups (", device.sharedGroups.length, ")"] }), (0, jsx_runtime_1.jsx)("div", { className: "table-responsive", children: (0, jsx_runtime_1.jsxs)("table", { className: "table table-sm table-hover", children: [(0, jsx_runtime_1.jsx)("thead", { children: (0, jsx_runtime_1.jsxs)("tr", { children: [(0, jsx_runtime_1.jsx)("th", { children: "Group ID" }), (0, jsx_runtime_1.jsx)("th", { children: "Role" }), (0, jsx_runtime_1.jsx)("th", { children: "Sync Status" })] }) }), (0, jsx_runtime_1.jsx)("tbody", { children: device.sharedGroups.map((groupId) => {
88
89
  const syncStats = device.syncStatsByGroup[groupId];
89
90
  const role = device.roles[groupId];
90
91
  return ((0, jsx_runtime_1.jsxs)("tr", { children: [(0, jsx_runtime_1.jsx)("td", { children: (0, jsx_runtime_1.jsx)("code", { className: "text-muted small", children: groupId }) }), (0, jsx_runtime_1.jsx)("td", { children: role != null ? String(role) : "N/A" }), (0, jsx_runtime_1.jsx)("td", { children: syncStats ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [syncStats.isPreferred && ((0, jsx_runtime_1.jsx)("span", { className: "badge bg-primary me-2", children: "Preferred" })), (0, jsx_runtime_1.jsx)("small", { className: "text-muted", children: new Date(syncStats.lastSyncTime).toLocaleString() })] })) : ((0, jsx_runtime_1.jsx)("span", { className: "text-muted", children: "-" })) })] }, groupId));
@@ -37,6 +37,7 @@ exports.SettingsPage = void 0;
37
37
  const jsx_runtime_1 = require("react/jsx-runtime");
38
38
  const peers_sdk_1 = require("@peers-app/peers-sdk");
39
39
  const react_1 = require("react");
40
+ const app_updates_settings_1 = require("../../app-updates/app-updates-settings");
40
41
  const input_1 = require("../../components/input");
41
42
  const tabs_1 = require("../../components/tabs");
42
43
  const tooltip_1 = require("../../components/tooltip");
@@ -65,6 +66,10 @@ const SettingsPage = () => {
65
66
  name: "Appearance",
66
67
  content: (0, jsx_runtime_1.jsx)(AppearanceSettingsTab, {}),
67
68
  },
69
+ {
70
+ name: "Updates",
71
+ content: (0, jsx_runtime_1.jsx)(app_updates_settings_1.AppUpdatesSettings, {}),
72
+ },
68
73
  {
69
74
  name: "Advanced",
70
75
  content: (0, jsx_runtime_1.jsx)(AdvancedSettingsTab, {}),
@@ -1,3 +1,4 @@
1
+ import type { IAppUpdateStatus } from "@peers-app/peers-sdk";
1
2
  import { type INetworkStatusViewState } from "./use-network-status";
2
3
  /** How long continuous raw syncing stays a green pulse before the indeterminate circle. */
3
4
  export declare const SYNCING_GREEN_MS = 4000;
@@ -30,6 +31,8 @@ interface INetworkStatusPresentation {
30
31
  count: number;
31
32
  colorClass: "text-success" | "text-danger";
32
33
  };
34
+ /** Bottom-left yellow upgrade arrow when this device has an app update available. */
35
+ updateAvailableOverlay?: boolean;
33
36
  }
34
37
  /** Convert host availability and network state into accessible indicator text and styling. */
35
38
  export declare function getNetworkStatusPresentation(viewState: INetworkStatusViewState, opts?: {
@@ -37,6 +40,15 @@ export declare function getNetworkStatusPresentation(viewState: INetworkStatusVi
37
40
  /** Keep the calm green syncing pulse while waiting for stable synced. */
38
41
  holdSyncingVisual?: boolean;
39
42
  }): INetworkStatusPresentation;
43
+ /**
44
+ * Add the app-update badge and label to a network status presentation. Applies in every
45
+ * network state, offline included, because installing does not depend on connectivity.
46
+ *
47
+ * @param presentation - Presentation from {@link getNetworkStatusPresentation}.
48
+ * @param appUpdate - This device's update status, if known.
49
+ * @returns The presentation, with `updateAvailableOverlay` when an update is available.
50
+ */
51
+ export declare function withAppUpdate(presentation: INetworkStatusPresentation, appUpdate: IAppUpdateStatus | undefined): INetworkStatusPresentation;
40
52
  /** Compact relative time for the status popover (always short enough for one line). */
41
53
  export declare function formatRelativeSyncTime(timestampMs: number, nowMs?: number): string;
42
54
  /** Prefer a friendly name; otherwise a compact middle-ellipsis of the id. */
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.SYNCED_STABLE_MS = exports.SYNCING_GREEN_MS = void 0;
7
7
  exports.getNetworkStatusPresentation = getNetworkStatusPresentation;
8
+ exports.withAppUpdate = withAppUpdate;
8
9
  exports.formatRelativeSyncTime = formatRelativeSyncTime;
9
10
  exports.connectionIdentityLabel = connectionIdentityLabel;
10
11
  exports.NetworkStatusIndicator = NetworkStatusIndicator;
@@ -12,6 +13,9 @@ exports.TabsStripIndicators = TabsStripIndicators;
12
13
  const jsx_runtime_1 = require("react/jsx-runtime");
13
14
  const moment_1 = __importDefault(require("moment"));
14
15
  const react_1 = require("react");
16
+ const app_update_controls_1 = require("../app-updates/app-update-controls");
17
+ const app_updates_consumer_1 = require("../app-updates/app-updates-consumer");
18
+ const use_app_update_status_1 = require("../app-updates/use-app-update-status");
15
19
  const identity_routes_1 = require("../screens/identity/identity-routes");
16
20
  const network_viewer_ipc_1 = require("../screens/network-viewer/network-viewer-ipc");
17
21
  const network_viewer_section_1 = require("../screens/network-viewer/network-viewer-section");
@@ -125,6 +129,24 @@ function getNetworkStatusPresentation(viewState, opts) {
125
129
  connectedGroupsOverlay: groupsOverlay,
126
130
  }, stuckRecoveryCount);
127
131
  }
132
+ /**
133
+ * Add the app-update badge and label to a network status presentation. Applies in every
134
+ * network state, offline included, because installing does not depend on connectivity.
135
+ *
136
+ * @param presentation - Presentation from {@link getNetworkStatusPresentation}.
137
+ * @param appUpdate - This device's update status, if known.
138
+ * @returns The presentation, with `updateAvailableOverlay` when an update is available.
139
+ */
140
+ function withAppUpdate(presentation, appUpdate) {
141
+ if (!appUpdate?.supported || !(0, app_updates_consumer_1.hasAppUpdate)(appUpdate)) {
142
+ return presentation;
143
+ }
144
+ return {
145
+ ...presentation,
146
+ label: `${presentation.label}; ${(0, app_updates_consumer_1.describeAppUpdateStatus)(appUpdate)}`,
147
+ updateAvailableOverlay: true,
148
+ };
149
+ }
128
150
  function statusMarkBgClass(colorClass) {
129
151
  if (colorClass === "text-danger") {
130
152
  return "bg-danger";
@@ -211,14 +233,15 @@ function StatusCountRow({ label, value, className, }) {
211
233
  const tone = className ? ` ${className}` : "";
212
234
  return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("dt", { className: `col-7${tone}`, children: label }), (0, jsx_runtime_1.jsx)("dd", { className: `col-5 text-end${tone}`, children: value })] }));
213
235
  }
214
- function NetworkStatusDetails({ detailsId, status, unavailable, connectionsLoadState, onUseInvite, onOpenIdentity, onOpenViewer, onOpenRecovery, }) {
215
- return ((0, jsx_runtime_1.jsx)("div", { id: detailsId, role: "dialog", "aria-label": "Network status details", className: "card shadow position-absolute end-0 mt-1", style: { top: "100%", width: "320px", zIndex: 1100 }, children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body p-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 mb-3", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-sm btn-outline-primary flex-fill", onClick: onUseInvite, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-qr-code me-1", "aria-hidden": "true" }), "Use Invite"] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-sm btn-outline-primary flex-fill", onClick: onOpenIdentity, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-person-badge me-1", "aria-hidden": "true" }), "Identity"] })] }), (0, jsx_runtime_1.jsx)("h6", { className: "card-title mb-2", children: "Network status" }), unavailable || !status ? ((0, jsx_runtime_1.jsx)("p", { className: "small text-muted mb-3", children: "Network diagnostics are temporarily unavailable. The local host may still be starting or reconnecting." })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("dl", { className: "row small mb-2", children: [(0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Connections", value: status.connectionCount }), (0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Groups connected", value: status.connectedContextCount }), status.syncingContextCount > 0 && ((0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Syncing contexts", value: status.syncingContextCount })), status.disconnectedContextCount > 0 && ((0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Groups offline", value: status.disconnectedContextCount, className: "text-warning" })), status.errorCount > 0 && ((0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Sync errors", value: status.errorCount })), status.lastSuccessfulSyncAt != null && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Last sync" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end text-truncate", title: new Date(status.lastSuccessfulSyncAt).toLocaleString(), children: formatRelativeSyncTime(status.lastSuccessfulSyncAt) })] }))] }), status.errors.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "alert alert-danger py-2 px-2 small mb-2", children: [status.errors[0].message, status.errors.length > 1 && ` (+${status.errors.length - 1} more)`] })), status.stuckRecoveryCount > 0 && ((0, jsx_runtime_1.jsx)("div", { className: "alert alert-warning py-2 px-2 small mb-2", children: status.stuckRecoveryCount === 1
236
+ function NetworkStatusDetails({ detailsId, status, appUpdate, unavailable, connectionsLoadState, onUseInvite, onOpenIdentity, onOpenViewer, onOpenRecovery, }) {
237
+ return ((0, jsx_runtime_1.jsx)("div", { id: detailsId, role: "dialog", "aria-label": "Network status details", className: "card shadow position-absolute end-0 mt-1", style: { top: "100%", width: "320px", zIndex: 1100 }, children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body p-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 mb-3", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-sm btn-outline-primary flex-fill", onClick: onUseInvite, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-qr-code me-1", "aria-hidden": "true" }), "Use Invite"] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-sm btn-outline-primary flex-fill", onClick: onOpenIdentity, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-person-badge me-1", "aria-hidden": "true" }), "Identity"] })] }), (0, jsx_runtime_1.jsx)(app_update_controls_1.AppUpdatePopoverSection, { status: appUpdate }), (0, jsx_runtime_1.jsx)("h6", { className: "card-title mb-2", children: "Network status" }), unavailable || !status ? ((0, jsx_runtime_1.jsx)("p", { className: "small text-muted mb-3", children: "Network diagnostics are temporarily unavailable. The local host may still be starting or reconnecting." })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("dl", { className: "row small mb-2", children: [(0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Connections", value: status.connectionCount }), (0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Groups connected", value: status.connectedContextCount }), status.syncingContextCount > 0 && ((0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Syncing contexts", value: status.syncingContextCount })), status.disconnectedContextCount > 0 && ((0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Groups offline", value: status.disconnectedContextCount, className: "text-warning" })), status.errorCount > 0 && ((0, jsx_runtime_1.jsx)(StatusCountRow, { label: "Sync errors", value: status.errorCount })), status.lastSuccessfulSyncAt != null && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Last sync" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end text-truncate", title: new Date(status.lastSuccessfulSyncAt).toLocaleString(), children: formatRelativeSyncTime(status.lastSuccessfulSyncAt) })] }))] }), status.errors.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "alert alert-danger py-2 px-2 small mb-2", children: [status.errors[0].message, status.errors.length > 1 && ` (+${status.errors.length - 1} more)`] })), status.stuckRecoveryCount > 0 && ((0, jsx_runtime_1.jsx)("div", { className: "alert alert-warning py-2 px-2 small mb-2", children: status.stuckRecoveryCount === 1
216
238
  ? "1 record needs attention"
217
239
  : `${status.stuckRecoveryCount} records need attention` })), (0, jsx_runtime_1.jsxs)("div", { className: "border-top pt-2 mt-1 mb-2", children: [(0, jsx_runtime_1.jsx)("div", { className: "small text-muted mb-1", children: "Connected Devices" }), (0, jsx_runtime_1.jsx)(ConnectedDevicesList, { loadState: connectionsLoadState })] })] })), (0, jsx_runtime_1.jsx)("button", { type: "button", className: `btn btn-sm btn-outline-primary w-100${status && status.stuckRecoveryCount > 0 ? " mb-2" : ""}`, onClick: onOpenViewer, children: "Open Network Viewer" }), status && status.stuckRecoveryCount > 0 && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-sm btn-warning w-100", onClick: onOpenRecovery, children: "Sync recovery" }))] }) }));
218
240
  }
219
241
  /** Accessible reactive network status button and details popover. */
220
242
  function NetworkStatusIndicator({ compact = false }) {
221
243
  const viewState = (0, use_network_status_1.useNetworkStatus)();
244
+ const appUpdate = (0, use_app_update_status_1.useAppUpdateStatus)();
222
245
  const [displaySyncing, setDisplaySyncing] = (0, react_1.useState)(false);
223
246
  const [syncingLongerThanGreenMs, setSyncingLongerThanGreenMs] = (0, react_1.useState)(false);
224
247
  const [open, setOpen] = (0, react_1.useState)(false);
@@ -286,10 +309,10 @@ function NetworkStatusIndicator({ compact = false }) {
286
309
  };
287
310
  }, [open, diagnosticsReady, connectionCount]);
288
311
  const holdSyncingVisual = displaySyncing && !rawIsSyncing;
289
- const presentation = getNetworkStatusPresentation(viewState, {
312
+ const presentation = withAppUpdate(getNetworkStatusPresentation(viewState, {
290
313
  syncingLongerThanGreenMs,
291
314
  holdSyncingVisual,
292
- });
315
+ }), appUpdate);
293
316
  (0, react_1.useEffect)(() => {
294
317
  if (!open) {
295
318
  return;
@@ -329,7 +352,12 @@ function NetworkStatusIndicator({ compact = false }) {
329
352
  fontWeight: 700,
330
353
  right: "-0.28rem",
331
354
  bottom: "-0.32rem",
332
- }, "aria-hidden": "true", children: presentation.disconnectedGroupsOverlay.count }))] }), !compact && ((0, jsx_runtime_1.jsxs)("span", { className: "small", children: [viewState.status?.connectionCount ?? 0, (0, jsx_runtime_1.jsx)("span", { className: "visually-hidden", children: " connections" })] }))] }), open && ((0, jsx_runtime_1.jsx)(NetworkStatusDetails, { detailsId: detailsId, status: viewState.status, unavailable: viewState.availability !== "ready", connectionsLoadState: connectionsLoadState, onUseInvite: () => {
355
+ }, "aria-hidden": "true", children: presentation.disconnectedGroupsOverlay.count })), presentation.updateAvailableOverlay && ((0, jsx_runtime_1.jsx)("i", { className: `network-status-update-badge bi ${app_update_controls_1.APP_UPDATE_ICON_CLASS} text-warning position-absolute`, style: {
356
+ fontSize: "0.5rem",
357
+ lineHeight: 1,
358
+ left: "-0.3rem",
359
+ bottom: "-0.32rem",
360
+ }, "aria-hidden": "true" }))] }), !compact && ((0, jsx_runtime_1.jsxs)("span", { className: "small", children: [viewState.status?.connectionCount ?? 0, (0, jsx_runtime_1.jsx)("span", { className: "visually-hidden", children: " connections" })] }))] }), open && ((0, jsx_runtime_1.jsx)(NetworkStatusDetails, { detailsId: detailsId, status: viewState.status, appUpdate: appUpdate, unavailable: viewState.availability !== "ready", connectionsLoadState: connectionsLoadState, onUseInvite: () => {
333
361
  setOpen(false);
334
362
  (0, tabs_state_1.goToTabPath)(identity_routes_1.IDENTITY_ACCEPT_PATH);
335
363
  }, onOpenIdentity: () => {
@@ -13,12 +13,40 @@ const network_status_indicator_1 = require("./network-status-indicator");
13
13
  const goToTabPath = jest.fn();
14
14
  const getConnections = jest.fn();
15
15
  let mockViewState = { availability: "loading" };
16
+ let mockAppUpdate;
17
+ const installUpdateAndRestart = jest.fn();
16
18
  jest.mock("./tabs-state", () => ({
17
19
  goToTabPath: (...args) => goToTabPath(...args),
18
20
  }));
19
21
  jest.mock("./use-network-status", () => ({
20
22
  useNetworkStatus: () => mockViewState,
21
23
  }));
24
+ jest.mock("../app-updates/use-app-update-status", () => ({
25
+ useAppUpdateStatus: () => mockAppUpdate,
26
+ }));
27
+ jest.mock("../app-updates/app-updates-consumer", () => ({
28
+ ...jest.requireActual("../app-updates/app-updates-consumer"),
29
+ getAppUpdatesConsumer: () => Promise.resolve({
30
+ tools: {
31
+ installUpdateAndRestart: (...args) => installUpdateAndRestart(...args),
32
+ },
33
+ }),
34
+ }));
35
+ function appUpdate(overrides = {}) {
36
+ return {
37
+ supported: true,
38
+ canRestart: true,
39
+ platform: "darwin",
40
+ currentVersion: "1.0.0",
41
+ latestVersion: "1.1.0",
42
+ downloadedVersion: "1.1.0",
43
+ state: "downloaded",
44
+ installOnRestart: true,
45
+ installOnQuitArmed: true,
46
+ remoteRestartAllowed: true,
47
+ ...overrides,
48
+ };
49
+ }
22
50
  jest.mock("../screens/network-viewer/network-viewer-ipc", () => ({
23
51
  getNetworkDiagnosticsConsumer: () => Promise.resolve({
24
52
  tools: {
@@ -225,9 +253,49 @@ describe("getNetworkStatusPresentation", () => {
225
253
  }).colorClass).toBe("text-danger");
226
254
  });
227
255
  });
256
+ describe("withAppUpdate", () => {
257
+ const synced = (0, network_status_indicator_1.getNetworkStatusPresentation)({
258
+ availability: "ready",
259
+ status: status({ state: "synced" }),
260
+ });
261
+ it("leaves the presentation alone without an available update", () => {
262
+ expect((0, network_status_indicator_1.withAppUpdate)(synced, undefined)).toBe(synced);
263
+ expect((0, network_status_indicator_1.withAppUpdate)(synced, appUpdate({ state: "up-to-date" }))).toBe(synced);
264
+ expect((0, network_status_indicator_1.withAppUpdate)(synced, appUpdate({ supported: false }))).toBe(synced);
265
+ expect((0, network_status_indicator_1.withAppUpdate)(synced, appUpdate({
266
+ state: "error",
267
+ latestVersion: "1.0.0",
268
+ downloadedVersion: undefined,
269
+ error: "feed unreachable",
270
+ }))).toBe(synced);
271
+ });
272
+ it("keeps the update badge after a failed download while a newer version is known", () => {
273
+ const presentation = (0, network_status_indicator_1.withAppUpdate)(synced, appUpdate({ state: "error", downloadedVersion: undefined, error: "download failed" }));
274
+ expect(presentation.updateAvailableOverlay).toBe(true);
275
+ expect(presentation.label).toBe(`${synced.label}; Version 1.1.0 is available. Update problem: download failed`);
276
+ });
277
+ it("adds the update badge and label while an update is available, downloading, or ready", () => {
278
+ for (const state of ["available", "downloading", "downloaded"]) {
279
+ const presentation = (0, network_status_indicator_1.withAppUpdate)(synced, appUpdate({ state }));
280
+ expect(presentation.updateAvailableOverlay).toBe(true);
281
+ expect(presentation.label).toMatch(/^Network synced; .*1\.1\.0/);
282
+ expect(presentation.colorClass).toBe(synced.colorClass);
283
+ }
284
+ });
285
+ it("applies offline too", () => {
286
+ const offline = (0, network_status_indicator_1.getNetworkStatusPresentation)({
287
+ availability: "ready",
288
+ status: status({ state: "offline", connectionCount: 0 }),
289
+ });
290
+ expect((0, network_status_indicator_1.withAppUpdate)(offline, appUpdate()).updateAvailableOverlay).toBe(true);
291
+ });
292
+ });
228
293
  describe("NetworkStatusIndicator", () => {
229
294
  beforeEach(() => {
230
295
  mockViewState = { availability: "loading" };
296
+ mockAppUpdate = undefined;
297
+ installUpdateAndRestart.mockReset();
298
+ installUpdateAndRestart.mockResolvedValue({ action: "installing" });
231
299
  (0, network_viewer_section_1.networkViewerSection)("main");
232
300
  getConnections.mockReset();
233
301
  getConnections.mockResolvedValue({ connections: [] });
@@ -286,6 +354,28 @@ describe("NetworkStatusIndicator", () => {
286
354
  view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
287
355
  expect(view.container.querySelector(".bg-danger.rounded-circle")).toBeTruthy();
288
356
  });
357
+ it("shows a bottom-left update badge and an Update & restart action when an update is ready", async () => {
358
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
359
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
360
+ expect(view.container.querySelector(".network-status-update-badge")).toBeNull();
361
+ mockAppUpdate = appUpdate();
362
+ view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
363
+ const badge = view.container.querySelector(".network-status-update-badge");
364
+ expect(badge).toHaveClass("bi-arrow-up-circle-fill", "text-warning");
365
+ expect(badge).toHaveStyle({ left: "-0.3rem", bottom: "-0.32rem" });
366
+ react_1.fireEvent.click(view.getByRole("button", { name: /Version 1\.1\.0 is ready to install/ }));
367
+ const section = view.getByTestId("app-update-section");
368
+ expect(section).toHaveTextContent("Installs automatically the next time Peers restarts");
369
+ react_1.fireEvent.click(view.getByRole("button", { name: /Update & restart/ }));
370
+ await (0, react_1.waitFor)(() => expect(installUpdateAndRestart).toHaveBeenCalledTimes(1));
371
+ });
372
+ it("omits the install-on-restart note when nothing is armed to install", async () => {
373
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
374
+ mockAppUpdate = appUpdate({ installOnRestart: false, installOnQuitArmed: false });
375
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
376
+ await openNetworkDetails(view, /ready to install/);
377
+ expect(view.getByTestId("app-update-section")).not.toHaveTextContent("Installs automatically");
378
+ });
289
379
  it("shows a yellow disconnected-groups count when synced with offline groups", () => {
290
380
  mockViewState = {
291
381
  availability: "ready",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.26.0",
3
+ "version": "0.26.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-ui.git"
@@ -30,7 +30,7 @@
30
30
  "lint:fix": "biome check --write ."
31
31
  },
32
32
  "peerDependencies": {
33
- "@peers-app/peers-sdk": "^0.26.0",
33
+ "@peers-app/peers-sdk": "^0.26.2",
34
34
  "bootstrap": "^5.3.3",
35
35
  "react": "^18.0.0",
36
36
  "react-dom": "^18.0.0"
@@ -41,7 +41,7 @@
41
41
  "@babel/preset-react": "^7.24.1",
42
42
  "@babel/preset-typescript": "^7.27.1",
43
43
  "@biomejs/biome": "2.3.11",
44
- "@peers-app/peers-sdk": "0.26.0",
44
+ "@peers-app/peers-sdk": "0.26.2",
45
45
  "@testing-library/dom": "^10.4.0",
46
46
  "@testing-library/jest-dom": "^6.6.3",
47
47
  "@testing-library/react": "^16.3.0",