Skip to content

Commit 0e4e520

Browse files
committed
Add main window update indicator
1 parent f06e19a commit 0e4e520

15 files changed

Lines changed: 267 additions & 24 deletions

src/main/index.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { autoUpdater } from 'electron-updater';
33
import { existsSync } from 'node:fs';
44
import { join } from 'node:path';
55
import { DEFAULT_BLUETOOTH_STATUS } from '../shared/bluetooth-status';
6+
import { SHOW_SETTINGS_SECTION_CHANNEL } from '../shared/ipc-channels';
7+
import type { SettingsSectionId } from '../shared/settings';
68
import { WindowsBluetoothTransport } from './bluetooth/bluetooth-transport';
79
import { ControlService } from './control/control-service';
810
import { CursorOverlay } from './cursor-overlay';
@@ -122,7 +124,11 @@ function createMainWindow(options: MainWindowOptions = {}): BrowserWindow {
122124
return window;
123125
}
124126

125-
function createSettingsWindow(): BrowserWindow {
127+
function settingsHashForSection(section: SettingsSectionId): string {
128+
return section === 'general' ? '/settings' : `/settings/${section}`;
129+
}
130+
131+
function createSettingsWindow(section: SettingsSectionId = 'general'): BrowserWindow {
126132
const iconPath = appIconPath();
127133
const window = new BrowserWindow({
128134
width: 820,
@@ -172,10 +178,10 @@ function createSettingsWindow(): BrowserWindow {
172178

173179
if (isDev && process.env.ELECTRON_RENDERER_URL) {
174180
const url = new URL(process.env.ELECTRON_RENDERER_URL);
175-
url.hash = '/settings';
181+
url.hash = settingsHashForSection(section);
176182
void window.loadURL(url.toString());
177183
} else {
178-
void window.loadFile(join(__dirname, '../renderer/index.html'), { hash: '/settings' });
184+
void window.loadFile(join(__dirname, '../renderer/index.html'), { hash: settingsHashForSection(section) });
179185
}
180186

181187
return window;
@@ -194,9 +200,24 @@ function showMainWindow(): void {
194200
mainWindow.focus();
195201
}
196202

197-
function showSettingsWindow(): void {
203+
function sendSettingsSection(window: BrowserWindow, section: SettingsSectionId): void {
204+
const send = (): void => {
205+
if (!window.isDestroyed()) {
206+
window.webContents.send(SHOW_SETTINGS_SECTION_CHANNEL, section);
207+
}
208+
};
209+
210+
if (window.webContents.isLoading()) {
211+
window.webContents.once('did-finish-load', send);
212+
return;
213+
}
214+
215+
send();
216+
}
217+
218+
function showSettingsWindow(section: SettingsSectionId = 'general'): void {
198219
if (!settingsWindow || settingsWindow.isDestroyed()) {
199-
settingsWindow = createSettingsWindow();
220+
settingsWindow = createSettingsWindow(section);
200221
return;
201222
}
202223

@@ -205,6 +226,7 @@ function showSettingsWindow(): void {
205226
}
206227
settingsWindow.show();
207228
settingsWindow.focus();
229+
sendSettingsSection(settingsWindow, section);
208230
}
209231

210232
function quitApp(): void {

src/main/settings-window-ipc.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { ipcMain } from 'electron';
22
import { OPEN_SETTINGS_WINDOW_CHANNEL } from '../shared/ipc-channels';
3+
import { isSettingsSectionId, type SettingsSectionId } from '../shared/settings';
34

4-
export function registerSettingsWindowIpc(openSettingsWindow: () => void): void {
5-
ipcMain.handle(OPEN_SETTINGS_WINDOW_CHANNEL, () => {
6-
openSettingsWindow();
5+
export function registerSettingsWindowIpc(openSettingsWindow: (section?: SettingsSectionId) => void): void {
6+
ipcMain.handle(OPEN_SETTINGS_WINDOW_CHANNEL, (_event, section) => {
7+
openSettingsWindow(isSettingsSectionId(section) ? section : undefined);
78
});
89
}

src/preload/index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { contextBridge, ipcRenderer } from 'electron';
1+
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron';
22
import type { PairingApprovalDecision, PendingPairingApprovalView } from '../shared/pairing-approval';
33
import type { PairedDeviceView, PcControlStatus } from '../shared/server-status';
44
import type { CursorOverlaySettings } from '../shared/cursor-overlay-settings';
@@ -23,7 +23,9 @@ import {
2323
SET_CURSOR_OVERLAY_ENABLED_CHANNEL,
2424
SET_CURSOR_OVERLAY_SETTINGS_CHANNEL,
2525
SET_START_WITH_SYSTEM_CHANNEL,
26+
SHOW_SETTINGS_SECTION_CHANNEL,
2627
} from '../shared/ipc-channels';
28+
import type { SettingsSectionId } from '../shared/settings';
2729
import type { SystemStartupSettings } from '../shared/system-startup';
2830
import type { UpdateState } from '../shared/update';
2931

@@ -45,7 +47,13 @@ contextBridge.exposeInMainWorld('switchifyPc', {
4547
getCursorOverlaySettings: (): Promise<CursorOverlaySettings> => ipcRenderer.invoke(GET_CURSOR_OVERLAY_SETTINGS_CHANNEL),
4648
setCursorOverlaySettings: (settings: CursorOverlaySettings): Promise<CursorOverlaySettings> =>
4749
ipcRenderer.invoke(SET_CURSOR_OVERLAY_SETTINGS_CHANNEL, settings),
48-
openSettingsWindow: (): Promise<void> => ipcRenderer.invoke(OPEN_SETTINGS_WINDOW_CHANNEL),
50+
openSettingsWindow: (section?: SettingsSectionId): Promise<void> =>
51+
ipcRenderer.invoke(OPEN_SETTINGS_WINDOW_CHANNEL, section),
52+
onShowSettingsSection: (handler: (section: SettingsSectionId) => void): (() => void) => {
53+
const listener = (_event: IpcRendererEvent, section: SettingsSectionId): void => handler(section);
54+
ipcRenderer.on(SHOW_SETTINGS_SECTION_CHANNEL, listener);
55+
return () => ipcRenderer.removeListener(SHOW_SETTINGS_SECTION_CHANNEL, listener);
56+
},
4957
openExternalUrl: (url: string): Promise<{ ok: boolean }> =>
5058
ipcRenderer.invoke(OPEN_EXTERNAL_URL_CHANNEL, url),
5159
getPendingPairingRequests: (): Promise<PendingPairingApprovalView[]> =>

src/renderer/App.tsx

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { ReactElement } from 'react';
1+
import { useEffect, useState, type ReactElement } from 'react';
2+
import type { UpdateState } from '../shared/update';
23
import { AndroidDownloadPanel } from './components/AndroidDownloadPanel';
34
import { PairingApprovalRequests } from './components/PairingApprovalRequests';
45
import { PrimaryContent } from './components/PrimaryContent';
@@ -9,7 +10,7 @@ import { SettingsApp } from './SettingsApp';
910
import { useSwitchifyPcStatus } from './useSwitchifyPcStatus';
1011

1112
export function App(): ReactElement {
12-
if (window.location.hash === '#/settings') {
13+
if (window.location.hash === '#/settings' || window.location.hash.startsWith('#/settings/')) {
1314
return <SettingsApp />;
1415
}
1516

@@ -19,9 +20,38 @@ export function App(): ReactElement {
1920
function MainApp(): ReactElement {
2021
const bridge = window.switchifyPc;
2122
const status = useSwitchifyPcStatus(bridge);
23+
const [updateState, setUpdateState] = useState<UpdateState | null>(null);
24+
25+
useEffect(() => {
26+
let cancelled = false;
27+
28+
const refreshUpdateState = (): void => {
29+
void bridge.getUpdateState().then((state) => {
30+
if (!cancelled) {
31+
setUpdateState(state);
32+
}
33+
});
34+
};
35+
36+
refreshUpdateState();
37+
const interval = window.setInterval(refreshUpdateState, 30_000);
38+
window.addEventListener('focus', refreshUpdateState);
39+
40+
return () => {
41+
cancelled = true;
42+
window.clearInterval(interval);
43+
window.removeEventListener('focus', refreshUpdateState);
44+
};
45+
}, [bridge]);
2246

2347
return (
24-
<WindowChrome title={bridge.appName} state={status.uiState} className="app-shell">
48+
<WindowChrome
49+
title={bridge.appName}
50+
state={status.uiState}
51+
className="app-shell"
52+
updateState={updateState}
53+
onOpenUpdates={() => bridge.openSettingsWindow('updates')}
54+
>
2555
<section className="setup-card" aria-label="Switchify PC setup">
2656
<StatusHeader
2757
state={status.uiState}

src/renderer/SettingsApp.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { SystemStartupSettings } from '../shared/system-startup';
33
import type { UpdateState } from '../shared/update';
44
import { SettingsView } from './components/SettingsPanel';
55
import { WindowChrome } from './components/WindowTitleBar';
6+
import { settingsSectionFromHash } from './settings-route';
67
import { useSwitchifyPcStatus } from './useSwitchifyPcStatus';
78

89
export function SettingsApp(): ReactElement {
@@ -107,9 +108,11 @@ export function SettingsApp(): ReactElement {
107108
updateState={updateState}
108109
isCheckingForUpdates={isCheckingForUpdates}
109110
isDownloadingUpdate={isDownloadingUpdate}
110-
onCheckForUpdates={checkForUpdates}
111-
onDownloadUpdate={downloadUpdate}
112-
onInstallDownloadedUpdate={installDownloadedUpdate}
111+
initialSection={settingsSectionFromHash(window.location.hash)}
112+
onSettingsSectionRequest={bridge.onShowSettingsSection}
113+
onCheckForUpdates={checkForUpdates}
114+
onDownloadUpdate={downloadUpdate}
115+
onInstallDownloadedUpdate={installDownloadedUpdate}
113116
/>
114117
</WindowChrome>
115118
);

src/renderer/api.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export {};
33
import type { PairingApprovalDecision, PendingPairingApprovalView } from '../shared/pairing-approval';
44
import type { PairedDeviceView, PcControlStatus } from '../shared/server-status';
55
import type { CursorOverlaySettings } from '../shared/cursor-overlay-settings';
6+
import type { SettingsSectionId } from '../shared/settings';
67
import type { SystemStartupSettings } from '../shared/system-startup';
78
import type { UpdateState } from '../shared/update';
89

@@ -24,7 +25,8 @@ declare global {
2425
setCursorOverlayEnabled: (enabled: boolean) => Promise<boolean>;
2526
getCursorOverlaySettings: () => Promise<CursorOverlaySettings>;
2627
setCursorOverlaySettings: (settings: CursorOverlaySettings) => Promise<CursorOverlaySettings>;
27-
openSettingsWindow: () => Promise<void>;
28+
openSettingsWindow: (section?: SettingsSectionId) => Promise<void>;
29+
onShowSettingsSection: (handler: (section: SettingsSectionId) => void) => () => void;
2830
openExternalUrl: (url: string) => Promise<{ ok: boolean }>;
2931
getPendingPairingRequests: () => Promise<PendingPairingApprovalView[]>;
3032
respondToPairingRequest: (

src/renderer/components/SettingsPanel.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, type ReactElement } from 'react';
1+
import { useEffect, useState, type ReactElement } from 'react';
22
import type {
33
CursorOverlayColor,
44
CursorOverlaySettings,
@@ -8,6 +8,7 @@ import type {
88
import { CURSOR_OVERLAY_COLORS } from '../../shared/cursor-overlay-settings';
99
import type { PairedDeviceView, PcControlStatus } from '../../shared/server-status';
1010
import type { SystemStartupSettings } from '../../shared/system-startup';
11+
import type { SettingsSectionId } from '../../shared/settings';
1112
import type { UpdateState } from '../../shared/update';
1213
import { formatBluetoothStatus } from '../bluetooth-status';
1314
import type { ConnectedDeviceView } from '../connected-devices';
@@ -29,13 +30,13 @@ type SettingsViewProps = {
2930
updateState: UpdateState | null;
3031
isCheckingForUpdates: boolean;
3132
isDownloadingUpdate: boolean;
33+
initialSection: SettingsSectionId;
34+
onSettingsSectionRequest?: (handler: (section: SettingsSectionId) => void) => () => void;
3235
onCheckForUpdates: () => Promise<void>;
3336
onDownloadUpdate: () => Promise<void>;
3437
onInstallDownloadedUpdate: () => Promise<void>;
3538
};
3639

37-
type SettingsSectionId = 'general' | 'bluetooth' | 'pointer' | 'updates' | 'savedDevices';
38-
3940
const SETTINGS_SECTIONS: Array<{
4041
id: SettingsSectionId;
4142
label: string;
@@ -61,11 +62,18 @@ export function SettingsView({
6162
updateState,
6263
isCheckingForUpdates,
6364
isDownloadingUpdate,
65+
initialSection,
66+
onSettingsSectionRequest,
6467
onCheckForUpdates,
6568
onDownloadUpdate,
6669
onInstallDownloadedUpdate
6770
}: SettingsViewProps): ReactElement {
68-
const [selectedSection, setSelectedSection] = useState<SettingsSectionId>('general');
71+
const [selectedSection, setSelectedSection] = useState<SettingsSectionId>(initialSection);
72+
73+
useEffect(() => {
74+
if (!onSettingsSectionRequest) return undefined;
75+
return onSettingsSectionRequest(setSelectedSection);
76+
}, [onSettingsSectionRequest]);
6977

7078
return (
7179
<div className="settings-layout">

src/renderer/components/WindowTitleBar.tsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { ReactElement, ReactNode } from 'react';
22
import type { DesktopUiState } from '../../shared/desktop-ui-state';
3+
import type { UpdateState } from '../../shared/update';
4+
import { updateIndicatorState } from '../updates';
35
import { Tooltip } from './Tooltip';
46

57
type WindowControlOptions = {
@@ -12,18 +14,29 @@ export function WindowChrome({
1214
subtitle,
1315
className,
1416
windowControls,
17+
updateState,
18+
onOpenUpdates,
1519
children
1620
}: {
1721
title: string;
1822
state?: DesktopUiState;
1923
subtitle?: string;
2024
className: string;
2125
windowControls?: WindowControlOptions;
26+
updateState?: UpdateState | null;
27+
onOpenUpdates?: () => Promise<void> | void;
2228
children: ReactNode;
2329
}): ReactElement {
2430
return (
2531
<>
26-
<WindowTitleBar title={title} state={state} subtitle={subtitle} windowControls={windowControls} />
32+
<WindowTitleBar
33+
title={title}
34+
state={state}
35+
subtitle={subtitle}
36+
windowControls={windowControls}
37+
updateState={updateState}
38+
onOpenUpdates={onOpenUpdates}
39+
/>
2740
<main className={className}>{children}</main>
2841
</>
2942
);
@@ -33,17 +46,22 @@ export function WindowTitleBar({
3346
title,
3447
state,
3548
subtitle,
36-
windowControls
49+
windowControls,
50+
updateState,
51+
onOpenUpdates
3752
}: {
3853
title: string;
3954
state?: DesktopUiState;
4055
subtitle?: string;
4156
windowControls?: WindowControlOptions;
57+
updateState?: UpdateState | null;
58+
onOpenUpdates?: () => Promise<void> | void;
4259
}): ReactElement {
4360
const controls = {
4461
minimize: true,
4562
...windowControls
4663
};
64+
const updateIndicator = updateIndicatorState(updateState ?? null);
4765

4866
return (
4967
<header className="window-titlebar" aria-label="Window title bar">
@@ -58,6 +76,18 @@ export function WindowTitleBar({
5876
{!state && subtitle ? <span className="window-titlebar-status">{subtitle}</span> : null}
5977
</div>
6078
<div className="window-titlebar-controls" aria-label="Window controls">
79+
{updateIndicator !== 'hidden' && onOpenUpdates ? (
80+
<Tooltip label={updateIndicator === 'downloaded' ? 'Update ready to install' : 'Update available'} placement="bottom">
81+
<button
82+
type="button"
83+
className={`window-titlebar-control window-titlebar-update window-titlebar-update-${updateIndicator}`}
84+
aria-label="Open updates"
85+
onClick={() => void onOpenUpdates()}
86+
>
87+
<span className="window-control-icon window-control-icon-update" aria-hidden="true" />
88+
</button>
89+
</Tooltip>
90+
) : null}
6191
{controls.minimize ? (
6292
<Tooltip label="Minimize" placement="bottom">
6393
<button
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { settingsHashForSection, settingsSectionFromHash } from './settings-route';
3+
4+
describe('settingsSectionFromHash', () => {
5+
it('uses general for the base settings route', () => {
6+
expect(settingsSectionFromHash('#/settings')).toBe('general');
7+
});
8+
9+
it('reads the updates section from the settings route', () => {
10+
expect(settingsSectionFromHash('#/settings/updates')).toBe('updates');
11+
});
12+
13+
it('uses general for invalid sections', () => {
14+
expect(settingsSectionFromHash('#/settings/unknown')).toBe('general');
15+
});
16+
});
17+
18+
describe('settingsHashForSection', () => {
19+
it('formats the general settings route', () => {
20+
expect(settingsHashForSection('general')).toBe('/settings');
21+
});
22+
23+
it('formats non-default settings sections', () => {
24+
expect(settingsHashForSection('updates')).toBe('/settings/updates');
25+
});
26+
});

src/renderer/settings-route.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { SettingsSectionId } from '../shared/settings';
2+
import { isSettingsSectionId } from '../shared/settings';
3+
4+
export function settingsHashForSection(section: SettingsSectionId): string {
5+
return section === 'general' ? '/settings' : `/settings/${section}`;
6+
}
7+
8+
export function settingsSectionFromHash(hash: string): SettingsSectionId {
9+
const section = hash.replace(/^#\/settings\/?/, '');
10+
return isSettingsSectionId(section) ? section : 'general';
11+
}

0 commit comments

Comments
 (0)