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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 14 additions & 1 deletion apps/desktop/electron/community-links.cjs
Original file line number Diff line number Diff line change
@@ -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 };
25 changes: 20 additions & 5 deletions apps/desktop/electron/lifecycle/fatal-events.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 };
57 changes: 56 additions & 1 deletion test/community-links.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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));
Expand Down
42 changes: 42 additions & 0 deletions test/fatal-lifecycle.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
}
});
2 changes: 1 addition & 1 deletion test/screenshot-images.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
1 change: 1 addition & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export default defineConfig({
},
server: {
port: 6500,
strictPort: true,
},
test: {
root: fileURLToPath(new URL('./', import.meta.url)),
Expand Down