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
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readBundledChangelog, resolveBundledChangelogPath } from './bundled-changelog';
test('bundled changelog path prefers the packaged resources copy', () => {
const resolved = resolveBundledChangelogPath({
resourcesPath: '/res',
appPath: '/app',
dirname: '/app/dist/main',
joinPath: (...parts) => parts.join('/'),
fileExists: (candidate) => candidate === '/res/CHANGELOG.md',
});
assert.equal(resolved, '/res/CHANGELOG.md');
});
test('bundled changelog path falls back to the repo root during development', () => {
const resolved = resolveBundledChangelogPath({
resourcesPath: '/res',
appPath: '/app',
dirname: '/repo/dist/main',
joinPath: (...parts) => parts.join('/'),
fileExists: (candidate) => candidate === '/repo/dist/main/../../CHANGELOG.md',
});
assert.equal(resolved, '/repo/dist/main/../../CHANGELOG.md');
});
test('bundled changelog returns null when no copy is installed', () => {
const result = readBundledChangelog({
resolvePath: () => null,
readFile: () => {
throw new Error('should not read');
},
logWarn: () => {},
});
assert.equal(result, null);
});
test('bundled changelog logs and returns null when the file cannot be read', () => {
const warnings: string[] = [];
const result = readBundledChangelog({
resolvePath: () => '/res/CHANGELOG.md',
readFile: () => {
throw new Error('EACCES');
},
logWarn: (message) => warnings.push(message),
});
assert.equal(result, null);
assert.equal(warnings.length, 1);
assert.match(warnings[0] ?? '', /EACCES/);
});
@@ -0,0 +1,35 @@
export function resolveBundledChangelogPath(deps: {
resourcesPath: string;
appPath: string;
dirname: string;
joinPath: (...parts: string[]) => string;
fileExists: (path: string) => boolean;
}): string | null {
const candidates = [
deps.joinPath(deps.resourcesPath, 'CHANGELOG.md'),
deps.joinPath(deps.appPath, 'CHANGELOG.md'),
deps.joinPath(deps.dirname, '..', 'CHANGELOG.md'),
deps.joinPath(deps.dirname, '..', '..', 'CHANGELOG.md'),
];
return candidates.find((candidate) => deps.fileExists(candidate)) ?? null;
}
export function readBundledChangelog(deps: {
resolvePath: () => string | null;
readFile: (path: string) => string;
logWarn: (message: string) => void;
}): string | null {
const changelogPath = deps.resolvePath();
if (!changelogPath) return null;
try {
return deps.readFile(changelogPath);
} catch (error) {
deps.logWarn(
`Failed to read bundled changelog at ${changelogPath}: ${
error instanceof Error ? error.message : String(error)
}`,
);
return null;
}
}
@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
CHANGELOG_REQUEST_TIMEOUT_MS,
createChangelogRuntime,
withRequestTimeout,
} from './changelog-runtime';
import type { FetchLike, FetchResponseLike } from '../update/release-assets';
import { createCurlFetch } from '../update/fetch-adapter';
function okResponse(body: string): FetchResponseLike {
return {
ok: true,
status: 200,
json: async () => JSON.parse(body),
text: async () => body,
arrayBuffer: async () => new ArrayBuffer(0),
};
}
test('request timeout wrapper attaches an abort signal to every request', async () => {
const seen: Array<{ url: string; init?: Record<string, unknown> }> = [];
const wrapped = withRequestTimeout(async (url, init) => {
seen.push({ url, init });
return okResponse('body');
}, 1_234);
await wrapped('https://example.test/a');
await wrapped('https://example.test/b', { headers: { 'User-Agent': 'SubMiner' } });
assert.equal(seen.length, 2);
for (const request of seen) {
assert.ok(request.init?.signal instanceof AbortSignal, 'each request carries a signal');
assert.equal((request.init?.signal as AbortSignal).aborted, false);
}
// Existing init is preserved rather than replaced.
assert.deepEqual(seen[1]?.init?.headers, { 'User-Agent': 'SubMiner' });
});
test('request timeout wrapper aborts a request that never settles', async () => {
let observed: AbortSignal | undefined;
const wrapped = withRequestTimeout((_url, init) => {
observed = init?.signal as AbortSignal;
return new Promise<FetchResponseLike>(() => {
// Never resolves, standing in for a stalled connection.
});
}, 10);
void wrapped('https://example.test/stalled');
await new Promise((resolve) => setTimeout(resolve, 40));
assert.equal(observed?.aborted, true);
});
test('changelog runtime times out both requests instead of hanging the modal', async () => {
const signals: AbortSignal[] = [];
// Stays pending until the deadline fires, standing in for a stalled server.
const stalling: FetchLike = (_url, init) => {
const signal = init?.signal as AbortSignal;
signals.push(signal);
return new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
};
const runtime = createChangelogRuntime({
getInstalledVersion: () => '0.19.2',
getUpdateChannel: () => 'stable',
resourcesPath: '/res',
appPath: '/app',
dirname: '/app/dist/main',
joinPath: (...parts) => parts.join('/'),
fileExists: () => false,
readFile: () => '',
logWarn: () => {},
createFetch: () => withRequestTimeout(stalling, 10),
});
const snapshot = await runtime.getChangelogSnapshot();
// Release lookup and changelog download both hit the deadline rather than hang.
assert.equal(signals.length, 2);
assert.ok(
signals.every((signal) => signal.aborted),
'every stalled request was aborted',
);
// No bundled copy is readable here, so the timeout surfaces as an error state
// that does not claim a bundled changelog is on screen.
assert.match(snapshot.error ?? '', /^Changelog unavailable: /);
assert.match(snapshot.error ?? '', /timed out|timeout|abort/i);
assert.deepEqual(snapshot.entries, []);
});
test('changelog timeout reaches the curl transport, not just global fetch', async () => {
// The POSIX transport is curl, whose own --max-time is 60s; the changelog
// deadline is shorter, so the signal has to actually terminate the process.
let killed: string | undefined;
const curlFetch = createCurlFetch({
execFile: ((
_file: string,
_args: readonly string[],
_options: unknown,
_callback: unknown,
) => ({
kill: (signal?: string) => {
killed = signal;
return true;
},
})) as never,
});
const wrapped = withRequestTimeout(curlFetch, 10);
await assert.rejects(wrapped('https://example.test/stalled'));
assert.equal(killed, 'SIGKILL', 'the stalled curl process is killed at the changelog deadline');
});
test('changelog request timeout is finite', () => {
assert.ok(Number.isFinite(CHANGELOG_REQUEST_TIMEOUT_MS));
assert.ok(CHANGELOG_REQUEST_TIMEOUT_MS > 0);
});
@@ -0,0 +1,85 @@
import type { ChangelogSnapshot } from '../../../types/changelog';
import type { UpdateChannel } from '../../../types/config';
import { createCurlFetch, createGlobalFetch } from '../update/fetch-adapter';
import { fetchLatestStableRelease, type FetchLike } from '../update/release-assets';
import { readBundledChangelog, resolveBundledChangelogPath } from './bundled-changelog';
import { createChangelogSource } from './changelog-source';
export interface ChangelogRuntimeDeps {
getInstalledVersion: () => string;
getUpdateChannel: () => UpdateChannel;
resourcesPath: string;
appPath: string;
dirname: string;
joinPath: (...parts: string[]) => string;
fileExists: (path: string) => boolean;
readFile: (path: string) => string;
logWarn: (message: string) => void;
/** Injected in tests; production picks curl on POSIX and global fetch on Windows. */
createFetch?: () => FetchLike;
}
/**
* curl enforces its own `--max-time`, but the global-fetch transport has no
* deadline: without this a stalled connection leaves the modal on "Loading
* changelog..." with no way back except closing it.
*/
export const CHANGELOG_REQUEST_TIMEOUT_MS = 30_000;
export function withRequestTimeout(fetchImpl: FetchLike, timeoutMs: number): FetchLike {
return (url, init) => {
if (typeof AbortSignal?.timeout !== 'function') return fetchImpl(url, init);
return fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
};
}
export function createChangelogRuntime(deps: ChangelogRuntimeDeps): {
getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
} {
// curl matches the updater's transport choice: Electron's global fetch is
// unreliable for GitHub on some Linux builds.
const fetchImpl = withRequestTimeout(
deps.createFetch?.() ??
(process.platform === 'win32' ? createGlobalFetch() : createCurlFetch()),
CHANGELOG_REQUEST_TIMEOUT_MS,
);
const source = createChangelogSource({
fetchLatestReleaseTag: async () => {
const release = await fetchLatestStableRelease({
fetch: fetchImpl,
channel: deps.getUpdateChannel(),
});
return release?.tag_name ?? null;
},
fetchText: async (url) => {
const response = await fetchImpl(url, {
headers: { 'User-Agent': 'SubMiner changelog' },
});
if (!response.ok) {
throw new Error(`Changelog request failed with ${response.status}`);
}
return await response.text();
},
readBundledChangelog: () =>
readBundledChangelog({
resolvePath: () =>
resolveBundledChangelogPath({
resourcesPath: deps.resourcesPath,
appPath: deps.appPath,
dirname: deps.dirname,
joinPath: deps.joinPath,
fileExists: deps.fileExists,
}),
readFile: deps.readFile,
logWarn: deps.logWarn,
}),
getInstalledVersion: deps.getInstalledVersion,
now: () => Date.now(),
logWarn: deps.logWarn,
});
return {
getChangelogSnapshot: (options?: { refresh?: boolean }) => source.getSnapshot(options),
};
}
@@ -0,0 +1,45 @@
import type { ChangelogSnapshot, ChangelogSourceKind } from '../../../types/changelog';
import { parseChangelog } from '../../../core/utils/changelog-parse';
import { compareSemverLike } from '../update/release-assets';
export function buildChangelogSnapshot(
markdown: string,
options: {
installedVersion: string;
source: ChangelogSourceKind;
releaseTag?: string;
warning?: string;
},
): ChangelogSnapshot {
const entries = parseChangelog(markdown);
const latest = entries.reduce<string | null>(
(best, entry) =>
best === null || compareSemverLike(entry.version, best) > 0 ? entry.version : best,
null,
);
const latestEntry = entries.find((entry) => entry.version === latest) ?? entries[0] ?? null;
return {
entries,
installedVersion: options.installedVersion,
latestVersion: latest,
expandedGroupKey: latestEntry?.groupKey ?? null,
source: options.source,
...(options.releaseTag ? { releaseTag: options.releaseTag } : {}),
...(options.warning ? { warning: options.warning } : {}),
};
}
export function buildEmptyChangelogSnapshot(options: {
installedVersion: string;
error: string;
}): ChangelogSnapshot {
return {
entries: [],
installedVersion: options.installedVersion,
latestVersion: null,
expandedGroupKey: null,
source: 'bundled',
error: options.error,
};
}
@@ -0,0 +1,204 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildRawChangelogUrl, createChangelogSource } from './changelog-source';
import { buildChangelogSnapshot } from './changelog-snapshot';
const REMOTE = `# Changelog
## v0.20.0 (2026-09-01)
### Added
- Remote only entry.
## v0.19.2 (2026-08-04)
### Fixed
- Installed entry.
`;
const BUNDLED = `# Changelog
## v0.19.2 (2026-08-04)
### Fixed
- Installed entry.
`;
function createDeps(overrides: Partial<Parameters<typeof createChangelogSource>[0]> = {}) {
return {
fetchLatestReleaseTag: async () => 'v0.20.0',
fetchText: async () => REMOTE,
readBundledChangelog: () => BUNDLED,
getInstalledVersion: () => '0.19.2',
now: () => 1_000,
logWarn: () => {},
...overrides,
};
}
test('changelog source reads the changelog at the latest release tag', async () => {
const urls: string[] = [];
const source = createChangelogSource(
createDeps({
fetchText: async (url: string) => {
urls.push(url);
return REMOTE;
},
}),
);
const snapshot = await source.getSnapshot();
assert.deepEqual(urls, [
'https://raw.githubusercontent.com/ksyasuda/SubMiner/v0.20.0/CHANGELOG.md',
]);
assert.equal(snapshot.source, 'remote');
assert.equal(snapshot.releaseTag, 'v0.20.0');
assert.equal(snapshot.latestVersion, '0.20.0');
assert.equal(snapshot.installedVersion, '0.19.2');
assert.equal(snapshot.expandedGroupKey, '0.20');
});
test('changelog source falls back to the default branch when no release tag resolves', async () => {
const urls: string[] = [];
const source = createChangelogSource(
createDeps({
fetchLatestReleaseTag: async () => null,
fetchText: async (url: string) => {
urls.push(url);
return REMOTE;
},
}),
);
const snapshot = await source.getSnapshot();
assert.deepEqual(urls, ['https://raw.githubusercontent.com/ksyasuda/SubMiner/main/CHANGELOG.md']);
assert.equal(snapshot.source, 'remote');
assert.equal(snapshot.releaseTag, undefined);
});
test('changelog source falls back to the bundled changelog when the download fails', async () => {
const warnings: string[] = [];
const source = createChangelogSource(
createDeps({
fetchText: async () => {
throw new Error('offline');
},
logWarn: (message: string) => warnings.push(message),
}),
);
const snapshot = await source.getSnapshot();
assert.equal(snapshot.source, 'bundled');
assert.match(snapshot.warning ?? '', /offline/);
assert.equal(snapshot.latestVersion, '0.19.2');
assert.equal(warnings.length, 1);
});
test('changelog source reports an error when no changelog can be loaded', async () => {
const source = createChangelogSource(
createDeps({
fetchText: async () => {
throw new Error('offline');
},
readBundledChangelog: () => null,
}),
);
const snapshot = await source.getSnapshot();
assert.deepEqual(snapshot.entries, []);
assert.match(snapshot.error ?? '', /offline/);
// Nothing is rendered, so the message must not promise a bundled changelog.
assert.match(snapshot.error ?? '', /^Changelog unavailable: /);
assert.doesNotMatch(snapshot.error ?? '', /Showing the bundled changelog/);
assert.equal(snapshot.installedVersion, '0.19.2');
});
test('changelog source caches remote results and refreshes on demand', async () => {
let fetches = 0;
let clock = 0;
const source = createChangelogSource(
createDeps({
now: () => clock,
fetchText: async () => {
fetches += 1;
return REMOTE;
},
}),
);
await source.getSnapshot();
await source.getSnapshot();
assert.equal(fetches, 1);
await source.getSnapshot({ refresh: true });
assert.equal(fetches, 2);
clock = 11 * 60 * 1000;
await source.getSnapshot();
assert.equal(fetches, 3);
});
test('changelog source retries the network after a bundled fallback', async () => {
let fetches = 0;
const source = createChangelogSource(
createDeps({
fetchText: async () => {
fetches += 1;
throw new Error('offline');
},
}),
);
await source.getSnapshot();
await source.getSnapshot();
assert.equal(fetches, 2);
});
test('changelog source treats an empty remote changelog as a failure', async () => {
const source = createChangelogSource(createDeps({ fetchText: async () => ' ' }));
const snapshot = await source.getSnapshot();
assert.equal(snapshot.source, 'bundled');
});
test('changelog source falls back when the remote body parses to no releases', () => {
const warnings: string[] = [];
const source = createChangelogSource(
createDeps({
// A 200 that is not a changelog, e.g. a redirect landing page.
fetchText: async () => '<!doctype html><html><body>Moved</body></html>',
logWarn: (message: string) => warnings.push(message),
}),
);
return source.getSnapshot().then((snapshot) => {
assert.equal(snapshot.source, 'bundled');
assert.equal(snapshot.entries.length, 1);
assert.match(snapshot.warning ?? '', /no releases/);
assert.equal(warnings.length, 1);
});
});
test('raw changelog urls encode the release ref', () => {
assert.equal(
buildRawChangelogUrl('v1.0.0', 'owner', 'repo'),
'https://raw.githubusercontent.com/owner/repo/v1.0.0/CHANGELOG.md',
);
});
test('snapshot expansion uses the newest version even when file order is unsorted', () => {
const snapshot = buildChangelogSnapshot(
'## v0.18.0 (2026-01-01)\n\n### Fixed\n- Old.\n\n## v0.19.0 (2026-02-01)\n\n### Fixed\n- New.\n',
{ installedVersion: '0.18.0', source: 'bundled' },
);
assert.equal(snapshot.latestVersion, '0.19.0');
assert.equal(snapshot.expandedGroupKey, '0.19');
});
@@ -0,0 +1,117 @@
import type { ChangelogSnapshot } from '../../../types/changelog';
import { buildChangelogSnapshot, buildEmptyChangelogSnapshot } from './changelog-snapshot';
const DEFAULT_OWNER = 'ksyasuda';
const DEFAULT_REPO = 'SubMiner';
const DEFAULT_CACHE_TTL_MS = 10 * 60 * 1000;
export interface ChangelogSourceDeps {
/** Resolves the release the changelog should be read from, or null when unknown. */
fetchLatestReleaseTag: () => Promise<string | null>;
fetchText: (url: string) => Promise<string>;
/** Reads the CHANGELOG.md shipped with the install; null when unavailable. */
readBundledChangelog: () => string | null;
getInstalledVersion: () => string;
now: () => number;
logWarn: (message: string) => void;
owner?: string;
repo?: string;
cacheTtlMs?: number;
}
export function buildRawChangelogUrl(ref: string, owner: string, repo: string): string {
return `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(ref)}/CHANGELOG.md`;
}
function summarize(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function createChangelogSource(deps: ChangelogSourceDeps): {
getSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
} {
const owner = deps.owner ?? DEFAULT_OWNER;
const repo = deps.repo ?? DEFAULT_REPO;
const cacheTtlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
let cached: { snapshot: ChangelogSnapshot; fetchedAt: number } | null = null;
let inFlight: Promise<ChangelogSnapshot> | null = null;
/** `reason` is the raw failure; each branch phrases it for what it can show. */
function fallbackToBundled(reason: string): ChangelogSnapshot {
const bundled = deps.readBundledChangelog();
if (bundled === null) {
// Nothing is on screen, so promising a bundled changelog would be a lie.
return buildEmptyChangelogSnapshot({
installedVersion: deps.getInstalledVersion(),
error: `Changelog unavailable: ${reason}`,
});
}
return buildChangelogSnapshot(bundled, {
installedVersion: deps.getInstalledVersion(),
source: 'bundled',
warning: `Showing the bundled changelog: ${reason}`,
});
}
async function loadSnapshot(): Promise<ChangelogSnapshot> {
let releaseTag: string | null = null;
try {
releaseTag = await deps.fetchLatestReleaseTag();
} catch (error) {
deps.logWarn(`Changelog release lookup failed: ${summarize(error)}`);
}
// Without a release tag the default branch still gives the newest published
// changelog, so try it before falling back to the bundled copy.
const ref = releaseTag ?? 'main';
try {
const markdown = await deps.fetchText(buildRawChangelogUrl(ref, owner, repo));
if (markdown.trim().length === 0) {
throw new Error('Remote changelog was empty.');
}
const snapshot = buildChangelogSnapshot(markdown, {
installedVersion: deps.getInstalledVersion(),
source: 'remote',
...(releaseTag ? { releaseTag } : {}),
});
// A 200 that isn't a changelog (a redirect landing page, a renamed repo)
// parses to nothing; the bundled copy beats showing an empty modal.
if (snapshot.entries.length === 0) {
throw new Error('Remote changelog contained no releases.');
}
return snapshot;
} catch (error) {
const message = summarize(error);
deps.logWarn(`Changelog download failed (${ref}): ${message}`);
return fallbackToBundled(message);
}
}
return {
async getSnapshot(options?: { refresh?: boolean }): Promise<ChangelogSnapshot> {
const refresh = options?.refresh === true;
if (!refresh && cached && deps.now() - cached.fetchedAt < cacheTtlMs) {
return cached.snapshot;
}
if (inFlight) return await inFlight;
inFlight = loadSnapshot()
.then((snapshot) => {
// Only a successful remote read is worth caching; a bundled fallback
// should retry the network on the next open.
if (snapshot.source === 'remote') {
cached = { snapshot, fetchedAt: deps.now() };
} else {
cached = null;
}
return snapshot;
})
.finally(() => {
inFlight = null;
});
return await inFlight;
},
};
}