Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ function initBackend() {
nativeTheme.themeSource = theme;
};
applyTheme(readSettings(settings).theme); // honor the persisted choice at launch
registerIpc(ipcMain, { accounts, settings, secrets, crypto: safeStorage, db, saveDialog, selectDirectory, saveTextFile, openTextFile, appVersion: app.getVersion(), openExternal: (url) => shell.openExternal(url), applyTheme });
registerIpc(ipcMain, { accounts, settings, secrets, crypto: safeStorage, db, saveDialog, selectDirectory, saveTextFile, openTextFile, appVersion: app.getVersion(), openExternal: (url) => shell.openExternal(url), downloadsDir: app.getPath('downloads'), openPath: async (filePath) => { await shell.openPath(filePath); }, applyTheme });
}

const createWindow = () => {
Expand Down
2 changes: 2 additions & 0 deletions src/main/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const CH = {
saveTextFile: 'util:saveTextFile',
openTextFile: 'util:openTextFile',
checkForUpdate: 'app:checkForUpdate',
downloadUpdate: 'app:downloadUpdate',
getObjectAcl: 's3:getObjectAcl',
putObjectAcl: 's3:putObjectAcl',
getEditableMetadata: 's3:getEditableMetadata',
Expand Down Expand Up @@ -151,6 +152,7 @@ export interface ApiMap {
[CH.saveTextFile]: { args: [{ defaultName: string; contents: string }]; res: Result<{ saved: boolean }> };
[CH.openTextFile]: { args: []; res: Result<string | null> };
[CH.checkForUpdate]: { args: []; res: Result<UpdateInfo> };
[CH.downloadUpdate]: { args: [{ url: string; fileName: string }]; res: Result<{ path: string }> };
[CH.getObjectAcl]: { args: [{ accountId: string; bucket: string; key: string }]; res: Result<ObjectAcl> };
[CH.putObjectAcl]: { args: [{ accountId: string; bucket: string; key: string; acl: ObjectAcl }]; res: Result<true> };
[CH.getEditableMetadata]: { args: [{ accountId: string; bucket: string; key: string }]; res: Result<EditableMetadata> };
Expand Down
20 changes: 20 additions & 0 deletions src/main/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { createBucket } from '../s3/buckets';
import { exportAccounts, importAccounts, TransferError, peekEnvelope } from '../accounts/accountTransfer';
import type { ExportAccount, ImportPreview } from '../accounts/accountTransfer';
import { checkForUpdate } from '../update/checkForUpdate';
import { downloadInstaller } from '../update/downloadInstaller';
import { planSync, runSync, type Endpoint } from '../s3/sync';
import { planLocalSync, runLocalSync } from '../s3/localSync';
import type { LocalSyncArgs } from '../s3/localSync';
Expand Down Expand Up @@ -65,6 +66,10 @@ export interface RegisterDeps {
openExternal: (url: string) => Promise<void>;
/** Fetch implementation for the update check; defaults to globalThis.fetch. Injectable for tests. */
fetchImpl?: typeof fetch;
/** Absolute directory installers are downloaded into (app.getPath('downloads')), injected by main.ts. */
downloadsDir?: string;
/** Opens a downloaded file with the OS default handler (shell.openPath), injected by main.ts. */
openPath?: (filePath: string) => Promise<void>;
/** Applies the chosen theme to native chrome (nativeTheme.themeSource), injected by main.ts. Optional so tests/headless can omit it. */
applyTheme?: (theme: AppSettings['theme']) => void;
}
Expand Down Expand Up @@ -500,4 +505,19 @@ export function registerIpc(ipcMain: IpcMainLike, deps: RegisterDeps): void {
h(CH.checkForUpdate, () =>
checkForUpdate({ fetchImpl: deps.fetchImpl ?? globalThis.fetch, currentVersion: deps.appVersion }),
);

h(CH.downloadUpdate, async ({ url, fileName }: { url: string; fileName: string }) => {
if (!deps.downloadsDir || !deps.openPath) {
return err('UpdateDownloadFailed', 'Downloading updates is not available in this environment');
}
const res = await downloadInstaller({
url,
fileName,
destDir: deps.downloadsDir,
fetchImpl: deps.fetchImpl ?? globalThis.fetch,
});
if (!res.ok) return res;
await deps.openPath(res.data.path);
return res;
});
}
78 changes: 74 additions & 4 deletions src/main/update/checkForUpdate.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { compareVersions, checkForUpdate } from './checkForUpdate';
import { compareVersions, checkForUpdate, pickInstallerAsset, type ReleaseAsset } from './checkForUpdate';

function fakeFetch(impl: { status: number; body?: unknown; throwError?: string; jsonThrows?: boolean }) {
return vi.fn().mockImplementation(async () => {
Expand Down Expand Up @@ -36,13 +36,83 @@ describe('compareVersions', () => {
});
});

describe('pickInstallerAsset', () => {
const assets: ReleaseAsset[] = [
{ name: 's3Manager-2.0.0-arm64.dmg', downloadUrl: 'https://x/arm64.dmg', size: 1 },
{ name: 's3Manager-2.0.0-x64.dmg', downloadUrl: 'https://x/x64.dmg', size: 2 },
{ name: 's3Manager-2.0.0.Setup.exe', downloadUrl: 'https://x/setup.exe', size: 3 },
{ name: 's3manager_2.0.0_amd64.deb', downloadUrl: 'https://x/amd64.deb', size: 4 },
{ name: 's3manager-2.0.0.x86_64.rpm', downloadUrl: 'https://x/x86_64.rpm', size: 5 },
];

it('picks the arch-matching .dmg on macOS', () => {
expect(pickInstallerAsset(assets, 'darwin', 'arm64')?.name).toBe('s3Manager-2.0.0-arm64.dmg');
expect(pickInstallerAsset(assets, 'darwin', 'x64')?.name).toBe('s3Manager-2.0.0-x64.dmg');
});

it('picks the .exe on Windows', () => {
expect(pickInstallerAsset(assets, 'win32', 'x64')?.name).toBe('s3Manager-2.0.0.Setup.exe');
});

it('prefers .deb over .rpm on Linux and honors arch aliases', () => {
expect(pickInstallerAsset(assets, 'linux', 'x64')?.name).toBe('s3manager_2.0.0_amd64.deb');
});

it('falls back to the first matching-ext asset when no arch token matches', () => {
const only = [{ name: 'app.dmg', downloadUrl: 'https://x/a.dmg', size: 1 }];
expect(pickInstallerAsset(only, 'darwin', 'arm64')?.name).toBe('app.dmg');
});

it('returns null for an unknown platform or when nothing matches', () => {
expect(pickInstallerAsset(assets, 'aix', 'x64')).toBeNull();
expect(pickInstallerAsset([{ name: 'notes.txt', downloadUrl: 'https://x/n.txt', size: 1 }], 'darwin', 'arm64')).toBeNull();
});
});

describe('checkForUpdate', () => {
it('reports an available update from a newer tag', async () => {
const res = await checkForUpdate({
fetchImpl: fakeFetch({ status: 200, body: { tag_name: 'v2.0.0', html_url: 'https://example/r' } }),
currentVersion: '1.0.0',
});
expect(res).toEqual({ ok: true, data: { currentVersion: '1.0.0', latestVersion: '2.0.0', updateAvailable: true, releaseUrl: 'https://example/r' } });
expect(res).toEqual({ ok: true, data: { currentVersion: '1.0.0', latestVersion: '2.0.0', updateAvailable: true, releaseUrl: 'https://example/r', installer: null } });
});

it('selects the platform/arch installer from release assets when an update is available', async () => {
const res = await checkForUpdate({
fetchImpl: fakeFetch({
status: 200,
body: {
tag_name: 'v2.0.0',
html_url: 'https://example/r',
assets: [
{ name: 's3Manager-2.0.0-arm64.dmg', browser_download_url: 'https://github.com/a/arm64.dmg', size: 10 },
{ name: 's3Manager-2.0.0-x64.dmg', browser_download_url: 'https://github.com/a/x64.dmg', size: 11 },
],
},
}),
currentVersion: '1.0.0',
platform: 'darwin',
arch: 'arm64',
});
expect(res.ok && res.data.installer).toEqual({ name: 's3Manager-2.0.0-arm64.dmg', downloadUrl: 'https://github.com/a/arm64.dmg', size: 10 });
});

it('does not offer an installer when the tag is not newer', async () => {
const res = await checkForUpdate({
fetchImpl: fakeFetch({
status: 200,
body: {
tag_name: 'v1.0.0',
html_url: 'https://example/r',
assets: [{ name: 'app-1.0.0-x64.dmg', browser_download_url: 'https://github.com/a/x64.dmg', size: 10 }],
},
}),
currentVersion: '1.0.0',
platform: 'darwin',
arch: 'x64',
});
expect(res.ok && res.data.installer).toBeNull();
});

it('reports up to date when the tag matches', async () => {
Expand All @@ -55,7 +125,7 @@ describe('checkForUpdate', () => {

it('treats a 404 (no releases) as up to date with the releases page url', async () => {
const res = await checkForUpdate({ fetchImpl: fakeFetch({ status: 404 }), currentVersion: '1.0.0' });
expect(res).toEqual({ ok: true, data: { currentVersion: '1.0.0', latestVersion: null, updateAvailable: false, releaseUrl: 'https://github.com/NoiXdev/s3Manager/releases' } });
expect(res).toEqual({ ok: true, data: { currentVersion: '1.0.0', latestVersion: null, updateAvailable: false, releaseUrl: 'https://github.com/NoiXdev/s3Manager/releases', installer: null } });
});

it('returns an error on a non-OK response', async () => {
Expand All @@ -75,6 +145,6 @@ describe('checkForUpdate', () => {

it('treats a 200 response with no tag as up to date', async () => {
const res = await checkForUpdate({ fetchImpl: fakeFetch({ status: 200, body: {} }), currentVersion: '1.0.0' });
expect(res).toEqual({ ok: true, data: { currentVersion: '1.0.0', latestVersion: null, updateAvailable: false, releaseUrl: 'https://github.com/NoiXdev/s3Manager/releases' } });
expect(res).toEqual({ ok: true, data: { currentVersion: '1.0.0', latestVersion: null, updateAvailable: false, releaseUrl: 'https://github.com/NoiXdev/s3Manager/releases', installer: null } });
});
});
74 changes: 71 additions & 3 deletions src/main/update/checkForUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,73 @@ export const GITHUB_REPO = 'NoiXdev/s3Manager';
const RELEASES_PAGE = `https://github.com/${GITHUB_REPO}/releases`;
const LATEST_RELEASE_API = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`;

export interface ReleaseAsset {
name: string;
downloadUrl: string;
size: number;
}

export interface UpdateInfo {
currentVersion: string;
latestVersion: string | null;
updateAvailable: boolean;
releaseUrl: string;
/** The release asset to download & open for this platform/arch, or null if none matches (e.g. Linux without a matching package, or unknown platform). */
installer: ReleaseAsset | null;
}

interface GithubAsset {
name?: string;
browser_download_url?: string;
size?: number;
}

/** Node arch → substrings that commonly appear in release asset file names for that arch. */
const ARCH_ALIASES: Record<string, string[]> = {
arm64: ['arm64', 'aarch64'],
x64: ['x64', 'x86_64', 'amd64'],
ia32: ['ia32', 'x86', 'i386'],
};

/** File-name suffix that identifies the installer for each platform (electron-forge makers). */
const PLATFORM_EXT: Record<string, string[]> = {
darwin: ['.dmg'],
win32: ['.exe'],
// Prefer .deb, fall back to .rpm — the order here is the preference order.
linux: ['.deb', '.rpm'],
};

/**
* Pick the installer asset matching this platform/arch from a release's assets.
* Prefers an asset whose name also mentions the current arch; otherwise falls
* back to the first asset with the right extension. Pure — no process access.
*/
export function pickInstallerAsset(
assets: ReleaseAsset[],
platform: string,
arch: string,
): ReleaseAsset | null {
const exts = PLATFORM_EXT[platform];
if (!exts) return null;
const archTokens = ARCH_ALIASES[arch] ?? [arch];
for (const ext of exts) {
const matches = assets.filter((a) => a.name.toLowerCase().endsWith(ext));
if (matches.length === 0) continue;
const archMatch = matches.find((a) =>
archTokens.some((tok) => a.name.toLowerCase().includes(tok)),
);
return archMatch ?? matches[0];
}
return null;
}

function parseAssets(assets: GithubAsset[] | undefined): ReleaseAsset[] {
if (!Array.isArray(assets)) return [];
return assets
.filter((a): a is Required<Pick<GithubAsset, 'name' | 'browser_download_url'>> & GithubAsset =>
typeof a?.name === 'string' && typeof a?.browser_download_url === 'string',
)
.map((a) => ({ name: a.name, downloadUrl: a.browser_download_url, size: a.size ?? 0 }));
}

/** Parse "v1.2.3" / "1.2.3-beta.1" into [1,2,3]; ignores a leading v and any -prerelease suffix. */
Expand All @@ -33,9 +95,13 @@ export function compareVersions(a: string, b: string): number {
export async function checkForUpdate({
fetchImpl,
currentVersion,
platform = process.platform,
arch = process.arch,
}: {
fetchImpl: typeof fetch;
currentVersion: string;
platform?: string;
arch?: string;
}): Promise<Result<UpdateInfo>> {
let res: Response;
try {
Expand All @@ -46,14 +112,14 @@ export async function checkForUpdate({
return err('UpdateCheckFailed', (e as Error).message);
}
if (res.status === 404) {
return ok({ currentVersion, latestVersion: null, updateAvailable: false, releaseUrl: RELEASES_PAGE });
return ok({ currentVersion, latestVersion: null, updateAvailable: false, releaseUrl: RELEASES_PAGE, installer: null });
}
if (!res.ok) {
return err('UpdateCheckFailed', `GitHub responded ${res.status}`);
}
let body: { tag_name?: string; html_url?: string };
let body: { tag_name?: string; html_url?: string; assets?: GithubAsset[] };
try {
body = (await res.json()) as { tag_name?: string; html_url?: string };
body = (await res.json()) as { tag_name?: string; html_url?: string; assets?: GithubAsset[] };
} catch (e) {
return err('UpdateCheckFailed', (e as Error).message);
}
Expand All @@ -65,5 +131,7 @@ export async function checkForUpdate({
latestVersion,
updateAvailable,
releaseUrl: updateAvailable ? (body.html_url ?? RELEASES_PAGE) : RELEASES_PAGE,
// Only offer a direct installer for an actual newer release.
installer: updateAvailable ? pickInstallerAsset(parseAssets(body.assets), platform, arch) : null,
});
}
62 changes: 62 additions & 0 deletions src/main/update/downloadInstaller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect, vi } from 'vitest';
import { tmpdir } from 'node:os';
import { readFile, rm, mkdir } from 'node:fs/promises';
import path from 'node:path';
import { Readable } from 'node:stream';
import { isAllowedDownloadHost, downloadInstaller } from './downloadInstaller';

describe('isAllowedDownloadHost', () => {
it('allows github.com and *.githubusercontent.com over https', () => {
expect(isAllowedDownloadHost('https://github.com/o/r/releases/download/v1/app.dmg')).toBe(true);
expect(isAllowedDownloadHost('https://objects.githubusercontent.com/x')).toBe(true);
expect(isAllowedDownloadHost('https://release-assets.githubusercontent.com/x')).toBe(true);
});

it('rejects non-GitHub hosts, http, and lookalikes', () => {
expect(isAllowedDownloadHost('https://evil.com/app.dmg')).toBe(false);
expect(isAllowedDownloadHost('http://github.com/app.dmg')).toBe(false);
expect(isAllowedDownloadHost('https://github.com.evil.com/app.dmg')).toBe(false);
expect(isAllowedDownloadHost('https://notgithubusercontent.com/x')).toBe(false);
expect(isAllowedDownloadHost('not a url')).toBe(false);
});
});

describe('downloadInstaller', () => {
function streamFetch(bytes: string) {
return vi.fn().mockResolvedValue({
ok: true,
status: 200,
body: Readable.toWeb(Readable.from([Buffer.from(bytes)])),
}) as unknown as typeof fetch;
}

it('refuses a non-GitHub host before fetching', async () => {
const fetchImpl = vi.fn() as unknown as typeof fetch;
const res = await downloadInstaller({ url: 'https://evil.com/app.dmg', fileName: 'app.dmg', destDir: tmpdir(), fetchImpl });
expect(res.ok).toBe(false);
expect(fetchImpl).not.toHaveBeenCalled();
});

it('streams an allowed download to destDir and sanitizes the file name', async () => {
const dir = path.join(tmpdir(), `s3m-dl-${process.pid}`);
await mkdir(dir, { recursive: true });
const res = await downloadInstaller({
url: 'https://github.com/o/r/releases/download/v1/app.dmg',
fileName: '../../../etc/app.dmg',
destDir: dir,
fetchImpl: streamFetch('binary-data'),
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.data.path).toBe(path.join(dir, 'app.dmg'));
expect(await readFile(res.data.path, 'utf8')).toBe('binary-data');
await rm(dir, { recursive: true, force: true });
}
});

it('returns an error on a non-OK response', async () => {
const fetchImpl = vi.fn().mockResolvedValue({ ok: false, status: 500, body: null }) as unknown as typeof fetch;
const res = await downloadInstaller({ url: 'https://github.com/o/r/x.dmg', fileName: 'x.dmg', destDir: tmpdir(), fetchImpl });
expect(res.ok).toBe(false);
});
});
Loading
Loading