diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bcb99169..a0979c1de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -168,6 +168,8 @@ User-facing changes to Beam are documented in this file. ### Fixed +- Restored the GitHub star count in the community panel and protected shutdown against late events from destroyed windows. + - Video export reuses the last successfully used destination across projects and application restarts instead of always returning to the OS Videos folder. - Screenshot project thumbnails now show the edited composition, including HTML layers, gradients and text, and refresh automatically in the background after edits. - Moving a selected Screenshot group member changes only that layer; selecting the group header still moves all members together. diff --git a/apps/desktop/electron/community-links.cjs b/apps/desktop/electron/community-links.cjs index dd6bba367..d46ee8cf5 100644 --- a/apps/desktop/electron/community-links.cjs +++ b/apps/desktop/electron/community-links.cjs @@ -1,6 +1,19 @@ -function registerCommunityLinks({ ipcMain, shell }) { +function registerCommunityLinks({ ipcMain, shell, fetchImpl = globalThis.fetch }) { ipcMain.handle('community:open-discord', () => shell.openExternal('https://discord.gg/PcKC8AbaaA')); ipcMain.handle('community:open-github', () => shell.openExternal('https://github.com/BeamRecorder/Beam')); + ipcMain.handle('community:get-github-stars', async () => { + try { + const response = await fetchImpl('https://api.github.com/repos/BeamRecorder/Beam', { + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) return { stars: 0 }; + const data = await response.json(); + const stars = data?.stargazers_count; + return { stars: Number.isSafeInteger(stars) && stars >= 0 ? stars : 0 }; + } catch { + return { stars: 0 }; + } + }); } module.exports = { registerCommunityLinks }; diff --git a/apps/desktop/electron/lifecycle/fatal-events.cjs b/apps/desktop/electron/lifecycle/fatal-events.cjs index 0c1b4c78a..01a6834e0 100644 --- a/apps/desktop/electron/lifecycle/fatal-events.cjs +++ b/apps/desktop/electron/lifecycle/fatal-events.cjs @@ -14,14 +14,21 @@ function registerFatalLifecycle({ app, powerMonitor, coordinator, log = () => {} processTarget.on('uncaughtException', (error) => exitAfterShutdown('fatal', `Uncaught exception: ${error?.stack || error}`), ); - processTarget.on('unhandledRejection', (reason) => exitAfterShutdown('fatal', `Unhandled rejection: ${reason}`)); + processTarget.on('unhandledRejection', (reason) => { + if (!coordinator.canAcceptWork() && String(reason?.message || reason).includes('Object has been destroyed')) { + log(`Ignored destroyed object rejection: ${reason}`); + return; + } + exitAfterShutdown('fatal', `Unhandled rejection: ${reason}`); + }); powerMonitor.on('shutdown', requestQuit); - app.on('render-process-gone', (_event, contents, details) => + app.on('render-process-gone', (_event, contents, details) => { + if (!coordinator.canAcceptWork()) return; exitAfterShutdown( 'renderer-crash', - `renderer ${contents?.id ?? 'unknown'} exited (${details.reason}, code=${details.exitCode})`, - ), - ); + `renderer ${rendererId(contents)} exited (${details.reason}, code=${details.exitCode})`, + ); + }); app.on('child-process-gone', (_event, details) => { if (details.reason === 'clean-exit') return false; return exitAfterShutdown( @@ -31,4 +38,12 @@ function registerFatalLifecycle({ app, powerMonitor, coordinator, log = () => {} }); } +function rendererId(contents) { + try { + return contents?.isDestroyed?.() ? 'unknown' : (contents?.id ?? 'unknown'); + } catch { + return 'unknown'; + } +} + module.exports = { registerFatalLifecycle }; diff --git a/test/community-links.test.cjs b/test/community-links.test.cjs index 9e481bc02..8e35ca520 100644 --- a/test/community-links.test.cjs +++ b/test/community-links.test.cjs @@ -2,11 +2,12 @@ const assert = require('node:assert/strict'); const test = require('node:test'); const { registerCommunityLinks } = require('../apps/desktop/electron/community-links.cjs'); -function setup(openExternal) { +function setup(openExternal, fetchImpl) { const handlers = new Map(); registerCommunityLinks({ ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, shell: { openExternal }, + fetchImpl, }); return handlers; } @@ -18,6 +19,60 @@ test('Discord opens the current invitation, ignoring renderer-supplied URLs', as assert.deepEqual(calls, ['https://discord.gg/PcKC8AbaaA']); }); +test('star count uses the fixed GitHub API with a bounded request', async () => { + const requests = []; + const handlers = setup( + async () => {}, + async (...args) => { + requests.push(args); + return { ok: true, json: async () => ({ stargazers_count: 123 }) }; + }, + ); + assert.deepEqual(await handlers.get('community:get-github-stars')({}, 'https://untrusted'), { stars: 123 }); + assert.equal(requests[0][0], 'https://api.github.com/repos/BeamRecorder/Beam'); + assert.ok(requests[0][1].signal instanceof AbortSignal); +}); + +test('star count safely handles failed requests and malformed responses', async () => { + for (const fetchImpl of [ + async () => { + throw new Error('Network unavailable'); + }, + async () => ({ + ok: false, + json: async () => { + throw new Error('must not parse'); + }, + }), + async () => ({ + ok: true, + json: async () => { + throw new Error('Invalid JSON'); + }, + }), + ]) { + const handlers = setup(async () => {}, fetchImpl); + assert.deepEqual(await handlers.get('community:get-github-stars')(), { stars: 0 }); + } +}); + +test('star count only accepts nonnegative safe integers', async () => { + for (const value of [ + null, + {}, + { stargazers_count: '12' }, + { stargazers_count: -1 }, + { stargazers_count: 1.5 }, + { stargazers_count: Infinity }, + ]) { + const handlers = setup( + async () => {}, + async () => ({ ok: true, json: async () => value }), + ); + assert.deepEqual(await handlers.get('community:get-github-stars')(), { stars: 0 }); + } +}); + test('GitHub opens the Beam repository', async () => { const calls = []; const handlers = setup(async (url) => calls.push(url)); diff --git a/test/fatal-lifecycle.test.cjs b/test/fatal-lifecycle.test.cjs index 8062c5649..c7c7025f3 100644 --- a/test/fatal-lifecycle.test.cjs +++ b/test/fatal-lifecycle.test.cjs @@ -45,3 +45,45 @@ test('OS shutdown and signals request normal quit while clean Electron children context.processTarget.emit('SIGTERM'); assert.deepEqual(context.requests, ['quit', 'quit']); }); + +test('destroyed-object rejections remain fatal while the application is running', async () => { + const context = setup(); + context.processTarget.emit('unhandledRejection', new Error('Object has been destroyed')); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(context.requests, ['fatal', 'exit:1']); +}); + +test('late destroyed-object and renderer notifications are harmless during teardown', async () => { + const context = setup(); + context.processTarget.emit('uncaughtException', new Error('first failure')); + context.processTarget.emit('unhandledRejection', new Error('Object has been destroyed')); + context.app.emit( + 'render-process-gone', + {}, + { + get id() { + throw new Error('destroyed'); + }, + }, + {}, + ); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(context.requests, ['fatal', 'exit:1']); +}); + +test('a destroyed renderer cannot prevent an active crash from being handled', async () => { + for (const contents of [ + undefined, + { isDestroyed: () => true }, + { + get id() { + throw new Error('Object has been destroyed'); + }, + }, + ]) { + const context = setup(); + context.app.emit('render-process-gone', {}, contents, { reason: 'crashed', exitCode: 9 }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(context.requests, ['renderer-crash', 'exit:1']); + } +}); diff --git a/test/screenshot-images.test.cjs b/test/screenshot-images.test.cjs index e028353c6..d4741fb9e 100644 --- a/test/screenshot-images.test.cjs +++ b/test/screenshot-images.test.cjs @@ -248,7 +248,7 @@ test('does not resolve symlinked screenshot media directories or files', (t) => fs.symlinkSync(outside, mediaDirectory, 'dir'); const url = `project-media://screenshot/${fx.document.id}/media/${fileName}`; assert.equal(fx.store.fileForUrl(url), null); - fs.rmSync(mediaDirectory, { force: true }); + fs.rmSync(mediaDirectory, { recursive: true, force: true }); fs.mkdirSync(mediaDirectory); fs.symlinkSync(externalFile, path.join(mediaDirectory, fileName), 'file'); diff --git a/vite.config.ts b/vite.config.ts index ca76b5f50..825610fbf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -102,6 +102,7 @@ export default defineConfig({ }, server: { port: 6500, + strictPort: true, }, test: { root: fileURLToPath(new URL('./', import.meta.url)),