feat(overlay): add in-app changelog modal (#187)

This commit is contained in:
2026-08-05 22:19:13 -07:00
committed by GitHub
parent a0dde4ee3e
commit 441ecf3c04
55 changed files with 3686 additions and 242 deletions
@@ -112,3 +112,101 @@ test('createCurlFetch requests updater metadata without Electron networking', as
assert.equal(calls[0]?.options.encoding, 'buffer');
assert.equal(calls[0]?.options.timeout, 65_000);
});
test('curl fetch kills the child process when the caller aborts', async () => {
let killed: string | undefined;
let settle: ((error: Error | null, stdout: Buffer, stderr: Buffer) => void) | null = null;
const controller = new AbortController();
const curlFetch = createCurlFetch({
execFile: ((
_file: string,
_args: readonly string[],
_options: unknown,
callback: (error: Error | null, stdout: Buffer, stderr: Buffer) => void,
) => {
settle = callback;
return {
kill: (signal?: string) => {
killed = signal;
return true;
},
};
}) as never,
});
const pending = curlFetch('https://example.test/slow', { signal: controller.signal });
controller.abort(new Error('deadline reached'));
await assert.rejects(pending, /deadline reached/);
assert.equal(killed, 'SIGKILL', 'the stalled curl process is terminated');
assert.ok(settle, 'the callback is still held by the stub');
});
test('curl fetch preserves a non-Error abort reason', async () => {
const controller = new AbortController();
const curlFetch = createCurlFetch({
execFile: (() => ({ kill: () => true })) as never,
});
const pending = curlFetch('https://example.test/slow', { signal: controller.signal });
controller.abort('plain string reason');
await pending.then(
() => assert.fail('expected the aborted request to reject'),
(error) => assert.equal(error, 'plain string reason'),
);
});
test('curl fetch synthesises an error when the signal carries no reason', async () => {
const controller = new AbortController();
const curlFetch = createCurlFetch({
execFile: (() => ({ kill: () => true })) as never,
});
const pending = curlFetch('https://example.test/slow', { signal: controller.signal });
controller.abort();
// A bare abort() still supplies a DOMException reason, so that is what surfaces.
await pending.then(
() => assert.fail('expected the aborted request to reject'),
(error) => assert.ok(error instanceof Error),
);
});
test('curl fetch rejects immediately when the signal is already aborted', async () => {
let spawned = 0;
const controller = new AbortController();
controller.abort(new Error('already gone'));
const curlFetch = createCurlFetch({
execFile: (() => {
spawned += 1;
return { kill: () => true };
}) as never,
});
await assert.rejects(
curlFetch('https://example.test/x', { signal: controller.signal }),
/already gone/,
);
assert.equal(spawned, 0, 'no curl process is started for an aborted request');
});
test('curl fetch still resolves normally when no signal is supplied', async () => {
const curlFetch = createCurlFetch({
execFile: ((
_file: string,
_args: readonly string[],
_options: unknown,
callback: (error: Error | null, stdout: Buffer, stderr: Buffer) => void,
) => {
callback(null, Buffer.from('{"ok":true}'), Buffer.alloc(0));
return { kill: () => true };
}) as never,
});
const response = await curlFetch('https://example.test/x');
assert.equal(response.ok, true);
assert.deepEqual(await response.json(), { ok: true });
});
+28 -3
View File
@@ -79,8 +79,24 @@ export function createCurlFetch(options: CurlFetchOptions = {}): FetchLike {
];
addHeaderArgs(args, init.headers);
args.push(url);
// curl has its own --max-time, but a caller-supplied signal must be able to
// cut the request short of it; otherwise a shorter caller deadline is a lie.
const signal = init.signal instanceof AbortSignal ? init.signal : undefined;
// Mirror fetch: reject with the caller's reason whatever its type, and only
// synthesise an error when the signal carries none.
const abortReason = (): unknown =>
signal?.reason !== undefined ? signal.reason : new Error('curl request aborted');
if (signal?.aborted) throw abortReason();
const body = await new Promise<Buffer>((resolve, reject) => {
execFile(
let onAbort: (() => void) | null = null;
const settle = (run: () => void) => {
if (onAbort) signal?.removeEventListener('abort', onAbort);
onAbort = null;
run();
};
const child = execFile(
curlPath,
args,
{
@@ -93,12 +109,21 @@ export function createCurlFetch(options: CurlFetchOptions = {}): FetchLike {
const stderrMessage = Buffer.isBuffer(stderr) ? stderr.toString('utf8') : stderr;
const errno = (error as NodeJS.ErrnoException).code;
const fallback = errno ? `curl failed (${errno})` : 'curl failed';
reject(new Error(stderrMessage.trim() || fallback));
settle(() => reject(new Error(stderrMessage.trim() || fallback)));
return;
}
resolve(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout));
settle(() => resolve(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout)));
},
);
if (signal) {
onAbort = () => {
onAbort = null;
child.kill('SIGKILL');
reject(abortReason());
};
signal.addEventListener('abort', onAbort, { once: true });
}
});
return {
ok: true,
@@ -51,6 +51,13 @@ test('compareSemverLike orders prerelease identifiers within the same base versi
assert.equal(compareSemverLike('0.15.0', '0.15.0-rc.1') > 0, true);
});
test('compareSemverLike ignores build metadata, which carries no precedence', () => {
assert.equal(compareSemverLike('0.15.0+build.2', '0.15.0+build.1'), 0);
assert.equal(compareSemverLike('0.15.0-rc.1+build.2', '0.15.0-rc.1+build.1'), 0);
assert.equal(compareSemverLike('0.15.1+build.1', '0.15.0+build.9') > 0, true);
assert.equal(compareSemverLike('0.15.0+build.1', '0.15.0-rc.1') > 0, true);
});
test('findReleaseAsset finds exact asset names only', () => {
const release = {
tag_name: 'v0.14.1',
+3 -54
View File
@@ -1,4 +1,7 @@
import type { UpdateChannel } from '../../../types/config';
import { compareSemverLike } from '../../../core/utils/semver-compare';
export { compareSemverLike };
export interface GitHubReleaseAsset {
name: string;
@@ -130,57 +133,3 @@ export function parseReleaseVersion(
if (!release) return null;
return release.tag_name.replace(/^v/i, '');
}
export function compareSemverLike(a: string, b: string): number {
const parse = (
value: string,
): {
core: number[];
prerelease: Array<number | string>;
} => {
const normalized = value.replace(/^v/i, '');
const [coreText = '', ...prereleaseParts] = normalized.split('-');
const core = coreText
.split('.')
.slice(0, 3)
.map((part) => Number.parseInt(part, 10) || 0);
while (core.length < 3) core.push(0);
const prereleaseText = prereleaseParts.join('-');
return {
core,
prerelease: prereleaseText
? prereleaseText.split('.').map((part) => {
const numeric = Number.parseInt(part, 10);
return /^\d+$/.test(part) ? numeric : part;
})
: [],
};
};
const left = parse(a);
const right = parse(b);
for (let i = 0; i < 3; i += 1) {
const diff = (left.core[i] ?? 0) - (right.core[i] ?? 0);
if (diff !== 0) return diff;
}
if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
if (left.prerelease.length === 0) return 1;
if (right.prerelease.length === 0) return -1;
const length = Math.max(left.prerelease.length, right.prerelease.length);
for (let i = 0; i < length; i += 1) {
const leftPart = left.prerelease[i];
const rightPart = right.prerelease[i];
if (leftPart === undefined && rightPart === undefined) return 0;
if (leftPart === undefined) return -1;
if (rightPart === undefined) return 1;
if (leftPart === rightPart) continue;
if (typeof leftPart === 'number' && typeof rightPart === 'number') {
return leftPart - rightPart;
}
if (typeof leftPart === 'number') return -1;
if (typeof rightPart === 'number') return 1;
return leftPart > rightPart ? 1 : -1;
}
return 0;
}
@@ -36,7 +36,7 @@ test('notifyUpdateAvailable routes notification surfaces from config', async ()
]);
});
test('notifyUpdateAvailable adds an install action to overlay update notifications', async () => {
test('notifyUpdateAvailable adds install and changelog actions to overlay update notifications', async () => {
const payloads: OverlayNotificationPayload[] = [];
await notifyUpdateAvailable(
@@ -53,7 +53,10 @@ test('notifyUpdateAvailable adds an install action to overlay update notificatio
const payload = payloads[0];
assert.ok(payload);
assert.deepEqual(payload.actions, [{ id: 'install-update', label: 'Update' }]);
assert.deepEqual(payload.actions, [
{ id: 'install-update', label: 'Update' },
{ id: 'view-changelog', label: "What's New", keepOpen: true },
]);
assert.equal(payload.id, 'subminer-update-available');
assert.equal(payload.persistent, true);
});
@@ -3,6 +3,7 @@ import type { OverlayNotificationPayload } from '../../../types/notification';
export const UPDATE_AVAILABLE_NOTIFICATION_ID = 'subminer-update-available';
export const INSTALL_UPDATE_ACTION_ID = 'install-update';
export const VIEW_CHANGELOG_ACTION_ID = 'view-changelog';
export interface UpdateNotificationDeps {
showSystemNotification: (title: string, body: string) => void;
@@ -25,7 +26,10 @@ export async function notifyUpdateAvailable(
body: message,
variant: 'info',
persistent: true,
actions: [{ id: INSTALL_UPDATE_ACTION_ID, label: 'Update' }],
actions: [
{ id: INSTALL_UPDATE_ACTION_ID, label: 'Update' },
{ id: VIEW_CHANGELOG_ACTION_ID, label: "What's New", keepOpen: true },
],
});
}
if (options.notificationType === 'osd' || options.notificationType === 'osd-system') {