diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 497da50..7b614c5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -89,6 +89,9 @@ jobs: if: matrix.os == 'windows-latest' run: pnpm run build:app && pnpm exec electron-builder --win --publish=never + - name: Verify packaged runtime dependencies + run: pnpm run verify:package-runtime + - name: Cleanup Mac artifacts if: matrix.os == 'macos-latest' run: | diff --git a/package.json b/package.json index e377f5d..44edfef 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "version:plugins:beta": "node scripts/bump-plugins.mjs prerelease --preid=beta", "version:plugins:rc": "node scripts/bump-plugins.mjs prerelease --preid=rc", "version:plugins:release": "node scripts/bump-plugins.mjs patch --git-commit --git-tag", + "verify:package-runtime": "node scripts/verify-packaged-runtime-deps.mjs", "publish:plugins": "node scripts/publish-plugins.mjs --tag=latest", "publish:plugins:dry": "node scripts/publish-plugins.mjs --tag=latest --dry-run", "publish:plugins:next": "node scripts/publish-plugins.mjs --tag=next", @@ -88,13 +89,34 @@ "@giopic/s3-plugin": "workspace:^", "@vueuse/core": "^14.2.1", "animate.css": "^4.1.1", + "asynckit": "^0.4.0", "axios": "^1.15.2", + "call-bind-apply-helpers": "^1.0.2", + "combined-stream": "^1.0.8", "dayjs": "^1.11.20", + "delayed-stream": "^1.0.0", + "dunder-proto": "^1.0.1", "electron-better-ipc": "^2.0.1", "electron-log": "^5.4.3", "electron-store": "^11.0.2", "electron-updater": "^6.8.3", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0", "mime": "^4.1.0", + "mime-db": "^1.52.0", + "mime-types": "^2.1.35", "p-limit": "^7.3.0", "pinia": "^3.0.4", "pinia-plugin-persistedstate-2": "^2.0.32", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c4917f..c25658c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,12 +32,27 @@ importers: animate.css: specifier: ^4.1.1 version: 4.1.1 + asynckit: + specifier: ^0.4.0 + version: 0.4.0 axios: specifier: ^1.15.2 version: 1.15.2 + call-bind-apply-helpers: + specifier: ^1.0.2 + version: 1.0.2 + combined-stream: + specifier: ^1.0.8 + version: 1.0.8 dayjs: specifier: ^1.11.20 version: 1.11.20 + delayed-stream: + specifier: ^1.0.0 + version: 1.0.0 + dunder-proto: + specifier: ^1.0.1 + version: 1.0.1 electron-better-ipc: specifier: ^2.0.1 version: 2.0.1 @@ -50,9 +65,57 @@ importers: electron-updater: specifier: ^6.8.3 version: 6.8.3 + es-define-property: + specifier: ^1.0.1 + version: 1.0.1 + es-errors: + specifier: ^1.3.0 + version: 1.3.0 + es-object-atoms: + specifier: ^1.1.1 + version: 1.1.1 + es-set-tostringtag: + specifier: ^2.1.0 + version: 2.1.0 + follow-redirects: + specifier: ^1.16.0 + version: 1.16.0 + form-data: + specifier: ^4.0.5 + version: 4.0.5 + function-bind: + specifier: ^1.1.2 + version: 1.1.2 + get-intrinsic: + specifier: ^1.3.0 + version: 1.3.0 + get-proto: + specifier: ^1.0.1 + version: 1.0.1 + gopd: + specifier: ^1.2.0 + version: 1.2.0 + has-symbols: + specifier: ^1.1.0 + version: 1.1.0 + has-tostringtag: + specifier: ^1.0.2 + version: 1.0.2 + hasown: + specifier: ^2.0.2 + version: 2.0.2 + math-intrinsics: + specifier: ^1.1.0 + version: 1.1.0 mime: specifier: ^4.1.0 version: 4.1.0 + mime-db: + specifier: ^1.52.0 + version: 1.52.0 + mime-types: + specifier: ^2.1.35 + version: 2.1.35 p-limit: specifier: ^7.3.0 version: 7.3.0 diff --git a/scripts/verify-packaged-runtime-deps.mjs b/scripts/verify-packaged-runtime-deps.mjs new file mode 100644 index 0000000..1959b29 --- /dev/null +++ b/scripts/verify-packaged-runtime-deps.mjs @@ -0,0 +1,110 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const releaseDir = path.join(rootDir, 'release') + +const requiredPackages = [ + 'axios', + 'follow-redirects', + 'form-data', + 'proxy-from-env', + 'asynckit', + 'combined-stream', + 'delayed-stream', + 'es-set-tostringtag', + 'hasown', + 'mime-types', + 'mime-db', + 'es-errors', + 'get-intrinsic', + 'has-tostringtag', + 'call-bind-apply-helpers', + 'es-define-property', + 'es-object-atoms', + 'function-bind', + 'get-proto', + 'gopd', + 'has-symbols', + 'math-intrinsics', + 'dunder-proto', +] + +const importChecks = [ + 'node_modules/axios/lib/adapters/http.js', + 'node_modules/form-data/lib/form_data.js', +] + +function findAppAsars(dir, results = []) { + if (!existsSync(dir)) + return results + + for (const entry of readdirSync(dir)) { + const fullPath = path.join(dir, entry) + const stats = statSync(fullPath) + if (stats.isDirectory()) { + findAppAsars(fullPath, results) + } + else if (entry === 'app.asar') { + results.push(fullPath) + } + } + + return results +} + +function packageJsonPath(extractedDir, packageName) { + return path.join(extractedDir, 'node_modules', ...packageName.split('/'), 'package.json') +} + +function extractAsar(asarPath, destination) { + const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' + const result = spawnSync(pnpm, ['exec', 'asar', 'extract', asarPath, destination], { + cwd: rootDir, + stdio: 'inherit', + }) + + if (result.status !== 0) { + throw new Error(`Failed to extract ${path.relative(rootDir, asarPath)}`) + } +} + +async function verifyAsar(asarPath) { + const tmpRoot = mkdtempSync(path.join(tmpdir(), 'giopic-app-asar-')) + const extractedDir = path.join(tmpRoot, 'app') + + try { + extractAsar(asarPath, extractedDir) + + const missing = requiredPackages.filter(packageName => !existsSync(packageJsonPath(extractedDir, packageName))) + if (missing.length > 0) { + throw new Error(`Missing runtime packages in app.asar: ${missing.join(', ')}`) + } + + for (const target of importChecks) { + const targetPath = path.join(extractedDir, ...target.split('/')) + if (!existsSync(targetPath)) { + throw new Error(`Missing import check target in app.asar: ${target}`) + } + await import(pathToFileURL(targetPath).href) + } + + console.log(`Verified packaged runtime dependencies: ${path.relative(rootDir, asarPath)}`) + } + finally { + rmSync(tmpRoot, { recursive: true, force: true }) + } +} + +const asarPaths = findAppAsars(releaseDir) + +if (asarPaths.length === 0) { + throw new Error('No app.asar files found under release/. Run electron-builder before this verification.') +} + +for (const asarPath of asarPaths) { + await verifyAsar(asarPath) +} diff --git a/src/main/services/AppUpdater.test.ts b/src/main/services/AppUpdater.test.ts index 309d61c..8fea172 100644 --- a/src/main/services/AppUpdater.test.ts +++ b/src/main/services/AppUpdater.test.ts @@ -102,6 +102,33 @@ describe('appUpdater', () => { expect(pkg.default.autoUpdater.checkForUpdates).toHaveBeenCalled() }) + it('should catch startup update check errors', async () => { + const pkg = await import('electron-updater') + const { getStore } = await import('@/main/stores') + const logger = await import('@/main/utils/logger') + + let readyToShow: (() => Promise) | undefined + mockWindow.once = vi.fn((_event, callback) => { + readyToShow = callback as () => Promise + }) as any + + vi.mocked(getStore).mockImplementation((key: string) => { + if (key === 'updateSource') + return 'github' + if (key === 'autoUpdate') + return true + return false + }) + vi.mocked(pkg.default.autoUpdater.checkForUpdates).mockRejectedValue(new Error('missing update config')) + + const { AppUpdater } = await import('@/main/services/AppUpdater') + + const _updater = new AppUpdater(mockWindow) + await expect(readyToShow?.()).resolves.toBeUndefined() + + expect(logger.default.error).toHaveBeenCalledWith('[update] Error checking for updates:', expect.any(Error)) + }) + it('should setup update server with cn source', async () => { const pkg = await import('electron-updater') const { getStore } = await import('@/main/stores') diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index 34472e6..0750ae9 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -126,8 +126,7 @@ export class AppUpdater { const updateOnThisStart = getStore('updateAtNext') this.win.once('ready-to-show', async () => { if (getStore('autoUpdate') || updateOnThisStart) { - await autoUpdater.checkForUpdates() - + await this.checkForUpdates() this.silentUpdateCheck = false } })