feat(overlay): add in-app changelog modal

- Add a changelog modal opened from tray "View Changelog" or the update notification's "What's New" button; renders in-player or in its own window like the help modal
- Fetch changelog from the newest published release so newer-than-installed notes are visible, falling back to the bundled CHANGELOG.md on failure
- Group versions by minor line (current expanded, older folded), badge the installed version, tag newer ones "New"
- Support J/K/arrow navigation, Enter to fold/unfold, R to refetch, Esc to close
- Add changelog parsing/semver-compare utils and changelog IPC channel/runtime; bundle CHANGELOG.md into packaged builds
- Replace release/release-notes.md with changes/changelog-modal.md changeset entry
This commit is contained in:
2026-08-05 01:20:52 -07:00
parent a0dde4ee3e
commit 3a97239ade
48 changed files with 2697 additions and 113 deletions
+7
View File
@@ -0,0 +1,7 @@
type: added
area: overlay
- Added an in-app changelog modal, opened from the tray ("View Changelog") or the "What's New" button on the update-available notification, which now stays on screen so "Update" is still reachable after reading the notes. It renders inside the player bounds when a video is playing and in its own window otherwise, the same as the help modal.
- The changelog is fetched from the newest published release, so release notes for versions newer than the installed build are visible; a failed download falls back to the changelog bundled with the install and says so in the modal.
- Versions are foldable: the current `0.x` line is expanded and older lines are folded, matching the docs-site changelog. A badge marks the installed version and newer versions are tagged "New".
- Keyboard: `J`/`K` or arrows move between versions, `Enter` folds/unfolds, `R` refetches, `Esc` closes.
+2 -2
View File
@@ -75,8 +75,8 @@ src/
renderer/ # Overlay renderer (modularized UI/runtime)
handlers/ # Keyboard/mouse/gamepad interaction modules
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
# character dictionary, playlist browser, subtitle sidebar,
# YouTube track picker, controller config/debug/select)
# changelog, character dictionary, playlist browser, subtitle
# sidebar, YouTube track picker, controller config/debug/select)
positioning/ # Subtitle position controller (drag-to-reposition)
settings/ # Settings window UI (model, controls, markup)
types/ # Domain type modules (anki, config, integrations, ...)
+4
View File
@@ -145,6 +145,8 @@ The tray menu includes `Export Logs`, which creates the same sanitized local-dat
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
The tray menu also includes `View Changelog`, which opens the in-app changelog modal. It fetches the changelog from the newest published release, so you see release notes for versions newer than the one you run; if the download fails it falls back to the changelog bundled with your install and says so. Versions in the current `0.x` line are expanded by default and older lines are folded, matching this site's [Changelog](/changelog). A badge marks the version you have installed, and newer versions are tagged `New`. The same modal opens from the `What's New` button on the update-available overlay notification.
### Logging and App Mode
- `--log-level` controls logger verbosity.
@@ -368,6 +370,8 @@ Press `V` to cycle the primary SubMiner subtitle bar through hidden → visible
`Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. The same help view is also available through the `y-h` chord in mpv.
The changelog modal (tray > `View Changelog`) works the same way: it renders over mpv when a video is playing and in its own window otherwise. Use `J`/`K` or the arrow keys to move between versions, `Enter` to fold or unfold one, `R` to refetch, and `Esc` to close.
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
### Drag-and-Drop
+4
View File
@@ -260,6 +260,10 @@
{
"from": "dist/launcher/subminer",
"to": "launcher/subminer"
},
{
"from": "CHANGELOG.md",
"to": "CHANGELOG.md"
}
]
},
-40
View File
@@ -1,40 +0,0 @@
## Highlights
### Changed
- **Subsync Reference & Target Picker**
- You can now choose both sides of a sync run: which subtitle is the timing reference and which one gets retimed.
- The video file itself can be used as the reference for local files (audio-based sync), though a subtitle track stays the default.
- Works for both alass and ffsubsync, and retiming the secondary subtitle track no longer overwrites your primary one.
### Fixed
- **Startup Logging**
- Background startup now respects your configured log level even when no `--log-level` flag is passed.
- **Streaming Subtitle Tokenization**
- Jellyfin playback now seeds tokenization straight from the downloaded subtitle file, so episodes no longer fall back to slow, line-by-line tokenizing while waiting on playback events.
- Subtitle cues are no longer dropped when switching to a subtitle track embedded in the stream.
- Prefetching now runs through the whole episode instead of stopping once the cache filled, and the cache clears between episodes so slowdowns don't carry over to later titles.
- The tokenization cache was expanded from 256 to 2,500 lines, leaving more room for repeated lines (like openings and endings) to stay cached across episodes.
- **Subtitle Line Display**
- Subtitle lines now appear immediately at their cue time even if tokenization hasn't finished, upgrading in place with annotations once ready.
- A failed tokenization attempt is no longer cached as plain text, so the line gets another chance at full annotations later.
## What's Changed
- feat(subsync): add reference and target subtitle track picker by @ksyasuda in #181
- fix(logging): surface subtitle processing debug/warn logs by @ksyasuda in #182
- fix(streaming): keep subtitle tokenization prefetch warm for full episodes by @ksyasuda in #183
- fix(overlay): show plain subtitle line immediately on tokenization cache miss by @ksyasuda in #184
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
+15
View File
@@ -1,6 +1,7 @@
import electron from 'electron';
import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron';
import type {
ChangelogSnapshot,
CompiledSessionBinding,
ControllerConfigUpdate,
PlaylistBrowserMutationResult,
@@ -122,6 +123,7 @@ export interface IpcServiceDeps {
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
getChangelogSnapshot?: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
@@ -297,6 +299,7 @@ export interface IpcDepsRuntimeOptions {
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
getChangelogSnapshot?: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
@@ -418,6 +421,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
entries: [],
})),
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
getChangelogSnapshot: options.getChangelogSnapshot,
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
playPlaylistBrowserIndex: options.playPlaylistBrowserIndex,
@@ -820,6 +824,17 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.appendClipboardVideoToQueue();
});
ipc.handle(IPC_CHANNELS.request.getChangelogSnapshot, async (_event, payload: unknown) => {
const refresh =
typeof payload === 'object' && payload !== null && 'refresh' in payload
? (payload as { refresh?: unknown }).refresh === true
: false;
if (!deps.getChangelogSnapshot) {
throw new Error('Changelog service is unavailable.');
}
return await deps.getChangelogSnapshot({ refresh });
});
ipc.handle(IPC_CHANNELS.request.getPlaylistBrowserSnapshot, async () => {
return await deps.getPlaylistBrowserSnapshot();
});
+167
View File
@@ -0,0 +1,167 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { parseChangelog, resolveChangelogGroupKey } from './changelog-parse';
const SAMPLE = `# Changelog
## v0.19.2 (2026-08-04)
### Changed
- Subsync: picks both tracks now.
### Fixed
- Overlay: shows the plain line immediately.
<details>
<summary>Internal changes</summary>
### Internal
- Patched \`undici\`.
</details>
## v0.19.1 (2026-08-01)
### Added
- **Word Card Type:**
- Adds a setting.
- Flags clear each other.
## v0.18.0 (2026-07-01)
### Fixed
- Something older.
`;
test('changelog parser reads versions, dates, and sections in file order', () => {
const entries = parseChangelog(SAMPLE);
assert.deepEqual(
entries.map((entry) => `${entry.version}@${entry.date}`),
['0.19.2@2026-08-04', '0.19.1@2026-08-01', '0.18.0@2026-07-01'],
);
assert.deepEqual(
entries[0]?.sections.map((section) => section.heading),
['Changed', 'Fixed', 'Internal'],
);
assert.deepEqual(entries[0]?.sections[1]?.items, [
{ text: 'Overlay: shows the plain line immediately.', children: [] },
]);
});
test('changelog parser flags sections inside the details block as internal', () => {
const entries = parseChangelog(SAMPLE);
const sections = entries[0]?.sections ?? [];
assert.deepEqual(
sections.map((section) => section.internal),
[false, false, true],
);
assert.deepEqual(sections[2]?.items, [{ text: 'Patched `undici`.', children: [] }]);
});
test('changelog parser groups entries by major.minor', () => {
const entries = parseChangelog(SAMPLE);
assert.deepEqual(
entries.map((entry) => entry.groupKey),
['0.19', '0.19', '0.18'],
);
assert.equal(resolveChangelogGroupKey('1.2.3'), '1.2');
});
test('changelog parser keeps bullets that precede any section heading', () => {
const entries = parseChangelog('## v0.1.0 (2025-01-01)\n\n- Initial release.\n');
assert.deepEqual(entries[0]?.sections, [
{
heading: 'Changes',
items: [{ text: 'Initial release.', children: [] }],
internal: false,
},
]);
});
test('changelog parser drops empty sections and tolerates missing dates', () => {
const entries = parseChangelog('## v0.2.0\n\n### Added\n\n### Fixed\n- One fix.\n');
assert.equal(entries[0]?.date, '');
assert.deepEqual(
entries[0]?.sections.map((section) => section.heading),
['Fixed'],
);
});
test('changelog parser keeps indented sub-bullets nested under their lead bullet', () => {
const entries = parseChangelog(SAMPLE);
const added = entries[1]?.sections.find((section) => section.heading === 'Added');
assert.deepEqual(added?.items, [
{
text: '**Word Card Type:**',
children: [
{ text: 'Adds a setting.', children: [] },
{ text: 'Flags clear each other.', children: [] },
],
},
]);
});
test('changelog parser nests three bullet levels and rejoins wrapped lines', () => {
const entries = parseChangelog(
[
'## v0.9.0 (2025-05-05)',
'',
'### Added',
'- Top level',
' - Second level',
' - Third level',
' continued on the next line',
' - Back to second level',
'- Another top level',
'',
].join('\n'),
);
assert.deepEqual(entries[0]?.sections[0]?.items, [
{
text: 'Top level',
children: [
{
text: 'Second level',
children: [{ text: 'Third level continued on the next line', children: [] }],
},
{ text: 'Back to second level', children: [] },
],
},
{ text: 'Another top level', children: [] },
]);
});
test('changelog parser handles the repo CHANGELOG.md', () => {
const markdown = fs.readFileSync(path.join(process.cwd(), 'CHANGELOG.md'), 'utf8');
const entries = parseChangelog(markdown);
assert.ok(entries.length > 3);
for (const entry of entries) {
assert.match(entry.version, /^\d+\.\d+\.\d+/);
assert.ok(entry.sections.length > 0, `expected sections for v${entry.version}`);
for (const section of entry.sections) {
for (const item of section.items) {
assert.ok(item.text.length > 0, `empty bullet in v${entry.version}`);
}
}
}
// Older entries group notes under a bold lead bullet; nesting must survive.
const breaking = entries
.find((entry) => entry.version === '0.15.0')
?.sections.find((section) => section.heading === 'Breaking Changes');
assert.deepEqual(
breaking?.items.map((item) => `${item.text}:${item.children.length}`),
['**Subsync:**:2', '**N+1 Highlighting:**:2'],
);
});
+123
View File
@@ -0,0 +1,123 @@
import type { ChangelogEntry, ChangelogItem, ChangelogSection } from '../../types/changelog';
const VERSION_HEADING = /^##\s+v(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\s*(?:\(([^)]*)\))?\s*$/;
const SECTION_HEADING = /^###\s+(.+?)\s*$/;
const BULLET = /^(\s*)[-*]\s+(.*)$/;
/**
* Entries are grouped by `major.minor` so the whole current minor line renders
* expanded, matching how docs-site/changelog.md splits current vs previous.
*/
export function resolveChangelogGroupKey(version: string): string {
const match = version.match(/^(\d+)\.(\d+)/);
if (!match) return version;
return `${match[1]}.${match[2]}`;
}
/**
* Parses the repo CHANGELOG.md into version entries. Bullets keep their inline
* markdown and their nesting: older entries group related notes under a bold
* lead bullet with indented children, and flattening them loses that structure.
*/
export function parseChangelog(markdown: string): ChangelogEntry[] {
const entries: ChangelogEntry[] = [];
let entry: ChangelogEntry | null = null;
let section: ChangelogSection | null = null;
let internal = false;
// Open bullets from outermost to innermost, used to place the next bullet.
let openItems: Array<{ indent: number; item: ChangelogItem }> = [];
function startSection(heading: string): void {
section = { heading, items: [], internal };
openItems = [];
entry?.sections.push(section);
}
function addBullet(indent: number, text: string): void {
if (!section) {
// Bullets before any "###" heading (older entries) land in a generic group.
startSection('Changes');
}
const item: ChangelogItem = { text, children: [] };
while (openItems.length > 0 && (openItems[openItems.length - 1]?.indent ?? 0) >= indent) {
openItems.pop();
}
const parent = openItems[openItems.length - 1];
if (parent) {
parent.item.children.push(item);
} else {
section?.items.push(item);
}
openItems.push({ indent, item });
}
function appendContinuation(text: string): void {
const current = openItems[openItems.length - 1];
if (!current) return;
current.item.text = `${current.item.text} ${text}`;
}
for (const rawLine of markdown.split(/\r?\n/)) {
const line = rawLine.trimEnd();
const trimmed = line.trim();
const versionMatch = trimmed.match(VERSION_HEADING);
if (versionMatch) {
const version = versionMatch[1] ?? '';
entry = {
version,
date: versionMatch[2]?.trim() ?? '',
groupKey: resolveChangelogGroupKey(version),
sections: [],
};
entries.push(entry);
section = null;
internal = false;
openItems = [];
continue;
}
if (!entry) continue;
if (trimmed.startsWith('<details')) {
internal = true;
section = null;
openItems = [];
continue;
}
if (trimmed.startsWith('</details')) {
internal = false;
section = null;
openItems = [];
continue;
}
if (trimmed.startsWith('<summary')) continue;
const sectionMatch = trimmed.match(SECTION_HEADING);
if (sectionMatch) {
startSection(sectionMatch[1] ?? '');
continue;
}
const bulletMatch = line.match(BULLET);
if (bulletMatch) {
addBullet((bulletMatch[1] ?? '').length, bulletMatch[2] ?? '');
continue;
}
// A blank line ends the current bullet run; an indented non-bullet line is a
// wrapped continuation of the bullet above it.
if (!trimmed) {
continue;
}
if (/^\s/.test(line)) {
appendContinuation(trimmed);
}
}
return entries.map((item) => ({
...item,
sections: item.sections.filter((entrySection) => entrySection.items.length > 0),
}));
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Loose semver ordering shared by the updater and the changelog UI.
* Returns >0 when `a` is newer, <0 when older, 0 when equal.
*/
export function compareSemverLike(a: string, b: string): number {
const parse = (
value: string,
): {
core: number[];
prerelease: Array<number | string>;
} => {
// Build metadata ("+build.2") is not part of precedence per semver, and
// leaving it attached makes it leak into the prerelease comparison.
const normalized = value.replace(/^v/i, '').split('+', 1)[0] ?? '';
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;
}
+31
View File
@@ -468,6 +468,8 @@ import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku
import { openTsukihimeModal as openTsukihimeModalRuntime } from './main/runtime/tsukihime-open';
import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open';
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
import { openChangelogModal as openChangelogModalRuntime } from './main/runtime/changelog-open';
import { createChangelogRuntime } from './main/runtime/changelog/changelog-runtime';
import { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open';
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
@@ -506,6 +508,7 @@ import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'
import {
INSTALL_UPDATE_ACTION_ID,
UPDATE_AVAILABLE_NOTIFICATION_ID,
VIEW_CHANGELOG_ACTION_ID,
} from './main/runtime/update/update-notifications';
import { createOverlayNotificationsRuntime } from './main/runtime/overlay-notifications-runtime';
import {
@@ -2828,6 +2831,14 @@ function openSessionHelpOverlay(): void {
);
}
function openChangelogOverlay(): void {
openOverlayHostedModalWithOsd(
openChangelogModalRuntime,
'Changelog overlay unavailable.',
'Failed to open changelog overlay.',
);
}
function openCharacterDictionaryManagerOverlay(): void {
openCharacterDictionaryManagerWithConfigGate({
isCharacterDictionaryEnabled: () => configService.getConfig().subtitleStyle.nameMatchEnabled,
@@ -5095,6 +5106,18 @@ flushPendingMpvLogWrites = () => {
void flushMpvLog();
};
const { getChangelogSnapshot } = createChangelogRuntime({
getInstalledVersion: () => app.getVersion(),
getUpdateChannel: () => configService.getConfig().updates.channel,
resourcesPath: process.resourcesPath,
appPath: app.getAppPath(),
dirname: __dirname,
joinPath: (...parts) => path.join(...parts),
fileExists: (candidate) => fs.existsSync(candidate),
readFile: (candidate) => fs.readFileSync(candidate, 'utf8'),
logWarn: (message) => logger.warn(message),
});
const { getUpdateService } = createUpdateServiceRuntime({
userDataPath: USER_DATA_PATH,
getUpdatesConfig: () => configService.getConfig().updates,
@@ -5463,6 +5486,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
logger.warn('Failed to install update from overlay notification action:', error);
});
}
if (
notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID &&
actionId === VIEW_CHANGELOG_ACTION_ID
) {
openChangelogOverlay();
}
if (actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined) {
void openAnkiCardFromNotification(noteId).catch((error) => {
logger.warn('Failed to open Anki card from overlay notification action:', error);
@@ -5728,6 +5757,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
return result;
},
appendClipboardVideoToQueue: () => appendClipboardVideoToQueueHandler(),
getChangelogSnapshot: (options) => getChangelogSnapshot(options),
...playlistBrowserMainDeps,
getImmersionTracker: () => appState.immersionTracker,
},
@@ -6118,6 +6148,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
initializeOverlayRuntime: () => initializeOverlayRuntime(),
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
openSessionHelpModal: () => openSessionHelpOverlay(),
openChangelogModal: () => openChangelogOverlay(),
openTexthookerInBrowser: () =>
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
showTexthookerPage: () => shouldShowTexthookerTrayEntry(configService.getConfig()),
+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,69 @@
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;
}
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 =
deps.createFetch?.() ??
(process.platform === 'win32' ? createGlobalFetch() : createCurlFetch());
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,201 @@
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/);
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,115 @@
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;
function fallbackToBundled(warning: string): ChangelogSnapshot {
const bundled = deps.readBundledChangelog();
if (bundled === null) {
return buildEmptyChangelogSnapshot({
installedVersion: deps.getInstalledVersion(),
error: warning,
});
}
return buildChangelogSnapshot(bundled, {
installedVersion: deps.getInstalledVersion(),
source: 'bundled',
warning,
});
}
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(`Showing the bundled changelog: ${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,
+19 -11
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,7 +50,7 @@ test('tray menu template contains expected entries and handlers', () => {
quitApp: () => calls.push('quit'),
});
assert.equal(template.length, 14);
assert.equal(template.length, 15);
assert.equal(
template.some((entry) => entry.label === 'Open Runtime Options'),
false,
@@ -59,26 +60,29 @@ test('tray menu template contains expected entries and handlers', () => {
false,
);
assert.equal(template[0]!.label, 'Open Help');
assert.equal(template[3]!.label, 'Open SubMiner Setup');
assert.equal(template[4]!.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');
assert.equal(template[1]!.label, 'View Changelog');
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');
assert.equal(template[2]!.label, 'Open Texthooker');
template[2]!.click?.();
assert.equal(template[6]!.label, 'Open SubMiner Settings');
assert.equal(template[7]!.label, 'Sync Stats && History');
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?.();
assert.equal(template[8]!.label, 'Export Logs');
template[8]!.click?.();
assert.equal(template[12]!.label, 'Check for Updates');
template[12]!.click?.();
template[13]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[14]!.click?.();
assert.deepEqual(calls, [
'jellyfin-discovery:true',
'help',
'changelog',
'texthooker',
'sync-ui',
'export-logs',
@@ -91,6 +95,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 +125,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 +153,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 +182,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
? [
{
@@ -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') {
+5
View File
@@ -68,6 +68,7 @@ import type {
YoutubePickerResolveResult,
OverlayNotificationEventPayload,
OverlayNotificationPosition,
ChangelogSnapshot,
} from './types';
import { IPC_CHANNELS } from './shared/ipc/contracts';
@@ -166,6 +167,7 @@ function createLatestValueIpcListenerWithPayload<T>(
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen);
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
IPC_CHANNELS.event.characterDictionaryManagerOpen,
);
@@ -447,6 +449,9 @@ const electronAPI: ElectronAPI = {
},
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
onOpenSessionHelp: onOpenSessionHelpEvent,
onOpenChangelog: onOpenChangelogEvent,
getChangelogSnapshot: (options?: { refresh?: boolean }): Promise<ChangelogSnapshot> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options),
onOpenControllerSelect: onOpenControllerSelectEvent,
onOpenControllerDebug: onOpenControllerDebugEvent,
onOpenJimaku: onOpenJimakuEvent,
+50
View File
@@ -456,6 +456,7 @@ function createKeyboardHandlerHarness() {
let openControllerSelectCount = 0;
let openControllerDebugCount = 0;
let playlistBrowserKeydownCount = 0;
let changelogKeydownCount = 0;
const createWordNode = (left: number) => ({
classList: createClassList(),
@@ -504,6 +505,10 @@ function createKeyboardHandlerHarness() {
return true;
},
handleSessionHelpKeydown: () => false,
handleChangelogKeydown: () => {
changelogKeydownCount += 1;
return true;
},
openSessionHelpModal: () => {},
openControllerSelectModal: () => {
openControllerSelectCount += 1;
@@ -522,6 +527,7 @@ function createKeyboardHandlerHarness() {
openControllerSelectCount: () => openControllerSelectCount,
openControllerDebugCount: () => openControllerDebugCount,
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
changelogKeydownCount: () => changelogKeydownCount,
setWordCount: (count: number) => {
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
},
@@ -1404,6 +1410,50 @@ test('keyboard mode: playlist browser modal handles h before lookup controls', a
}
});
test('keyboard mode: changelog modal handles h/l fold keys before lookup controls', async () => {
const { ctx, testGlobals, handlers, changelogKeydownCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.handleKeyboardModeToggleRequested();
ctx.state.changelogModalOpen = true;
ctx.state.keyboardSelectedWordIndex = 2;
// H and L fold/unfold changelog entries; they must not move the subtitle
// word selection or seek mpv behind the open modal.
testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' });
testGlobals.dispatchKeydown({ key: 'l', code: 'KeyL' });
assert.equal(changelogKeydownCount(), 2);
assert.equal(ctx.state.keyboardSelectedWordIndex, 2);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: changelog modal handles arrow keys before yomitan popup', async () => {
const { ctx, testGlobals, handlers, changelogKeydownCount } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
ctx.state.changelogModalOpen = true;
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
assert.equal(changelogKeydownCount(), 1);
assert.equal(
testGlobals.commandEvents.some(
(event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown',
),
false,
);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: configured stats toggle works even while popup is open', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
+9
View File
@@ -22,6 +22,7 @@ export function createKeyboardHandlers(
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
handleChangelogKeydown: (e: KeyboardEvent) => boolean;
openSessionHelpModal: (opening: {
bindingKey: 'KeyH' | 'KeyK';
fallbackUsed: boolean;
@@ -1095,6 +1096,14 @@ export function createKeyboardHandlers(
}
}
// Ahead of the keyboard-driven lookup controls: the changelog modal binds
// arrows/H/L for folding, and those would otherwise move the subtitle word
// selection (and seek mpv) behind the open modal.
if (ctx.state.changelogModalOpen) {
options.handleChangelogKeydown(e);
return;
}
if (handleKeyboardDrivenModeLookupControls(e)) {
e.preventDefault();
return;
+20
View File
@@ -467,6 +467,26 @@
</div>
</div>
</div>
<div id="changelogModal" class="modal hidden" aria-hidden="true">
<div class="modal-content changelog-content">
<div class="modal-header">
<div class="modal-title">Changelog</div>
<div class="changelog-header-actions">
<button id="changelogRefresh" class="changelog-refresh" type="button">Refresh</button>
<button id="changelogClose" class="modal-close" type="button">Close</button>
</div>
</div>
<div class="modal-body">
<div class="changelog-meta">
<span id="changelogInstalled" class="changelog-installed"></span>
<span id="changelogSource" class="changelog-source"></span>
</div>
<div id="changelogWarning" class="changelog-warning"></div>
<div id="changelogStatus" class="changelog-status"></div>
<div id="changelogList" class="changelog-list"></div>
</div>
</div>
</div>
<div id="playlistBrowserModal" class="modal hidden" aria-hidden="true">
<div class="modal-content playlist-browser-content">
<div class="modal-header">
@@ -0,0 +1,212 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ChangelogEntry, ChangelogSnapshot } from '../../types/changelog';
import {
createChangelogEntryNode,
describeChangelogSource,
resolveEntryBadge,
shouldEntryStartExpanded,
tokenizeInlineMarkdown,
} from './changelog-render';
type FakeNode = {
tagName: string;
className: string;
title: string;
open: boolean;
tabIndex: number;
dataset: Record<string, string>;
children: FakeNode[];
textContent: string;
};
function createFakeNode(tagName: string): FakeNode {
const node: FakeNode = {
tagName: tagName.toLowerCase(),
className: '',
title: '',
open: false,
tabIndex: 0,
dataset: {},
children: [],
textContent: '',
};
return Object.assign(node, {
appendChild: (child: FakeNode) => {
node.children.push(child);
return child;
},
});
}
function withFakeDocument<T>(run: () => T): T {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
createElement: (tagName: string) => createFakeNode(tagName),
createTextNode: (value: string) => ({
tagName: '#text',
textContent: value,
children: [],
}),
},
});
try {
return run();
} finally {
if (previous) {
Object.defineProperty(globalThis, 'document', previous);
} else {
delete (globalThis as { document?: unknown }).document;
}
}
}
function flatten(node: FakeNode): FakeNode[] {
return [node, ...(node.children ?? []).flatMap((child) => flatten(child as FakeNode))];
}
const ENTRY: ChangelogEntry = {
version: '0.19.2',
date: '2026-08-04',
groupKey: '0.19',
sections: [
{
heading: 'Fixed',
items: [
{
text: '**Overlay:**',
children: [
{ text: 'Fixed `something`.', children: [] },
{ text: 'Fixed another thing.', children: [] },
],
},
{ text: 'Standalone fix.', children: [] },
],
internal: false,
},
{
heading: 'Internal',
items: [{ text: 'Patched deps.', children: [] }],
internal: true,
},
],
};
test('inline markdown tokenizer splits code, bold, and links', () => {
assert.deepEqual(tokenizeInlineMarkdown('Patched `undici` and **brace**.'), [
{ kind: 'text', value: 'Patched ' },
{ kind: 'code', value: 'undici' },
{ kind: 'text', value: ' and ' },
{ kind: 'strong', value: 'brace' },
{ kind: 'text', value: '.' },
]);
assert.deepEqual(tokenizeInlineMarkdown('See [docs](https://example.com).'), [
{ kind: 'text', value: 'See ' },
{ kind: 'link', value: 'docs', href: 'https://example.com' },
{ kind: 'text', value: '.' },
]);
});
test('inline markdown tokenizer leaves plain text untouched', () => {
assert.deepEqual(tokenizeInlineMarkdown('No markup here'), [
{ kind: 'text', value: 'No markup here' },
]);
});
test('entry badges mark the installed version and newer releases', () => {
assert.equal(resolveEntryBadge('0.19.2', '0.19.2'), 'installed');
assert.equal(resolveEntryBadge('0.20.0', '0.19.2'), 'newer');
assert.equal(resolveEntryBadge('0.19.1', '0.19.2'), null);
});
test('only entries in the newest major.minor line start expanded', () => {
const snapshot = { expandedGroupKey: '0.19' } as Pick<ChangelogSnapshot, 'expandedGroupKey'>;
assert.equal(shouldEntryStartExpanded(ENTRY, snapshot), true);
assert.equal(shouldEntryStartExpanded({ ...ENTRY, groupKey: '0.18' }, snapshot), false);
assert.equal(shouldEntryStartExpanded(ENTRY, { expandedGroupKey: null }), false);
});
test('entry node renders a collapsible section with badge and internal block', () => {
const node = withFakeDocument(() =>
createChangelogEntryNode(ENTRY, { expanded: true, badge: 'installed', index: 0 }),
) as unknown as FakeNode;
assert.equal(node.tagName, 'details');
assert.equal(node.open, true);
assert.equal(node.dataset.changelogVersion, '0.19.2');
const nodes = flatten(node);
const summary = nodes.find((child) => child.className === 'changelog-entry-summary');
assert.ok(summary);
assert.equal(summary?.dataset.changelogIndex, '0');
assert.equal(
nodes.find((child) => child.className === 'changelog-entry-version')?.textContent,
'v0.19.2',
);
assert.equal(
nodes.find((child) => child.className?.includes('changelog-entry-badge-installed'))
?.textContent,
'Installed',
);
assert.equal(
nodes.filter((child) => child.className === 'changelog-internal').length,
1,
'internal sections stay behind their own fold',
);
});
test('entry node renders sub-bullets as a nested list under their lead bullet', () => {
const node = withFakeDocument(() =>
createChangelogEntryNode(ENTRY, { expanded: true, badge: null, index: 0 }),
) as unknown as FakeNode;
const lists = flatten(node).filter((child) => child.className?.startsWith('changelog-items'));
const topLevel = lists.find((list) => list.className === 'changelog-items');
const nested = lists.filter((list) => list.className?.includes('changelog-items-nested'));
assert.ok(topLevel);
assert.equal(topLevel?.children.length, 2, 'lead bullet and standalone fix stay siblings');
assert.equal(nested.length, 1, 'children render in exactly one nested list');
assert.equal(nested[0]?.children.length, 2);
// The nested list hangs off its parent <li>, not off the section.
const leadItem = topLevel?.children[0];
assert.equal(
leadItem?.children.some((child) => child.className?.includes('changelog-items-nested')),
true,
);
});
test('entry node renders collapsed when it is outside the current line', () => {
const node = withFakeDocument(() =>
createChangelogEntryNode(ENTRY, { expanded: false, badge: null, index: 3 }),
) as unknown as FakeNode;
assert.equal(node.open, false);
assert.equal(
flatten(node).some((child) => child.className?.startsWith('changelog-entry-badge')),
false,
);
});
test('source description names the release the changelog came from', () => {
const base: ChangelogSnapshot = {
entries: [],
installedVersion: '0.19.2',
latestVersion: null,
expandedGroupKey: null,
source: 'remote',
};
assert.equal(
describeChangelogSource({ ...base, releaseTag: 'v0.20.0' }),
'Latest release v0.20.0',
);
assert.equal(describeChangelogSource(base), 'Latest published changelog');
assert.equal(describeChangelogSource({ ...base, source: 'bundled' }), 'Bundled changelog');
});
+220
View File
@@ -0,0 +1,220 @@
import type {
ChangelogEntry,
ChangelogItem,
ChangelogSection,
ChangelogSnapshot,
} from '../../types/changelog';
import { compareSemverLike } from '../../core/utils/semver-compare';
const SECTION_ICON: Record<string, string> = {
Added: '✦',
Changed: '⟲',
Fixed: '✔',
Docs: '▤',
Internal: '⚙',
'Breaking Changes': '⚠',
Changes: '•',
};
export type ChangelogEntryBadge = 'installed' | 'newer' | null;
export function resolveEntryBadge(
entryVersion: string,
installedVersion: string,
): ChangelogEntryBadge {
const comparison = compareSemverLike(entryVersion, installedVersion);
if (comparison === 0) return 'installed';
if (comparison > 0) return 'newer';
return null;
}
/**
* Version entries in the newest major.minor line start expanded, mirroring the
* docs-site changelog where older lines sit behind "Previous Versions".
*/
export function shouldEntryStartExpanded(
entry: ChangelogEntry,
snapshot: Pick<ChangelogSnapshot, 'expandedGroupKey'>,
): boolean {
if (!snapshot.expandedGroupKey) return false;
return entry.groupKey === snapshot.expandedGroupKey;
}
type InlineToken =
| { kind: 'text'; value: string }
| { kind: 'code'; value: string }
| { kind: 'strong'; value: string }
| { kind: 'link'; value: string; href: string };
/**
* Minimal inline-markdown tokenizer for changelog bullets: backtick code,
* bold, and links. Anything else stays literal text.
*/
export function tokenizeInlineMarkdown(text: string): InlineToken[] {
const tokens: InlineToken[] = [];
const pattern = /`([^`]+)`|\*\*([^*]+)\*\*|\[([^\]]+)\]\(([^)\s]+)\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
if (match.index > lastIndex) {
tokens.push({ kind: 'text', value: text.slice(lastIndex, match.index) });
}
if (match[1] !== undefined) {
tokens.push({ kind: 'code', value: match[1] });
} else if (match[2] !== undefined) {
tokens.push({ kind: 'strong', value: match[2] });
} else if (match[3] !== undefined && match[4] !== undefined) {
tokens.push({ kind: 'link', value: match[3], href: match[4] });
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
tokens.push({ kind: 'text', value: text.slice(lastIndex) });
}
return tokens;
}
function appendInlineMarkdown(target: HTMLElement, text: string): void {
for (const token of tokenizeInlineMarkdown(text)) {
if (token.kind === 'text') {
target.appendChild(document.createTextNode(token.value));
continue;
}
if (token.kind === 'code') {
const code = document.createElement('code');
code.className = 'changelog-code';
code.textContent = token.value;
target.appendChild(code);
continue;
}
if (token.kind === 'strong') {
const strong = document.createElement('strong');
strong.textContent = token.value;
target.appendChild(strong);
continue;
}
// Links stay inert: the overlay has nowhere to navigate to.
const link = document.createElement('span');
link.className = 'changelog-link';
link.textContent = token.value;
link.title = token.href;
target.appendChild(link);
}
}
function createItemList(items: ChangelogItem[], depth: number): HTMLUListElement {
const list = document.createElement('ul');
list.className = depth === 0 ? 'changelog-items' : 'changelog-items changelog-items-nested';
for (const item of items) {
const listItem = document.createElement('li');
listItem.className = 'changelog-item';
const text = document.createElement('span');
text.className = 'changelog-item-text';
appendInlineMarkdown(text, item.text);
listItem.appendChild(text);
if (item.children.length > 0) {
listItem.appendChild(createItemList(item.children, depth + 1));
}
list.appendChild(listItem);
}
return list;
}
function createSectionNode(section: ChangelogSection): HTMLElement {
const node = document.createElement('section');
node.className = 'changelog-section';
const title = document.createElement('h4');
title.className = 'changelog-section-title';
title.textContent = `${SECTION_ICON[section.heading] ?? '•'} ${section.heading}`;
node.appendChild(title);
node.appendChild(createItemList(section.items, 0));
return node;
}
function createInternalNode(sections: ChangelogSection[]): HTMLElement {
const details = document.createElement('details');
details.className = 'changelog-internal';
const summary = document.createElement('summary');
summary.className = 'changelog-internal-summary';
summary.textContent = 'Internal changes';
details.appendChild(summary);
for (const section of sections) {
details.appendChild(createSectionNode(section));
}
return details;
}
export function createChangelogEntryNode(
entry: ChangelogEntry,
options: { expanded: boolean; badge: ChangelogEntryBadge; index: number },
): HTMLDetailsElement {
const details = document.createElement('details');
details.className = 'changelog-entry';
details.open = options.expanded;
details.dataset.changelogVersion = entry.version;
const summary = document.createElement('summary');
summary.className = 'changelog-entry-summary';
summary.dataset.changelogIndex = String(options.index);
summary.tabIndex = -1;
const version = document.createElement('span');
version.className = 'changelog-entry-version';
version.textContent = `v${entry.version}`;
summary.appendChild(version);
if (entry.date) {
const date = document.createElement('span');
date.className = 'changelog-entry-date';
date.textContent = entry.date;
summary.appendChild(date);
}
if (options.badge) {
const badge = document.createElement('span');
badge.className = `changelog-entry-badge changelog-entry-badge-${options.badge}`;
badge.textContent = options.badge === 'installed' ? 'Installed' : 'New';
summary.appendChild(badge);
}
details.appendChild(summary);
const body = document.createElement('div');
body.className = 'changelog-entry-body';
const publicSections = entry.sections.filter((section) => !section.internal);
const internalSections = entry.sections.filter((section) => section.internal);
for (const section of publicSections) {
body.appendChild(createSectionNode(section));
}
if (internalSections.length > 0) {
body.appendChild(createInternalNode(internalSections));
}
if (publicSections.length === 0 && internalSections.length === 0) {
const empty = document.createElement('div');
empty.className = 'changelog-empty-entry';
empty.textContent = 'No release notes recorded for this version.';
body.appendChild(empty);
}
details.appendChild(body);
return details;
}
export function describeChangelogSource(snapshot: ChangelogSnapshot): string {
if (snapshot.source === 'remote') {
return snapshot.releaseTag
? `Latest release ${snapshot.releaseTag}`
: 'Latest published changelog';
}
return 'Bundled changelog';
}
+345
View File
@@ -0,0 +1,345 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ChangelogSnapshot } from '../../types/changelog';
import { createRendererState } from '../state.js';
import { createChangelogModal } from './changelog.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
toggle: (entry: string, force?: boolean) => {
if (force === true) tokens.add(entry);
else if (force === false) tokens.delete(entry);
else if (tokens.has(entry)) tokens.delete(entry);
else tokens.add(entry);
},
};
}
function createElementStub() {
return {
value: '',
textContent: '',
innerHTML: '',
classList: createClassList(['hidden']),
contains: () => false,
setAttribute: () => {},
addEventListener: () => {},
removeEventListener: () => {},
appendChild: () => {},
querySelectorAll: () => [],
focus: () => {},
};
}
const SNAPSHOT: ChangelogSnapshot = {
entries: [],
installedVersion: '0.19.2',
latestVersion: '0.19.2',
expandedGroupKey: '0.19',
source: 'remote',
releaseTag: 'v0.19.2',
};
type Harness = {
modal: ReturnType<typeof createChangelogModal>;
dom: Record<string, ReturnType<typeof createElementStub>>;
snapshotRequests: Array<{ refresh?: boolean } | undefined>;
modalClosedNotifications: string[];
restore: () => void;
};
function createHarness(
options: {
getChangelogSnapshot?: (request?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
} = {},
): Harness {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const previousHTMLElement = Object.getOwnPropertyDescriptor(globalThis, 'HTMLElement');
const previousElement = Object.getOwnPropertyDescriptor(globalThis, 'Element');
const snapshotRequests: Array<{ refresh?: boolean } | undefined> = [];
const modalClosedNotifications: string[] = [];
class TestElement {}
for (const name of ['HTMLElement', 'Element'] as const) {
Object.defineProperty(globalThis, name, {
configurable: true,
writable: true,
value: TestElement,
});
}
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
electronAPI: {
focusMainWindow: async () => {},
setIgnoreMouseEvents: () => {},
notifyOverlayModalClosed: (modal: string) => {
modalClosedNotifications.push(modal);
},
getChangelogSnapshot: async (request?: { refresh?: boolean }) => {
snapshotRequests.push(request);
return options.getChangelogSnapshot
? await options.getChangelogSnapshot(request)
: SNAPSHOT;
},
},
focus: () => {},
addEventListener: () => {},
removeEventListener: () => {},
setTimeout: (callback: () => void) => setTimeout(callback, 0),
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
activeElement: null,
addEventListener: () => {},
removeEventListener: () => {},
},
});
const dom = {
overlay: createElementStub(),
changelogModal: createElementStub(),
changelogClose: createElementStub(),
changelogRefresh: createElementStub(),
changelogInstalled: createElementStub(),
changelogSource: createElementStub(),
changelogWarning: createElementStub(),
changelogStatus: createElementStub(),
changelogList: createElementStub(),
};
const modal = createChangelogModal(
{
state: createRendererState(),
platform: {
overlayLayer: 'modal',
isModalLayer: true,
isLinuxPlatform: false,
isMacOSPlatform: false,
isWindowsPlatform: true,
shouldToggleMouseIgnore: false,
},
dom,
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
},
);
return {
modal,
dom,
snapshotRequests,
modalClosedNotifications,
restore: () => {
for (const [name, descriptor] of [
['window', previousWindow],
['document', previousDocument],
['HTMLElement', previousHTMLElement],
['Element', previousElement],
] as const) {
if (descriptor) {
Object.defineProperty(globalThis, name, descriptor);
} else {
delete (globalThis as Record<string, unknown>)[name];
}
}
},
};
}
test('changelog modal loads a snapshot on open and shows the installed version', async () => {
const harness = createHarness();
try {
harness.modal.openChangelogModal();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(harness.snapshotRequests, [undefined]);
assert.equal(harness.dom.changelogInstalled?.textContent, 'Installed v0.19.2');
assert.equal(harness.dom.changelogSource?.textContent, 'Latest release v0.19.2');
assert.equal(harness.dom.changelogModal?.classList.contains('hidden'), false);
} finally {
harness.restore();
}
});
test('changelog modal surfaces the bundled-fallback warning', async () => {
const harness = createHarness({
getChangelogSnapshot: async () => ({
...SNAPSHOT,
source: 'bundled',
releaseTag: undefined,
warning: 'Showing the bundled changelog: offline',
}),
});
try {
harness.modal.openChangelogModal();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
harness.dom.changelogWarning?.textContent,
'Showing the bundled changelog: offline',
);
assert.equal(harness.dom.changelogSource?.textContent, 'Bundled changelog');
} finally {
harness.restore();
}
});
test('changelog modal reports a load failure instead of hanging on the spinner text', async () => {
const harness = createHarness({
getChangelogSnapshot: async () => {
throw new Error('ipc down');
},
});
try {
harness.modal.openChangelogModal();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.match(
harness.dom.changelogList?.textContent ?? '',
/Changelog failed to load: ipc down/,
);
assert.equal(harness.dom.changelogStatus?.textContent, 'Press Esc to close.');
} finally {
harness.restore();
}
});
test('changelog modal clears stale metadata when a later open fails to load', async () => {
let shouldFail = false;
const harness = createHarness({
getChangelogSnapshot: async () => {
if (shouldFail) throw new Error('offline');
return SNAPSHOT;
},
});
try {
harness.modal.openChangelogModal();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(harness.dom.changelogInstalled?.textContent, 'Installed v0.19.2');
harness.modal.closeChangelogModal();
shouldFail = true;
harness.modal.openChangelogModal();
await new Promise((resolve) => setTimeout(resolve, 0));
// The previous session's values must not linger behind the error message.
assert.equal(harness.dom.changelogInstalled?.textContent, '');
assert.equal(harness.dom.changelogSource?.textContent, '');
assert.match(harness.dom.changelogList?.textContent ?? '', /Changelog failed to load: offline/);
} finally {
harness.restore();
}
});
test('changelog modal clears stale metadata when a refresh fails', async () => {
let shouldFail = false;
const harness = createHarness({
getChangelogSnapshot: async () => {
if (shouldFail) throw new Error('refresh offline');
return { ...SNAPSHOT, warning: 'Showing the bundled changelog: earlier failure' };
},
});
try {
harness.modal.openChangelogModal();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(harness.dom.changelogInstalled?.textContent, 'Installed v0.19.2');
shouldFail = true;
harness.modal.handleChangelogKeydown({
key: 'r',
preventDefault: () => {},
} as KeyboardEvent);
await new Promise((resolve) => setTimeout(resolve, 0));
// The rendered snapshot is gone, so nothing may still describe it.
assert.equal(harness.dom.changelogInstalled?.textContent, '');
assert.equal(harness.dom.changelogSource?.textContent, '');
assert.equal(harness.dom.changelogWarning?.textContent, '');
assert.match(
harness.dom.changelogList?.textContent ?? '',
/Changelog failed to load: refresh offline/,
);
} finally {
harness.restore();
}
});
test('changelog modal closes on Escape and notifies the main process', async () => {
const harness = createHarness();
try {
harness.modal.openChangelogModal();
await new Promise((resolve) => setTimeout(resolve, 0));
const handled = harness.modal.handleChangelogKeydown({
key: 'Escape',
preventDefault: () => {},
} as KeyboardEvent);
assert.equal(handled, true);
assert.deepEqual(harness.modalClosedNotifications, ['changelog']);
assert.equal(harness.dom.changelogModal?.classList.contains('hidden'), true);
} finally {
harness.restore();
}
});
test('changelog modal refetches on R and ignores keys while closed', async () => {
const harness = createHarness();
try {
assert.equal(
harness.modal.handleChangelogKeydown({ key: 'r', preventDefault: () => {} } as KeyboardEvent),
false,
);
harness.modal.openChangelogModal();
await new Promise((resolve) => setTimeout(resolve, 0));
harness.modal.handleChangelogKeydown({
key: 'r',
preventDefault: () => {},
} as KeyboardEvent);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(harness.snapshotRequests, [undefined, { refresh: true }]);
} finally {
harness.restore();
}
});
test('changelog modal drops a late in-flight load after close', async () => {
const pending: Array<(snapshot: ChangelogSnapshot) => void> = [];
const harness = createHarness({
getChangelogSnapshot: () =>
new Promise<ChangelogSnapshot>((resolve) => {
pending.push(resolve);
}),
});
try {
harness.modal.openChangelogModal();
harness.modal.closeChangelogModal();
pending[0]?.(SNAPSHOT);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(harness.dom.changelogInstalled?.textContent, '');
} finally {
harness.restore();
}
});
+351
View File
@@ -0,0 +1,351 @@
import type { ChangelogSnapshot } from '../../types/changelog';
import type { ModalStateReader, RendererContext } from '../context';
import {
createChangelogEntryNode,
describeChangelogSource,
resolveEntryBadge,
shouldEntryStartExpanded,
} from './changelog-render';
export function createChangelogModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
let priorFocus: Element | null = null;
let focusGuard: ((event: FocusEvent) => void) | null = null;
let windowFocusGuard: (() => void) | null = null;
let modalPointerFocusGuard: ((event: Event) => void) | null = null;
let isRecoveringModalFocus = false;
let lastFocusRecoveryAt = 0;
let loadToken = 0;
function getSummaries(): HTMLElement[] {
return Array.from(
ctx.dom.changelogList.querySelectorAll('.changelog-entry-summary'),
) as HTMLElement[];
}
function setSelected(index: number): void {
const summaries = getSummaries();
if (summaries.length === 0) return;
const wrapped = index % summaries.length;
const next = wrapped < 0 ? wrapped + summaries.length : wrapped;
ctx.state.changelogSelectedIndex = next;
summaries.forEach((summary, idx) => {
summary.classList.toggle('active', idx === next);
summary.tabIndex = idx === next ? 0 : -1;
});
const active = summaries[next];
if (!active) return;
active.focus({ preventScroll: true });
active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
function getSelectedEntry(): HTMLDetailsElement | null {
const summary = getSummaries()[ctx.state.changelogSelectedIndex];
const entry = summary?.parentElement;
return entry instanceof HTMLDetailsElement ? entry : null;
}
function isChangelogModalFocusTarget(target: EventTarget | null): boolean {
return target instanceof Element && ctx.dom.changelogModal.contains(target);
}
function focusFallbackTarget(): boolean {
if (!ctx.platform.isModalLayer) {
void window.electronAPI.focusMainWindow();
}
const firstSummary = getSummaries().find((summary) => summary.offsetParent !== null);
if (firstSummary) {
firstSummary.focus({ preventScroll: true });
return document.activeElement === firstSummary;
}
if (ctx.dom.changelogClose instanceof HTMLElement) {
ctx.dom.changelogClose.focus({ preventScroll: true });
return document.activeElement === ctx.dom.changelogClose;
}
window.focus();
return false;
}
function enforceModalFocus(): void {
if (!ctx.state.changelogModalOpen) return;
if (isChangelogModalFocusTarget(document.activeElement)) return;
if (isRecoveringModalFocus) return;
const now = Date.now();
if (now - lastFocusRecoveryAt < 120) return;
isRecoveringModalFocus = true;
lastFocusRecoveryAt = now;
focusFallbackTarget();
window.setTimeout(() => {
isRecoveringModalFocus = false;
}, 120);
}
function requestOverlayFocus(): void {
if (!ctx.platform.isModalLayer) {
void window.electronAPI.focusMainWindow();
}
}
function addPointerFocusListener(): void {
if (modalPointerFocusGuard) return;
modalPointerFocusGuard = () => {
requestOverlayFocus();
enforceModalFocus();
};
ctx.dom.changelogModal.addEventListener('pointerdown', modalPointerFocusGuard);
ctx.dom.changelogModal.addEventListener('click', modalPointerFocusGuard);
}
function removePointerFocusListener(): void {
if (!modalPointerFocusGuard) return;
ctx.dom.changelogModal.removeEventListener('pointerdown', modalPointerFocusGuard);
ctx.dom.changelogModal.removeEventListener('click', modalPointerFocusGuard);
modalPointerFocusGuard = null;
}
function startFocusRecoveryGuards(): void {
if (windowFocusGuard) return;
windowFocusGuard = () => {
requestOverlayFocus();
enforceModalFocus();
};
window.addEventListener('blur', windowFocusGuard);
window.addEventListener('focus', windowFocusGuard);
}
function stopFocusRecoveryGuards(): void {
if (!windowFocusGuard) return;
window.removeEventListener('blur', windowFocusGuard);
window.removeEventListener('focus', windowFocusGuard);
windowFocusGuard = null;
}
function renderSnapshot(snapshot: ChangelogSnapshot): void {
ctx.dom.changelogList.innerHTML = '';
ctx.dom.changelogList.classList.remove('changelog-list-empty');
ctx.dom.changelogInstalled.textContent = `Installed v${snapshot.installedVersion}`;
ctx.dom.changelogSource.textContent = describeChangelogSource(snapshot);
ctx.dom.changelogWarning.textContent = snapshot.warning ?? '';
if (snapshot.entries.length === 0) {
ctx.dom.changelogList.classList.add('changelog-list-empty');
ctx.dom.changelogList.textContent =
snapshot.error ?? 'No changelog entries are available right now.';
ctx.state.changelogSelectedIndex = 0;
return;
}
snapshot.entries.forEach((entry, index) => {
ctx.dom.changelogList.appendChild(
createChangelogEntryNode(entry, {
expanded: shouldEntryStartExpanded(entry, snapshot),
badge: resolveEntryBadge(entry.version, snapshot.installedVersion),
index,
}),
);
});
setSelected(0);
}
async function load(options?: { refresh?: boolean }): Promise<void> {
const token = ++loadToken;
ctx.dom.changelogStatus.textContent = options?.refresh
? 'Refreshing changelog...'
: 'Loading changelog...';
try {
const snapshot = await window.electronAPI.getChangelogSnapshot(options);
if (token !== loadToken || !ctx.state.changelogModalOpen) return;
renderSnapshot(snapshot);
ctx.dom.changelogStatus.textContent =
'J/K or arrows to move, Enter to fold, R to refresh, Esc closes.';
} catch (error) {
if (token !== loadToken || !ctx.state.changelogModalOpen) return;
const message = error instanceof Error ? error.message : 'Unknown error.';
ctx.dom.changelogList.innerHTML = '';
ctx.dom.changelogList.classList.add('changelog-list-empty');
// A failed refresh replaces an already-rendered snapshot, so the metadata
// line has to be cleared here too or it keeps describing stale entries.
ctx.dom.changelogInstalled.textContent = '';
ctx.dom.changelogSource.textContent = '';
ctx.dom.changelogWarning.textContent = '';
ctx.dom.changelogList.textContent = `Changelog failed to load: ${message}`;
ctx.dom.changelogStatus.textContent = 'Press Esc to close.';
}
}
function openChangelogModal(): void {
if (ctx.state.changelogModalOpen) return;
priorFocus = document.activeElement;
ctx.state.changelogModalOpen = true;
ctx.state.changelogSelectedIndex = 0;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.changelogModal.classList.remove('hidden');
ctx.dom.changelogModal.setAttribute('aria-hidden', 'false');
ctx.dom.changelogModal.setAttribute('tabindex', '-1');
ctx.dom.changelogList.innerHTML = '';
ctx.dom.changelogWarning.textContent = '';
// Reset the metadata line too, so a failed load can't leave the previous
// session's installed/source values on screen.
ctx.dom.changelogInstalled.textContent = '';
ctx.dom.changelogSource.textContent = '';
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
}
if (focusGuard === null) {
focusGuard = (event: FocusEvent) => {
if (!ctx.state.changelogModalOpen) return;
if (!isChangelogModalFocusTarget(event.target)) {
event.preventDefault();
enforceModalFocus();
}
};
document.addEventListener('focusin', focusGuard);
}
addPointerFocusListener();
startFocusRecoveryGuards();
requestOverlayFocus();
window.focus();
enforceModalFocus();
void load();
}
function closeChangelogModal(): void {
if (!ctx.state.changelogModalOpen) return;
ctx.state.changelogModalOpen = false;
loadToken += 1;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.changelogModal.classList.add('hidden');
ctx.dom.changelogModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('changelog');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
if (focusGuard) {
document.removeEventListener('focusin', focusGuard);
focusGuard = null;
}
removePointerFocusListener();
stopFocusRecoveryGuards();
if (priorFocus instanceof HTMLElement && priorFocus.isConnected) {
priorFocus.focus({ preventScroll: true });
} else if (ctx.dom.overlay instanceof HTMLElement) {
ctx.dom.overlay.focus({ preventScroll: true });
}
if (ctx.platform.shouldToggleMouseIgnore) {
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
} else {
window.electronAPI.setIgnoreMouseEvents(false);
}
}
window.focus();
}
function handleChangelogKeydown(e: KeyboardEvent): boolean {
if (!ctx.state.changelogModalOpen) return false;
if (e.key === 'Escape') {
e.preventDefault();
closeChangelogModal();
return true;
}
const key = e.key.toLowerCase();
if (key === 'r' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
void load({ refresh: true });
return true;
}
const summaries = getSummaries();
if (summaries.length === 0) return true;
if (key === 'arrowdown' || key === 'j') {
e.preventDefault();
setSelected(ctx.state.changelogSelectedIndex + 1);
return true;
}
if (key === 'arrowup' || key === 'k') {
e.preventDefault();
setSelected(ctx.state.changelogSelectedIndex - 1);
return true;
}
if (key === 'enter' || key === ' ') {
e.preventDefault();
const entry = getSelectedEntry();
if (entry) entry.open = !entry.open;
return true;
}
if (key === 'arrowleft' || key === 'h') {
e.preventDefault();
const entry = getSelectedEntry();
if (entry) entry.open = false;
return true;
}
if (key === 'arrowright' || key === 'l') {
e.preventDefault();
const entry = getSelectedEntry();
if (entry) entry.open = true;
return true;
}
return true;
}
function wireDomEvents(): void {
ctx.dom.changelogClose.addEventListener('click', () => {
closeChangelogModal();
});
ctx.dom.changelogRefresh.addEventListener('click', () => {
void load({ refresh: true });
});
ctx.dom.changelogList.addEventListener('click', (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
const summary = target.closest('.changelog-entry-summary') as HTMLElement | null;
if (!summary) return;
const index = Number.parseInt(summary.dataset.changelogIndex ?? '', 10);
if (!Number.isFinite(index)) return;
ctx.state.changelogSelectedIndex = index;
getSummaries().forEach((item, idx) => {
item.classList.toggle('active', idx === index);
item.tabIndex = idx === index ? 0 : -1;
});
});
}
return {
closeChangelogModal,
handleChangelogKeydown,
openChangelogModal,
wireDomEvents,
};
}
@@ -297,6 +297,88 @@ test('overlay notification action buttons send action ids', () => {
}
});
test('overlay notification keepOpen actions leave the card on screen', () => {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const stack = createFakeElement();
const sentActions: string[] = [];
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
createElement: (tagName: string) => createFakeElement(tagName),
},
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
clearTimeout: () => undefined,
setTimeout: () => 1,
electronAPI: {
sendOverlayNotificationAction: (_notificationId: string, actionId: string) => {
sentActions.push(actionId);
},
},
},
});
try {
const renderer = createOverlayNotificationRenderer({
dom: {
overlayNotificationStack: stack,
},
state: {
isOverOverlayNotification: false,
},
} as never);
renderer.show({
id: 'subminer-update-available',
title: 'SubMiner update available',
body: 'SubMiner v0.15.0 is available',
persistent: true,
actions: [
{ id: 'install-update', label: 'Update' },
{ id: 'view-changelog', label: "What's New", keepOpen: true },
],
});
const card = stack.children[0];
if (!card) {
assert.fail('Expected overlay notification card.');
}
const buttons: typeof card.children = [];
const collect = (node: typeof card): void => {
if (node.className === 'overlay-notification-action') buttons.push(node);
for (const child of node.children) collect(child);
};
collect(card);
assert.equal(buttons.length, 2);
// "What's New" opens the changelog but must not drop the Update affordance.
buttons[1]?.dispatchEventType('click');
assert.deepEqual(sentActions, ['view-changelog']);
assert.equal(card.classList.contains('leaving'), false);
buttons[0]?.dispatchEventType('click');
assert.deepEqual(sentActions, ['view-changelog', 'install-update']);
assert.equal(card.classList.contains('leaving'), true);
} finally {
if (originalDocument) {
Object.defineProperty(globalThis, 'document', originalDocument);
} else {
delete (globalThis as { document?: unknown }).document;
}
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
test('overlay notification renderer updates same-id progress without replacing the spinner', () => {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
+3 -1
View File
@@ -293,7 +293,9 @@ export function createOverlayNotificationRenderer(
window.electronAPI.sendOverlayNotificationAction?.(entry.id, action.id, {
noteId: action.noteId,
});
remove(entry.id);
if (action.keepOpen !== true) {
remove(entry.id);
}
});
actions.append(button);
}
+19
View File
@@ -38,6 +38,7 @@ import { createKikuModal } from './modals/kiku.js';
import { prepareForKikuFieldGroupingOpen } from './kiku-open.js';
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
import { createSessionHelpModal } from './modals/session-help.js';
import { createChangelogModal } from './modals/changelog.js';
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
@@ -149,6 +150,12 @@ const modalDescriptors = [
close: () => sessionHelpModal.closeSessionHelpModal(),
suppressesSubtitles: true,
},
{
id: 'changelog',
isOpen: () => ctx.state.changelogModalOpen,
close: () => changelogModal.closeChangelogModal(),
suppressesSubtitles: true,
},
] satisfies readonly ModalDescriptor<OverlayHostedModal>[];
const modalRegistry = createModalRegistry(modalDescriptors);
@@ -213,6 +220,10 @@ const sessionHelpModal = createSessionHelpModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const changelogModal = createChangelogModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
modalStateReader: { isAnyModalOpen },
shouldRestoreOpenOnStartup: async () =>
@@ -266,6 +277,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
handleChangelogKeydown: changelogModal.handleChangelogKeydown,
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
openControllerSelectModal: () => {
if (controllerSelectModal.openControllerSelectModal()) {
@@ -525,6 +537,12 @@ function registerModalOpenHandlers(): void {
window.electronAPI.notifyOverlayModalOpened('session-help');
});
});
window.electronAPI.onOpenChangelog(() => {
runGuarded('changelog:open', () => {
changelogModal.openChangelogModal();
window.electronAPI.notifyOverlayModalOpened('changelog');
});
});
window.electronAPI.onOpenControllerSelect(() => {
runGuarded('controller-select:open', () => {
if (controllerSelectModal.openControllerSelectModal()) {
@@ -794,6 +812,7 @@ async function init(): Promise<void> {
controllerSelectModal.wireDomEvents();
controllerDebugModal.wireDomEvents();
sessionHelpModal.wireDomEvents();
changelogModal.wireDomEvents();
subtitleSidebarModal.wireDomEvents();
characterDictionaryModal.wireDomEvents();
window.addEventListener('beforeunload', () => {
+4
View File
@@ -100,6 +100,8 @@ export type RendererState = {
sessionHelpModalOpen: boolean;
sessionHelpSelectedIndex: number;
changelogModalOpen: boolean;
changelogSelectedIndex: number;
playlistBrowserModalOpen: boolean;
playlistBrowserSnapshot: PlaylistBrowserSnapshot | null;
playlistBrowserStatus: string;
@@ -228,6 +230,8 @@ export function createRendererState(): RendererState {
sessionHelpModalOpen: false,
sessionHelpSelectedIndex: 0,
changelogModalOpen: false,
changelogSelectedIndex: 0,
playlistBrowserModalOpen: false,
playlistBrowserSnapshot: null,
playlistBrowserStatus: '',
+264
View File
@@ -3182,3 +3182,267 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
text-align: center;
}
}
/* Changelog modal */
.changelog-content {
width: min(820px, 94%);
max-height: 86%;
color: var(--ctp-text);
}
.changelog-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.changelog-refresh {
min-height: 28px;
padding: 5px 12px;
border-radius: 7px;
border: 1px solid rgba(110, 115, 141, 0.3);
background: rgba(49, 50, 68, 0.76);
color: var(--ctp-subtext1);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.changelog-refresh:hover,
.changelog-refresh:focus-visible {
border-color: rgba(138, 173, 244, 0.5);
color: var(--ctp-text);
outline: none;
}
.changelog-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
font-size: 12px;
}
.changelog-installed {
padding: 3px 10px;
border-radius: 999px;
border: 1px solid rgba(166, 218, 149, 0.45);
background: rgba(166, 218, 149, 0.14);
color: var(--ctp-green);
font-weight: 700;
letter-spacing: 0.02em;
}
.changelog-source {
color: var(--ctp-subtext0);
}
.changelog-warning {
min-height: 0;
font-size: 12px;
line-height: 1.45;
color: var(--ctp-yellow);
}
.changelog-warning:empty {
display: none;
}
.changelog-status {
min-height: 18px;
font-size: 12px;
color: var(--ctp-subtext1);
}
.changelog-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: calc(86vh - 190px);
overflow-y: auto;
padding-right: 4px;
}
.changelog-list-empty {
color: var(--ctp-subtext0);
padding: 12px;
font-size: 13px;
}
.changelog-entry {
/* Flex children shrink by default, which crushes expanded entries inside the
scrolling list; pin them to their content height instead. */
flex: 0 0 auto;
border: 1px solid rgba(110, 115, 141, 0.18);
border-radius: 10px;
background: rgba(54, 58, 79, 0.16);
overflow: hidden;
}
.changelog-entry[open] {
border-color: rgba(138, 173, 244, 0.28);
background: rgba(54, 58, 79, 0.26);
}
.changelog-entry-summary {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
cursor: pointer;
list-style: none;
user-select: none;
}
.changelog-entry-summary::-webkit-details-marker {
display: none;
}
.changelog-entry-summary::before {
content: '▸';
color: var(--ctp-overlay1);
font-size: 11px;
transition: transform 120ms ease;
}
.changelog-entry[open] > .changelog-entry-summary::before {
transform: rotate(90deg);
}
.changelog-entry-summary:hover,
.changelog-entry-summary:focus-visible,
.changelog-entry-summary.active {
background: rgba(138, 173, 244, 0.12);
outline: none;
}
.changelog-entry-summary.active {
box-shadow: inset 3px 0 0 var(--ctp-yellow);
}
.changelog-entry-version {
font-size: 14px;
font-weight: 800;
color: var(--ctp-text);
}
.changelog-entry-date {
font-size: 12px;
color: var(--ctp-subtext0);
}
.changelog-entry-badge {
margin-left: auto;
padding: 2px 9px;
border-radius: 999px;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.changelog-entry-badge-installed {
border: 1px solid rgba(166, 218, 149, 0.5);
background: rgba(166, 218, 149, 0.16);
color: var(--ctp-green);
}
.changelog-entry-badge-newer {
border: 1px solid rgba(238, 212, 159, 0.55);
background: rgba(238, 212, 159, 0.16);
color: var(--ctp-yellow);
}
.changelog-entry-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 4px 14px 14px;
}
.changelog-section-title {
margin: 0 0 6px;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 800;
color: var(--ctp-blue);
}
.changelog-items {
display: flex;
flex-direction: column;
gap: 7px;
margin: 0;
padding-left: 18px;
list-style: disc;
}
.changelog-items-nested {
gap: 5px;
margin-top: 6px;
padding-left: 16px;
list-style: circle;
}
.changelog-item {
font-size: 13px;
line-height: 1.55;
color: var(--ctp-subtext1);
}
.changelog-item::marker {
color: var(--ctp-overlay1);
}
.changelog-items-nested > .changelog-item {
font-size: 12.5px;
color: var(--ctp-subtext0);
}
.changelog-item strong {
color: var(--ctp-text);
}
.changelog-code {
padding: 1px 5px;
border-radius: 5px;
background: rgba(24, 25, 38, 0.85);
color: var(--ctp-peach);
font-family: monospace;
font-size: 12px;
}
.changelog-link {
color: var(--ctp-blue);
text-decoration: underline dotted;
}
.changelog-internal {
border-top: 1px dashed rgba(110, 115, 141, 0.25);
padding-top: 10px;
}
.changelog-internal-summary {
cursor: pointer;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ctp-overlay1);
}
.changelog-empty-entry {
font-size: 12px;
color: var(--ctp-subtext0);
}
@media (max-width: 640px) {
.changelog-list {
max-height: calc(86vh - 200px);
}
.changelog-entry-badge {
margin-left: 0;
}
}
+17
View File
@@ -120,6 +120,15 @@ export type RendererDom = {
subtitleSidebarStatus: HTMLDivElement;
subtitleSidebarList: HTMLUListElement;
changelogModal: HTMLDivElement;
changelogClose: HTMLButtonElement;
changelogRefresh: HTMLButtonElement;
changelogInstalled: HTMLSpanElement;
changelogSource: HTMLSpanElement;
changelogWarning: HTMLDivElement;
changelogStatus: HTMLDivElement;
changelogList: HTMLDivElement;
sessionHelpModal: HTMLDivElement;
sessionHelpClose: HTMLButtonElement;
sessionHelpShortcut: HTMLDivElement;
@@ -289,6 +298,14 @@ export function resolveRendererDom(): RendererDom {
subtitleSidebarStatus: getRequiredElement<HTMLDivElement>('subtitleSidebarStatus'),
subtitleSidebarList: getRequiredElement<HTMLUListElement>('subtitleSidebarList'),
changelogModal: getRequiredElement<HTMLDivElement>('changelogModal'),
changelogClose: getRequiredElement<HTMLButtonElement>('changelogClose'),
changelogRefresh: getRequiredElement<HTMLButtonElement>('changelogRefresh'),
changelogInstalled: getRequiredElement<HTMLSpanElement>('changelogInstalled'),
changelogSource: getRequiredElement<HTMLSpanElement>('changelogSource'),
changelogWarning: getRequiredElement<HTMLDivElement>('changelogWarning'),
changelogStatus: getRequiredElement<HTMLDivElement>('changelogStatus'),
changelogList: getRequiredElement<HTMLDivElement>('changelogList'),
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'),
+3
View File
@@ -14,6 +14,7 @@ export const OVERLAY_HOSTED_MODALS = [
'subtitle-sidebar',
'session-help',
'character-dictionary',
'changelog',
] as const;
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
@@ -123,6 +124,7 @@ export const IPC_CHANNELS = {
syncUiDeleteSnapshot: 'sync-ui:delete-snapshot',
syncUiRevealSnapshot: 'sync-ui:reveal-snapshot',
syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file',
getChangelogSnapshot: 'changelog:get-snapshot',
},
event: {
subtitleSet: 'subtitle:set',
@@ -146,6 +148,7 @@ export const IPC_CHANNELS = {
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
sessionHelpOpen: 'session-help:open',
changelogOpen: 'changelog:open',
characterDictionaryManagerOpen: 'character-dictionary:manager-open',
controllerSelectOpen: 'controller-select:open',
controllerDebugOpen: 'controller-debug:open',
+1
View File
@@ -1,4 +1,5 @@
export * from './types/anki';
export * from './types/changelog';
export * from './types/config';
export * from './types/integrations';
export * from './types/notification';
+41
View File
@@ -0,0 +1,41 @@
export type ChangelogItem = {
/** Bullet text with inline markdown preserved. */
text: string;
/** Nested bullets, as written with indentation in CHANGELOG.md. */
children: ChangelogItem[];
};
export type ChangelogSection = {
/** Section heading as written in CHANGELOG.md, e.g. "Added", "Fixed". */
heading: string;
items: ChangelogItem[];
/** True when the section lived inside the collapsed "Internal changes" block. */
internal: boolean;
};
export type ChangelogEntry = {
version: string;
date: string;
/** major.minor of `version`, used to decide which entries render expanded. */
groupKey: string;
sections: ChangelogSection[];
};
export type ChangelogSourceKind = 'remote' | 'bundled';
export type ChangelogSnapshot = {
entries: ChangelogEntry[];
/** Version this build reports, e.g. "0.19.2". */
installedVersion: string;
/** Newest version present in the fetched changelog, or null when empty. */
latestVersion: string | null;
/** Group key whose entries should start expanded. */
expandedGroupKey: string | null;
source: ChangelogSourceKind;
/** Release tag the remote changelog was read from, when known. */
releaseTag?: string;
/** Populated when the remote fetch failed and a fallback was used. */
warning?: string;
/** Populated when no changelog could be loaded at all. */
error?: string;
};
+2
View File
@@ -20,6 +20,8 @@ export interface OverlayNotificationAction {
id: string;
label: string;
noteId?: number;
/** Leaves the notification on screen after the action fires (default: dismiss). */
keepOpen?: boolean;
}
export interface OverlayNotificationPayload {
+7 -2
View File
@@ -4,6 +4,7 @@ import type {
KikuMergePreviewRequest,
KikuMergePreviewResponse,
} from './anki';
import type { ChangelogSnapshot } from './changelog';
import type { ResolvedConfig, ShortcutsConfig } from './config';
import type {
CompiledSessionBinding,
@@ -507,6 +508,8 @@ export interface ElectronAPI {
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
onOpenRuntimeOptions: (callback: () => void) => void;
onOpenSessionHelp: (callback: () => void) => void;
onOpenChangelog: (callback: () => void) => void;
getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
onOpenControllerSelect: (callback: () => void) => void;
onOpenControllerDebug: (callback: () => void) => void;
onOpenJimaku: (callback: () => void) => void;
@@ -563,7 +566,8 @@ export interface ElectronAPI {
| 'controller-debug'
| 'subtitle-sidebar'
| 'session-help'
| 'character-dictionary',
| 'character-dictionary'
| 'changelog',
) => void;
notifyOverlayModalOpened: (
modal:
@@ -578,7 +582,8 @@ export interface ElectronAPI {
| 'controller-debug'
| 'subtitle-sidebar'
| 'session-help'
| 'character-dictionary',
| 'character-dictionary'
| 'changelog',
) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;