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
+2
View File
@@ -109,6 +109,7 @@ export interface MainIpcRuntimeServiceDepsParams {
removeCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['removeCharacterDictionaryManagedEntry'];
moveCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['moveCharacterDictionaryManagedEntry'];
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
getChangelogSnapshot?: IpcDepsRuntimeOptions['getChangelogSnapshot'];
getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot'];
appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile'];
playPlaylistBrowserIndex: IpcDepsRuntimeOptions['playPlaylistBrowserIndex'];
@@ -302,6 +303,7 @@ export function createMainIpcRuntimeServiceDeps(
removeCharacterDictionaryManagedEntry: params.removeCharacterDictionaryManagedEntry,
moveCharacterDictionaryManagedEntry: params.moveCharacterDictionaryManagedEntry,
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
getChangelogSnapshot: params.getChangelogSnapshot,
getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot,
appendPlaylistBrowserFile: params.appendPlaylistBrowserFile,
playPlaylistBrowserIndex: params.playPlaylistBrowserIndex,
+48
View File
@@ -0,0 +1,48 @@
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
const CHANGELOG_MODAL: OverlayHostedModal = 'changelog';
const CHANGELOG_OPEN_TIMEOUT_MS = 1500;
export async function openChangelogModal(deps: {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
}): Promise<boolean> {
return await retryOverlayModalOpen(
{
waitForModalOpen: deps.waitForModalOpen,
logWarn: deps.logWarn,
},
{
modal: CHANGELOG_MODAL,
timeoutMs: CHANGELOG_OPEN_TIMEOUT_MS,
retryWarning:
'Changelog modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
sendOpen: () =>
openOverlayHostedModal(
{
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
ensureOverlayWindowsReadyForVisibilityActions:
deps.ensureOverlayWindowsReadyForVisibilityActions,
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
},
{
channel: IPC_CHANNELS.event.changelogOpen,
modal: CHANGELOG_MODAL,
preferModalWindow: true,
},
),
},
);
}
@@ -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;
},
};
}
@@ -65,6 +65,7 @@ test('build tray template handler wires actions and init guards', () => {
},
isOverlayRuntimeInitialized: () => initialized,
openSessionHelpModal: () => calls.push('help'),
openChangelogModal: () => calls.push('changelog'),
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: () => true,
showFirstRunSetup: () => true,
@@ -120,6 +121,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
initializeOverlayRuntime: () => calls.push('init'),
isOverlayRuntimeInitialized: () => true,
openSessionHelpModal: () => calls.push('help'),
openChangelogModal: () => calls.push('changelog'),
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: () => true,
showFirstRunSetup: () => false,
+8
View File
@@ -39,6 +39,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: {
platform?: string;
openSessionHelp: () => void;
openChangelog: () => void;
openTexthookerInBrowser: () => void;
showTexthookerPage: boolean;
openFirstRunSetup: () => void;
@@ -60,6 +61,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean;
openSessionHelpModal: () => void;
openChangelogModal: () => void;
openTexthookerInBrowser: () => void;
showTexthookerPage: () => boolean;
showFirstRunSetup: () => boolean;
@@ -87,6 +89,12 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
}
deps.openSessionHelpModal();
},
openChangelog: () => {
if (!deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime();
}
deps.openChangelogModal();
},
openTexthookerInBrowser: () => {
deps.openTexthookerInBrowser();
},
+2
View File
@@ -25,6 +25,7 @@ test('tray main deps builders return mapped handlers', () => {
initializeOverlayRuntime: () => calls.push('init'),
isOverlayRuntimeInitialized: () => false,
openSessionHelpModal: () => calls.push('help'),
openChangelogModal: () => calls.push('changelog'),
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: () => true,
showFirstRunSetup: () => true,
@@ -50,6 +51,7 @@ test('tray main deps builders return mapped handlers', () => {
const template = menuDeps.buildTrayMenuTemplateRuntime({
platform: menuDeps.platform,
openSessionHelp: () => calls.push('open-help'),
openChangelog: () => calls.push('open-changelog'),
openTexthookerInBrowser: () => calls.push('open-texthooker'),
showTexthookerPage: true,
openFirstRunSetup: () => calls.push('open-setup'),
+3
View File
@@ -29,6 +29,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: {
platform?: string;
openSessionHelp: () => void;
openChangelog: () => void;
openTexthookerInBrowser: () => void;
showTexthookerPage: boolean;
openFirstRunSetup: () => void;
@@ -50,6 +51,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean;
openSessionHelpModal: () => void;
openChangelogModal: () => void;
openTexthookerInBrowser: () => void;
showTexthookerPage: () => boolean;
showFirstRunSetup: () => boolean;
@@ -74,6 +76,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
initializeOverlayRuntime: deps.initializeOverlayRuntime,
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
openSessionHelpModal: deps.openSessionHelpModal,
openChangelogModal: deps.openChangelogModal,
openTexthookerInBrowser: deps.openTexthookerInBrowser,
showTexthookerPage: deps.showTexthookerPage,
showFirstRunSetup: deps.showFirstRunSetup,
@@ -25,6 +25,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
},
isOverlayRuntimeInitialized: () => overlayInitialized,
openSessionHelpModal: () => {},
openChangelogModal: () => {},
openTexthookerInBrowser: () => {},
showTexthookerPage: () => true,
showFirstRunSetup: () => true,
+48 -26
View File
@@ -30,6 +30,7 @@ test('tray menu template contains expected entries and handlers', () => {
const calls: string[] = [];
const template = buildTrayMenuTemplateRuntime({
openSessionHelp: () => calls.push('help'),
openChangelog: () => calls.push('changelog'),
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: true,
openFirstRunSetup: () => calls.push('setup'),
@@ -49,36 +50,53 @@ test('tray menu template contains expected entries and handlers', () => {
quitApp: () => calls.push('quit'),
});
assert.equal(template.length, 14);
assert.equal(
template.some((entry) => entry.label === 'Open Runtime Options'),
false,
// Resolve by label, not index: adding a menu entry should not force every
// later assertion in this test to be renumbered.
const entryFor = (label: string) => {
const entry = template.find((candidate) => candidate.label === label);
assert.ok(entry, `expected a "${label}" tray entry`);
return entry;
};
assert.deepEqual(
template.map((entry) => entry.label ?? `<${entry.type}>`),
[
'Open Help',
'View Changelog',
'Open Texthooker',
'Complete Setup',
'Open SubMiner Setup',
'Open Yomitan Settings',
'Open SubMiner Settings',
'Sync Stats && History',
'Export Logs',
'Configure Jellyfin',
'Jellyfin Discovery',
'Configure AniList',
'Check for Updates',
'<separator>',
'Quit',
],
);
assert.equal(
template.some((entry) => entry.label === 'Open Overlay'),
false,
);
assert.equal(template[0]!.label, 'Open Help');
assert.equal(template[3]!.label, 'Open SubMiner Setup');
const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery');
assert.equal(discovery?.type, 'checkbox');
assert.equal(discovery?.checked, false);
discovery?.click?.({ checked: true });
template[0]!.click?.();
assert.equal(template[1]!.label, 'Open Texthooker');
template[1]!.click?.();
assert.equal(template[5]!.label, 'Open SubMiner Settings');
assert.equal(template[6]!.label, 'Sync Stats && History');
template[6]!.click?.();
assert.equal(template[7]!.label, 'Export Logs');
template[7]!.click?.();
assert.equal(template[11]!.label, 'Check for Updates');
template[11]!.click?.();
template[12]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[13]!.click?.();
const discovery = entryFor('Jellyfin Discovery');
assert.equal(discovery.type, 'checkbox');
assert.equal(discovery.checked, false);
discovery.click?.({ checked: true });
entryFor('Open Help').click?.();
entryFor('View Changelog').click?.();
entryFor('Open Texthooker').click?.();
entryFor('Sync Stats && History').click?.();
entryFor('Export Logs').click?.();
entryFor('Check for Updates').click?.();
calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad');
entryFor('Quit').click?.();
assert.deepEqual(calls, [
'jellyfin-discovery:true',
'help',
'changelog',
'texthooker',
'sync-ui',
'export-logs',
@@ -91,6 +109,7 @@ test('tray menu template contains expected entries and handlers', () => {
test('tray menu template omits first-run setup entry when setup is complete', () => {
const labels = buildTrayMenuTemplateRuntime({
openSessionHelp: () => undefined,
openChangelog: () => undefined,
openTexthookerInBrowser: () => undefined,
showTexthookerPage: true,
openFirstRunSetup: () => undefined,
@@ -120,6 +139,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
test('tray menu template omits texthooker entry when texthooker page is disabled', () => {
const labels = buildTrayMenuTemplateRuntime({
openSessionHelp: () => undefined,
openChangelog: () => undefined,
openTexthookerInBrowser: () => undefined,
showTexthookerPage: false,
openFirstRunSetup: () => undefined,
@@ -147,6 +167,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
test('tray menu template renders active jellyfin discovery checkbox', () => {
const template = buildTrayMenuTemplateRuntime({
openSessionHelp: () => undefined,
openChangelog: () => undefined,
openTexthookerInBrowser: () => undefined,
showTexthookerPage: true,
openFirstRunSetup: () => undefined,
@@ -175,6 +196,7 @@ test('tray menu template renders a visible linux discovery check mark when activ
const template = buildTrayMenuTemplateRuntime({
platform: 'linux',
openSessionHelp: () => undefined,
openChangelog: () => undefined,
openTexthookerInBrowser: () => undefined,
showTexthookerPage: true,
openFirstRunSetup: () => undefined,
+5
View File
@@ -32,6 +32,7 @@ export function resolveTrayIconPathRuntime(deps: {
export type TrayMenuActionHandlers = {
platform?: string;
openSessionHelp: () => void;
openChangelog: () => void;
openTexthookerInBrowser: () => void;
showTexthookerPage: boolean;
openFirstRunSetup: () => void;
@@ -72,6 +73,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
label: 'Open Help',
click: handlers.openSessionHelp,
},
{
label: 'View Changelog',
click: handlers.openChangelog,
},
...(handlers.showTexthookerPage
? [
{
@@ -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') {