mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-07 19:21:32 -07:00
feat(overlay): add in-app changelog modal (#187)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user