Sitelet https://github.com/trustcrypto/OnlyKey-App/commit/a053e61794f9e35083b73cb9129550fb270bfa37
Skip to content

Commit a053e61

Browse files
committed
desktop: install now re-hashes, spawns the installer, then quits
applyAppUpdate verifies SHA-256 on disk, launches a detached NSIS/open/xdg-open process, then quits so files can be replaced. Spawn failure shows the folder and does not quit. Ready dialog is Install now / Show in folder / Later.
1 parent d275faf commit a053e61

8 files changed

Lines changed: 295 additions & 9 deletions

File tree

‎src/components/AppUpdateHost.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ function shouldPresent(phase: AppUpdatePhase): boolean {
1616
phase === 'available' ||
1717
phase === 'downloading' ||
1818
phase === 'ready' ||
19+
phase === 'applying' ||
1920
phase === 'error' ||
2021
phase === 'up-to-date'
2122
);

‎src/components/dialogs/AppUpdateDialog.tsx‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import {
3+
applyUpdate,
34
confirmDownload,
45
dismissUpdatePrompt,
56
showDownloadedUpdate,
@@ -15,6 +16,7 @@ const AppUpdateDialog: React.FC<AppUpdateDialogProps> = ({ open }) => {
1516
const currentVersion = useAppUpdateStore((s) => s.currentVersion);
1617
const latestVersion = useAppUpdateStore((s) => s.latestVersion);
1718
const error = useAppUpdateStore((s) => s.error);
19+
const destPath = useAppUpdateStore((s) => s.destPath);
1820
const downloadReceived = useAppUpdateStore((s) => s.downloadReceived);
1921
const downloadTotal = useAppUpdateStore((s) => s.downloadTotal);
2022

@@ -54,14 +56,26 @@ const AppUpdateDialog: React.FC<AppUpdateDialogProps> = ({ open }) => {
5456
/* no cancel while bytes are in flight */
5557
};
5658
} else if (phase === 'ready') {
57-
title = 'Update downloaded';
58-
message = `Version ${latestVersion} was downloaded and verified (SHA-256).`;
59-
confirmLabel = 'Show in folder';
60-
cancelLabel = 'OK';
59+
title = 'Install update';
60+
message = [
61+
`Version ${latestVersion} was downloaded and verified (SHA-256). Install now? The app will quit so the installer can replace files.`,
62+
destPath
63+
? `If Windows asks for permission and you choose No, open OnlyKey App from the Start Menu — the installer is still at ${destPath}. If Setup says files are in use, close the app and run that file.`
64+
: '',
65+
]
66+
.filter(Boolean)
67+
.join(' ');
68+
confirmLabel = 'Install now';
69+
cancelLabel = 'Later';
6170
onConfirm = () => {
62-
showDownloadedUpdate();
63-
dismissUpdatePrompt();
71+
void applyUpdate();
6472
};
73+
onCancel = () => dismissUpdatePrompt();
74+
} else if (phase === 'applying') {
75+
title = 'Starting installer';
76+
message = 'Launching the installer. OnlyKey App will quit…';
77+
confirmLabel = null;
78+
cancelLabel = null;
6579
} else if (phase === 'up-to-date') {
6680
title = 'App update';
6781
message = `OnlyKey App ${currentVersion} is up to date.`;
@@ -109,6 +123,15 @@ const AppUpdateDialog: React.FC<AppUpdateDialogProps> = ({ open }) => {
109123
{cancelLabel}
110124
</button>
111125
)}
126+
{phase === 'ready' && (
127+
<button
128+
type="button"
129+
onClick={() => showDownloadedUpdate()}
130+
className="px-5 py-2.5 bg-white/5 hover:bg-white/10 rounded-xl font-semibold"
131+
>
132+
Show in folder
133+
</button>
134+
)}
112135
{confirmLabel && (
113136
<button
114137
type="button"

‎src/components/dialogs/__tests__/AppUpdateDialog.ui.test.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ describe('AppUpdateDialog', () => {
3636
expect(screen.getByRole('button', { name: /later/i })).toBeDisabled();
3737
});
3838

39-
it('ready state is Show in folder / OK, not Install now', async () => {
39+
it('ready state offers Install now, Show in folder, and Later', async () => {
4040
const user = userEvent.setup();
4141
useAppUpdateStore.setState({
4242
phase: 'ready',
@@ -45,9 +45,9 @@ describe('AppUpdateDialog', () => {
4545
promptVisible: true,
4646
});
4747
renderWithProviders(<AppUpdateDialog open />);
48-
expect(screen.queryByRole('button', { name: /install now/i })).not.toBeInTheDocument();
48+
expect(screen.getByRole('button', { name: /install now/i })).toBeInTheDocument();
4949
expect(screen.getByRole('button', { name: /show in folder/i })).toBeInTheDocument();
50-
await user.click(screen.getByRole('button', { name: /ok/i }));
50+
await user.click(screen.getByRole('button', { name: /later/i }));
5151
expect(useAppUpdateStore.getState().promptVisible).toBe(false);
5252
});
5353
});

‎src/desktop/__tests__/updater.test.ts‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
APP_UPDATE_SESSION_KEY,
99
AppUpdateError,
1010
DEFAULT_MANIFEST_URL,
11+
applyAppUpdate,
12+
assertSafeUpdatePath,
1113
checkAppUpdate,
1214
compareSemver,
1315
downloadAndVerify,
@@ -699,3 +701,112 @@ describe('showUpdateInFolder', () => {
699701
expect(showInFolder).toHaveBeenCalledWith('/tmp/ok-updates/OnlyKey.exe');
700702
});
701703
});
704+
705+
describe('applyAppUpdate', () => {
706+
const body = new Uint8Array([1, 2, 3]);
707+
const hash = sha256(body);
708+
709+
it('re-hashes, spawns, then quits on win32 (19)', async () => {
710+
const tmp = path.join(os.tmpdir(), 'ok-apply-win');
711+
const dest = path.join(tmp, 'OnlyKey.exe');
712+
const spawnInstaller = vi.fn().mockResolvedValue(undefined);
713+
const quitApp = vi.fn();
714+
const showInFolder = vi.fn();
715+
await applyAppUpdate(dest, { sha256: hash }, {
716+
platform: () => 'win32',
717+
tmpDir: () => tmp,
718+
readFile: () => body,
719+
spawnInstaller,
720+
quitApp,
721+
showInFolder,
722+
applyDelayMs: 0,
723+
});
724+
expect(spawnInstaller).toHaveBeenCalledWith(path.resolve(dest), 'win32');
725+
expect(quitApp).toHaveBeenCalledOnce();
726+
expect(showInFolder).not.toHaveBeenCalled();
727+
});
728+
729+
it('refuses a tampered dest file and unlinks (19b)', async () => {
730+
const tmp = path.join(os.tmpdir(), 'ok-apply-tamper');
731+
const dest = path.join(tmp, 'OnlyKey.exe');
732+
const unlink = vi.fn();
733+
const spawnInstaller = vi.fn();
734+
const quitApp = vi.fn();
735+
await expect(
736+
applyAppUpdate(dest, { sha256: hash }, {
737+
platform: () => 'win32',
738+
tmpDir: () => tmp,
739+
readFile: () => new Uint8Array([9, 9, 9]),
740+
unlink,
741+
spawnInstaller,
742+
quitApp,
743+
applyDelayMs: 0,
744+
}),
745+
).rejects.toMatchObject({ code: 'sha256-mismatch' });
746+
expect(unlink).toHaveBeenCalled();
747+
expect(spawnInstaller).not.toHaveBeenCalled();
748+
expect(quitApp).not.toHaveBeenCalled();
749+
});
750+
751+
it('rejects destPath outside tmpDir, ADS names, and wrong extension (19c)', () => {
752+
const tmp = path.join(os.tmpdir(), 'ok-apply-safe');
753+
expect(() =>
754+
assertSafeUpdatePath(path.join(os.tmpdir(), 'other', 'OnlyKey.exe'), tmp, 'win32'),
755+
).toThrow(/outside/);
756+
expect(() => assertSafeUpdatePath(path.join(tmp, 'OnlyKey.exe:ads'), tmp, 'win32')).toThrow(
757+
/not allowed/,
758+
);
759+
expect(() => assertSafeUpdatePath(path.join(tmp, 'OnlyKey.dmg'), tmp, 'win32')).toThrow(
760+
/file type/,
761+
);
762+
});
763+
764+
it('spawns open / xdg-open then quits on darwin and linux (20)', async () => {
765+
const spawnInstaller = vi.fn().mockResolvedValue(undefined);
766+
const quitApp = vi.fn();
767+
const macTmp = path.join(os.tmpdir(), 'ok-apply-mac');
768+
const macDest = path.join(macTmp, 'OnlyKey.dmg');
769+
await applyAppUpdate(macDest, { sha256: hash }, {
770+
platform: () => 'darwin',
771+
tmpDir: () => macTmp,
772+
readFile: () => body,
773+
spawnInstaller,
774+
quitApp,
775+
applyDelayMs: 0,
776+
});
777+
expect(spawnInstaller).toHaveBeenCalledWith(path.resolve(macDest), 'darwin');
778+
779+
const linTmp = path.join(os.tmpdir(), 'ok-apply-lin');
780+
const linDest = path.join(linTmp, 'OnlyKey.deb');
781+
await applyAppUpdate(linDest, { sha256: hash }, {
782+
platform: () => 'linux',
783+
tmpDir: () => linTmp,
784+
readFile: () => body,
785+
spawnInstaller,
786+
quitApp,
787+
applyDelayMs: 0,
788+
});
789+
expect(spawnInstaller).toHaveBeenCalledWith(path.resolve(linDest), 'linux');
790+
expect(quitApp).toHaveBeenCalledTimes(2);
791+
});
792+
793+
it('shows the folder and does not quit when spawn fails (21)', async () => {
794+
const tmp = path.join(os.tmpdir(), 'ok-apply-fail');
795+
const dest = path.join(tmp, 'OnlyKey.exe');
796+
const showInFolder = vi.fn();
797+
const quitApp = vi.fn();
798+
await expect(
799+
applyAppUpdate(dest, { sha256: hash }, {
800+
platform: () => 'win32',
801+
tmpDir: () => tmp,
802+
readFile: () => body,
803+
spawnInstaller: vi.fn().mockRejectedValue(new Error('ENOENT')),
804+
showInFolder,
805+
quitApp,
806+
applyDelayMs: 0,
807+
}),
808+
).rejects.toMatchObject({ code: 'apply-failed' });
809+
expect(showInFolder).toHaveBeenCalled();
810+
expect(quitApp).not.toHaveBeenCalled();
811+
});
812+
});

‎src/desktop/updater.ts‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,3 +525,100 @@ export async function downloadAndVerify(
525525
export function showUpdateInFolder(destPath: string, io: AppUpdateIo = {}): void {
526526
(io.showInFolder ?? ((p: string) => nw.Shell.showItemInFolder(p)))(destPath);
527527
}
528+
529+
const INSTALLER_EXT: Partial<Record<NodeJS.Platform, string>> = {
530+
win32: '.exe',
531+
darwin: '.dmg',
532+
linux: '.deb',
533+
};
534+
535+
export function assertSafeUpdatePath(
536+
destPath: string,
537+
tmpDir: string,
538+
platform: NodeJS.Platform,
539+
): string {
540+
const path = require('path') as typeof import('path');
541+
const resolved = path.resolve(destPath);
542+
const root = path.resolve(tmpDir);
543+
const prefix = root.endsWith(path.sep) ? root : root + path.sep;
544+
if (resolved !== root && !resolved.startsWith(prefix)) {
545+
throw new AppUpdateError('Installer path is outside the updates directory.', 'io');
546+
}
547+
const base = path.basename(resolved);
548+
if (base.includes(':') || base.includes('\0') || base === '..' || base === '.') {
549+
throw new AppUpdateError('Installer path is not allowed.', 'io');
550+
}
551+
const expected = INSTALLER_EXT[platform];
552+
if (!expected || path.extname(resolved).toLowerCase() !== expected) {
553+
throw new AppUpdateError('Installer file type does not match this OS.', 'io');
554+
}
555+
return resolved;
556+
}
557+
558+
function defaultSpawnInstaller(destPath: string, platform: NodeJS.Platform): Promise<void> {
559+
const { spawn } = require('child_process') as typeof import('child_process');
560+
const cmd = platform === 'win32' ? destPath : platform === 'darwin' ? 'open' : 'xdg-open';
561+
const args = platform === 'win32' ? [] : [destPath];
562+
return new Promise((resolve, reject) => {
563+
let settled = false;
564+
const child = spawn(cmd, args, {
565+
detached: true,
566+
stdio: 'ignore',
567+
windowsHide: true,
568+
shell: false,
569+
});
570+
child.once('error', (err) => {
571+
if (settled) return;
572+
settled = true;
573+
reject(err);
574+
});
575+
child.once('spawn', () => {
576+
if (settled) return;
577+
settled = true;
578+
child.unref();
579+
resolve();
580+
});
581+
});
582+
}
583+
584+
export async function applyAppUpdate(
585+
destPath: string,
586+
expected: { sha256: string; bytes?: number },
587+
io: AppUpdateIo = {},
588+
): Promise<void> {
589+
const platform = io.platform?.() ?? process.platform;
590+
const tmp = io.tmpDir?.() ?? defaultTmpDir();
591+
const safe = assertSafeUpdatePath(destPath, tmp, platform);
592+
593+
let body: Uint8Array;
594+
try {
595+
body = io.readFile?.(safe) ?? new Uint8Array(require('fs').readFileSync(safe));
596+
} catch {
597+
throw new AppUpdateError('Could not read the downloaded installer.', 'io');
598+
}
599+
try {
600+
verifySha256(body, expected.sha256);
601+
} catch {
602+
unlinkDest(safe, io);
603+
throw new AppUpdateError(
604+
'Update package SHA-256 does not match the manifest.',
605+
'sha256-mismatch',
606+
);
607+
}
608+
609+
try {
610+
await (io.spawnInstaller ?? defaultSpawnInstaller)(safe, platform);
611+
} catch {
612+
showUpdateInFolder(safe, io);
613+
throw new AppUpdateError(
614+
destPath
615+
? `Could not open the installer. It is still at ${safe}.`
616+
: 'Could not open the installer.',
617+
'apply-failed',
618+
);
619+
}
620+
621+
const delay = io.applyDelayMs ?? 400;
622+
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
623+
(io.quitApp ?? (() => nw.App.quit()))();
624+
}

‎src/store/__tests__/useAppUpdateStore.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { userPreferences } from '../../desktop/userPreferences';
55
const checkAppUpdate = vi.fn();
66
const downloadAndVerify = vi.fn();
77
const showUpdateInFolder = vi.fn();
8+
const applyAppUpdate = vi.fn();
89

910
vi.mock('../../desktop/updater', async (importOriginal) => {
1011
const actual = await importOriginal<typeof import('../../desktop/updater')>();
@@ -13,11 +14,13 @@ vi.mock('../../desktop/updater', async (importOriginal) => {
1314
checkAppUpdate: (...args: unknown[]) => checkAppUpdate(...args),
1415
downloadAndVerify: (...args: unknown[]) => downloadAndVerify(...args),
1516
showUpdateInFolder: (...args: unknown[]) => showUpdateInFolder(...args),
17+
applyAppUpdate: (...args: unknown[]) => applyAppUpdate(...args),
1618
};
1719
});
1820

1921
import { AUTO_UPDATE_PREF_EVENT } from '../../desktop/userPreferences';
2022
import {
23+
applyUpdate,
2124
bindAutoUpdatePrefListeners,
2225
checkNow,
2326
confirmDownload,
@@ -28,6 +31,7 @@ import {
2831
startAutoCheck,
2932
useAppUpdateStore,
3033
} from '../useAppUpdateStore';
34+
import { seedDeviceStore } from '../../test/store';
3135

3236
const available = {
3337
kind: 'available' as const,
@@ -49,6 +53,9 @@ describe('useAppUpdateStore', () => {
4953
checkAppUpdate.mockReset();
5054
downloadAndVerify.mockReset();
5155
showUpdateInFolder.mockReset();
56+
applyAppUpdate.mockReset();
57+
applyAppUpdate.mockResolvedValue(undefined);
58+
seedDeviceStore({ isWorking: false });
5259
userPreferences.autoUpdate = true;
5360
resetAppUpdateStoreForTests();
5461
vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -179,4 +186,25 @@ describe('useAppUpdateStore', () => {
179186
showDownloadedUpdate();
180187
expect(showUpdateInFolder).toHaveBeenCalledWith('/tmp/ok.exe');
181188
});
189+
190+
it('applyUpdate spawns the installer from ready', async () => {
191+
useAppUpdateStore.setState({
192+
phase: 'ready',
193+
destPath: '/tmp/ok.exe',
194+
expectedSha256: 'abc',
195+
});
196+
await applyUpdate();
197+
expect(applyAppUpdate).toHaveBeenCalledWith('/tmp/ok.exe', { sha256: 'abc' });
198+
});
199+
200+
it('applyUpdate no-ops while a device job is running', async () => {
201+
seedDeviceStore({ isWorking: true });
202+
useAppUpdateStore.setState({
203+
phase: 'ready',
204+
destPath: '/tmp/ok.exe',
205+
expectedSha256: 'abc',
206+
});
207+
await applyUpdate();
208+
expect(applyAppUpdate).not.toHaveBeenCalled();
209+
});
182210
});

0 commit comments

Comments
 (0)