diff --git a/src/main.ts b/src/main.ts index 518e897..9f39a56 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 = () => { diff --git a/src/main/ipc/channels.ts b/src/main/ipc/channels.ts index 345983b..aaaeadf 100644 --- a/src/main/ipc/channels.ts +++ b/src/main/ipc/channels.ts @@ -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', @@ -151,6 +152,7 @@ export interface ApiMap { [CH.saveTextFile]: { args: [{ defaultName: string; contents: string }]; res: Result<{ saved: boolean }> }; [CH.openTextFile]: { args: []; res: Result }; [CH.checkForUpdate]: { args: []; res: Result }; + [CH.downloadUpdate]: { args: [{ url: string; fileName: string }]; res: Result<{ path: string }> }; [CH.getObjectAcl]: { args: [{ accountId: string; bucket: string; key: string }]; res: Result }; [CH.putObjectAcl]: { args: [{ accountId: string; bucket: string; key: string; acl: ObjectAcl }]; res: Result }; [CH.getEditableMetadata]: { args: [{ accountId: string; bucket: string; key: string }]; res: Result }; diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index f6d4082..7b47327 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -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'; @@ -65,6 +66,10 @@ export interface RegisterDeps { openExternal: (url: string) => Promise; /** 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; /** 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; } @@ -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; + }); } diff --git a/src/main/update/checkForUpdate.test.ts b/src/main/update/checkForUpdate.test.ts index ae2499e..9c0b376 100644 --- a/src/main/update/checkForUpdate.test.ts +++ b/src/main/update/checkForUpdate.test.ts @@ -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 () => { @@ -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 () => { @@ -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 () => { @@ -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 } }); }); }); diff --git a/src/main/update/checkForUpdate.ts b/src/main/update/checkForUpdate.ts index 9e2a5b4..a7f58d0 100644 --- a/src/main/update/checkForUpdate.ts +++ b/src/main/update/checkForUpdate.ts @@ -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 = { + 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 = { + 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> & 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. */ @@ -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> { let res: Response; try { @@ -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); } @@ -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, }); } diff --git a/src/main/update/downloadInstaller.test.ts b/src/main/update/downloadInstaller.test.ts new file mode 100644 index 0000000..8d84021 --- /dev/null +++ b/src/main/update/downloadInstaller.test.ts @@ -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); + }); +}); diff --git a/src/main/update/downloadInstaller.ts b/src/main/update/downloadInstaller.ts new file mode 100644 index 0000000..d574143 --- /dev/null +++ b/src/main/update/downloadInstaller.ts @@ -0,0 +1,68 @@ +import { createWriteStream } from 'node:fs'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import path from 'node:path'; +import { ok, err, type Result } from '../shared/result'; + +/** + * Only GitHub-owned hosts may be downloaded from. `browser_download_url`s live + * on github.com and redirect to *.githubusercontent.com asset storage; fetch + * follows those redirects, so validating the initial URL's host is sufficient. + * This guards against a compromised/spoofed renderer handing us an arbitrary URL. + */ +export function isAllowedDownloadHost(rawUrl: string): boolean { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return false; + } + if (url.protocol !== 'https:') return false; + const host = url.hostname.toLowerCase(); + return host === 'github.com' || host === 'githubusercontent.com' || host.endsWith('.githubusercontent.com'); +} + +/** Strip any path/traversal from an asset name, keeping only a safe base file name. */ +function safeFileName(name: string): string { + const base = path.basename(name).replace(/[/\\]/g, ''); + return base.length > 0 ? base : 'installer'; +} + +/** + * Download a release installer to `destDir` and resolve its absolute path. + * The caller is responsible for opening it (e.g. shell.openPath). The URL host + * is validated against the GitHub allowlist before any request is made. + */ +export async function downloadInstaller({ + url, + fileName, + destDir, + fetchImpl, +}: { + url: string; + fileName: string; + destDir: string; + fetchImpl: typeof fetch; +}): Promise> { + if (!isAllowedDownloadHost(url)) { + return err('UpdateDownloadFailed', 'Refusing to download from a non-GitHub host'); + } + + let res: Response; + try { + res = await fetchImpl(url, { headers: { 'User-Agent': 's3Manager-update-download' } }); + } catch (e) { + return err('UpdateDownloadFailed', (e as Error).message); + } + if (!res.ok || !res.body) { + return err('UpdateDownloadFailed', `Download responded ${res.status}`); + } + + const target = path.join(destDir, safeFileName(fileName)); + try { + await pipeline(Readable.fromWeb(res.body as Parameters[0]), createWriteStream(target)); + } catch (e) { + return err('UpdateDownloadFailed', (e as Error).message); + } + return ok({ path: target }); +} diff --git a/src/preload.ts b/src/preload.ts index dee68c3..005903d 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -54,6 +54,7 @@ const api = { setSettings: (a: ApiMap[typeof CH.setSettings]['args'][0]) => invoke(CH.setSettings, a), getAppInfo: () => invoke(CH.getAppInfo), checkForUpdate: () => invoke(CH.checkForUpdate), + downloadUpdate: (a: ApiMap[typeof CH.downloadUpdate]['args'][0]) => invoke(CH.downloadUpdate, a), getObjectAcl: (a: ApiMap[typeof CH.getObjectAcl]['args'][0]) => invoke(CH.getObjectAcl, a), putObjectAcl: (a: ApiMap[typeof CH.putObjectAcl]['args'][0]) => invoke(CH.putObjectAcl, a), getEditableMetadata: (a: ApiMap[typeof CH.getEditableMetadata]['args'][0]) => invoke(CH.getEditableMetadata, a), diff --git a/src/renderer/components/connections/ConnectionsScreen.tsx b/src/renderer/components/connections/ConnectionsScreen.tsx index f2d5410..a8e0254 100644 --- a/src/renderer/components/connections/ConnectionsScreen.tsx +++ b/src/renderer/components/connections/ConnectionsScreen.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { FiTrash2, FiEdit2, FiUpload } from 'react-icons/fi'; +import { FiTrash2, FiEdit2, FiUpload, FiDownload } from 'react-icons/fi'; import { useAccounts, useCreateAccount, useUpdateAccount, useRemoveAccount } from '../../hooks/useAccounts'; import { ProviderBadge } from '../accounts/ProviderBadge'; import { AccountForm } from '../accounts/AccountForm'; @@ -30,17 +30,19 @@ export function ConnectionsScreen({ onAccountRemoved }: { onAccountRemoved?: (id
- +
+ {t('settings.updateAvailable', { version: check.data.latestVersion })} +
+ {check.data.installer && ( + + )} + +
+ {download.isError && ( + {t('settings.updateInstallFailed')} + )} + {download.isSuccess && ( + {t('settings.updateInstallerOpened')} + )} +
)}