mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-06 07:21:33 -07:00
feat(overlay): add in-app changelog modal (#187)
This commit is contained in:
@@ -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.
|
||||||
@@ -75,8 +75,8 @@ src/
|
|||||||
renderer/ # Overlay renderer (modularized UI/runtime)
|
renderer/ # Overlay renderer (modularized UI/runtime)
|
||||||
handlers/ # Keyboard/mouse/gamepad interaction modules
|
handlers/ # Keyboard/mouse/gamepad interaction modules
|
||||||
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
|
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
|
||||||
# character dictionary, playlist browser, subtitle sidebar,
|
# changelog, character dictionary, playlist browser, subtitle
|
||||||
# YouTube track picker, controller config/debug/select)
|
# sidebar, YouTube track picker, controller config/debug/select)
|
||||||
positioning/ # Subtitle position controller (drag-to-reposition)
|
positioning/ # Subtitle position controller (drag-to-reposition)
|
||||||
settings/ # Settings window UI (model, controls, markup)
|
settings/ # Settings window UI (model, controls, markup)
|
||||||
types/ # Domain type modules (anki, config, integrations, ...)
|
types/ # Domain type modules (anki, config, integrations, ...)
|
||||||
|
|||||||
@@ -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.
|
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
|
### Logging and App Mode
|
||||||
|
|
||||||
- `--log-level` controls logger verbosity.
|
- `--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.
|
`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.
|
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
|
### Drag-and-Drop
|
||||||
|
|||||||
@@ -260,6 +260,10 @@
|
|||||||
{
|
{
|
||||||
"from": "dist/launcher/subminer",
|
"from": "dist/launcher/subminer",
|
||||||
"to": "launcher/subminer"
|
"to": "launcher/subminer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "CHANGELOG.md",
|
||||||
|
"to": "CHANGELOG.md"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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`.
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import electron from 'electron';
|
import electron from 'electron';
|
||||||
import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron';
|
import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron';
|
||||||
import type {
|
import type {
|
||||||
|
ChangelogSnapshot,
|
||||||
CompiledSessionBinding,
|
CompiledSessionBinding,
|
||||||
ControllerConfigUpdate,
|
ControllerConfigUpdate,
|
||||||
PlaylistBrowserMutationResult,
|
PlaylistBrowserMutationResult,
|
||||||
@@ -122,6 +123,7 @@ export interface IpcServiceDeps {
|
|||||||
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
|
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
|
||||||
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
|
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
|
||||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||||
|
getChangelogSnapshot?: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||||
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
||||||
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
||||||
@@ -297,6 +299,7 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
|
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
|
||||||
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
|
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
|
||||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||||
|
getChangelogSnapshot?: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||||
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
||||||
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
||||||
@@ -418,6 +421,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
entries: [],
|
entries: [],
|
||||||
})),
|
})),
|
||||||
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
|
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
|
||||||
|
getChangelogSnapshot: options.getChangelogSnapshot,
|
||||||
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
|
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
|
||||||
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
|
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
|
||||||
playPlaylistBrowserIndex: options.playPlaylistBrowserIndex,
|
playPlaylistBrowserIndex: options.playPlaylistBrowserIndex,
|
||||||
@@ -820,6 +824,17 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
return deps.appendClipboardVideoToQueue();
|
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 () => {
|
ipc.handle(IPC_CHANNELS.request.getPlaylistBrowserSnapshot, async () => {
|
||||||
return await deps.getPlaylistBrowserSnapshot();
|
return await deps.getPlaylistBrowserSnapshot();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
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 reads prerelease and build metadata version headings', () => {
|
||||||
|
const entries = parseChangelog(
|
||||||
|
[
|
||||||
|
'## v0.16.0 (2026-06-01)',
|
||||||
|
'',
|
||||||
|
'### Added',
|
||||||
|
'- New in 0.16.',
|
||||||
|
'',
|
||||||
|
'## v0.15.0-rc.1+build.2 (2026-05-29)',
|
||||||
|
'',
|
||||||
|
'### Added',
|
||||||
|
'- Release candidate note.',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The prerelease heading has to become its own entry. Asserting the exact
|
||||||
|
// version list is what catches the failure mode: a heading the regex misses
|
||||||
|
// is not skipped, its notes silently fold into the release above it.
|
||||||
|
assert.deepEqual(
|
||||||
|
entries.map((entry) => entry.version),
|
||||||
|
['0.16.0', '0.15.0-rc.1+build.2'],
|
||||||
|
);
|
||||||
|
assert.equal(entries[1]?.date, '2026-05-29');
|
||||||
|
assert.equal(entries[1]?.groupKey, '0.15');
|
||||||
|
assert.equal(entries[0]?.sections.length, 1);
|
||||||
|
// The prerelease body has to land on its own entry, not fold into 0.16.0.
|
||||||
|
assert.deepEqual(entries[1]?.sections, [
|
||||||
|
{
|
||||||
|
heading: 'Added',
|
||||||
|
items: [{ text: 'Release candidate note.', children: [] }],
|
||||||
|
internal: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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'],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { ChangelogEntry, ChangelogItem, ChangelogSection } from '../../types/changelog';
|
||||||
|
|
||||||
|
// Prerelease and build metadata are matched separately: a single `[-+]`-led
|
||||||
|
// group cannot span `-rc.1+build.2`, and an unmatched heading silently folds
|
||||||
|
// that release's notes into the previous entry.
|
||||||
|
const VERSION_HEADING =
|
||||||
|
/^##\s+v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An indented non-bullet line continues the bullet above it, including
|
||||||
|
// across a blank line: that is CommonMark's continuation paragraph, and
|
||||||
|
// dropping the open bullets here would silently discard the text.
|
||||||
|
if (!trimmed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^\s/.test(line)) {
|
||||||
|
appendContinuation(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.map((item) => ({
|
||||||
|
...item,
|
||||||
|
sections: item.sections.filter((entrySection) => entrySection.items.length > 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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
@@ -468,6 +468,8 @@ import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku
|
|||||||
import { openTsukihimeModal as openTsukihimeModalRuntime } from './main/runtime/tsukihime-open';
|
import { openTsukihimeModal as openTsukihimeModalRuntime } from './main/runtime/tsukihime-open';
|
||||||
import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open';
|
import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open';
|
||||||
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-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 { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open';
|
||||||
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
|
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
|
||||||
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
|
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
|
||||||
@@ -506,6 +508,7 @@ import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'
|
|||||||
import {
|
import {
|
||||||
INSTALL_UPDATE_ACTION_ID,
|
INSTALL_UPDATE_ACTION_ID,
|
||||||
UPDATE_AVAILABLE_NOTIFICATION_ID,
|
UPDATE_AVAILABLE_NOTIFICATION_ID,
|
||||||
|
VIEW_CHANGELOG_ACTION_ID,
|
||||||
} from './main/runtime/update/update-notifications';
|
} from './main/runtime/update/update-notifications';
|
||||||
import { createOverlayNotificationsRuntime } from './main/runtime/overlay-notifications-runtime';
|
import { createOverlayNotificationsRuntime } from './main/runtime/overlay-notifications-runtime';
|
||||||
import {
|
import {
|
||||||
@@ -2828,6 +2831,14 @@ function openSessionHelpOverlay(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openChangelogOverlay(): void {
|
||||||
|
openOverlayHostedModalWithOsd(
|
||||||
|
openChangelogModalRuntime,
|
||||||
|
'Changelog overlay unavailable.',
|
||||||
|
'Failed to open changelog overlay.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function openCharacterDictionaryManagerOverlay(): void {
|
function openCharacterDictionaryManagerOverlay(): void {
|
||||||
openCharacterDictionaryManagerWithConfigGate({
|
openCharacterDictionaryManagerWithConfigGate({
|
||||||
isCharacterDictionaryEnabled: () => configService.getConfig().subtitleStyle.nameMatchEnabled,
|
isCharacterDictionaryEnabled: () => configService.getConfig().subtitleStyle.nameMatchEnabled,
|
||||||
@@ -5095,6 +5106,18 @@ flushPendingMpvLogWrites = () => {
|
|||||||
void flushMpvLog();
|
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({
|
const { getUpdateService } = createUpdateServiceRuntime({
|
||||||
userDataPath: USER_DATA_PATH,
|
userDataPath: USER_DATA_PATH,
|
||||||
getUpdatesConfig: () => configService.getConfig().updates,
|
getUpdatesConfig: () => configService.getConfig().updates,
|
||||||
@@ -5463,6 +5486,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
logger.warn('Failed to install update from overlay notification action:', error);
|
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) {
|
if (actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined) {
|
||||||
void openAnkiCardFromNotification(noteId).catch((error) => {
|
void openAnkiCardFromNotification(noteId).catch((error) => {
|
||||||
logger.warn('Failed to open Anki card from overlay notification action:', error);
|
logger.warn('Failed to open Anki card from overlay notification action:', error);
|
||||||
@@ -5728,6 +5757,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
appendClipboardVideoToQueue: () => appendClipboardVideoToQueueHandler(),
|
appendClipboardVideoToQueue: () => appendClipboardVideoToQueueHandler(),
|
||||||
|
getChangelogSnapshot: (options) => getChangelogSnapshot(options),
|
||||||
...playlistBrowserMainDeps,
|
...playlistBrowserMainDeps,
|
||||||
getImmersionTracker: () => appState.immersionTracker,
|
getImmersionTracker: () => appState.immersionTracker,
|
||||||
},
|
},
|
||||||
@@ -6118,6 +6148,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
|||||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||||
openSessionHelpModal: () => openSessionHelpOverlay(),
|
openSessionHelpModal: () => openSessionHelpOverlay(),
|
||||||
|
openChangelogModal: () => openChangelogOverlay(),
|
||||||
openTexthookerInBrowser: () =>
|
openTexthookerInBrowser: () =>
|
||||||
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
|
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
|
||||||
showTexthookerPage: () => shouldShowTexthookerTrayEntry(configService.getConfig()),
|
showTexthookerPage: () => shouldShowTexthookerTrayEntry(configService.getConfig()),
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
removeCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['removeCharacterDictionaryManagedEntry'];
|
removeCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['removeCharacterDictionaryManagedEntry'];
|
||||||
moveCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['moveCharacterDictionaryManagedEntry'];
|
moveCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['moveCharacterDictionaryManagedEntry'];
|
||||||
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
|
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
|
||||||
|
getChangelogSnapshot?: IpcDepsRuntimeOptions['getChangelogSnapshot'];
|
||||||
getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot'];
|
getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot'];
|
||||||
appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile'];
|
appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile'];
|
||||||
playPlaylistBrowserIndex: IpcDepsRuntimeOptions['playPlaylistBrowserIndex'];
|
playPlaylistBrowserIndex: IpcDepsRuntimeOptions['playPlaylistBrowserIndex'];
|
||||||
@@ -302,6 +303,7 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
removeCharacterDictionaryManagedEntry: params.removeCharacterDictionaryManagedEntry,
|
removeCharacterDictionaryManagedEntry: params.removeCharacterDictionaryManagedEntry,
|
||||||
moveCharacterDictionaryManagedEntry: params.moveCharacterDictionaryManagedEntry,
|
moveCharacterDictionaryManagedEntry: params.moveCharacterDictionaryManagedEntry,
|
||||||
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
|
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
|
||||||
|
getChangelogSnapshot: params.getChangelogSnapshot,
|
||||||
getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot,
|
getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot,
|
||||||
appendPlaylistBrowserFile: params.appendPlaylistBrowserFile,
|
appendPlaylistBrowserFile: params.appendPlaylistBrowserFile,
|
||||||
playPlaylistBrowserIndex: params.playPlaylistBrowserIndex,
|
playPlaylistBrowserIndex: params.playPlaylistBrowserIndex,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
|
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||||
|
|
||||||
|
const CHANGELOG_MODAL: OverlayHostedModal = 'changelog';
|
||||||
|
const CHANGELOG_OPEN_TIMEOUT_MS = 1500;
|
||||||
|
|
||||||
|
export async function openChangelogModal(deps: {
|
||||||
|
ensureOverlayStartupPrereqs: () => void;
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||||
|
sendToActiveOverlayWindow: (
|
||||||
|
channel: string,
|
||||||
|
payload?: unknown,
|
||||||
|
runtimeOptions?: {
|
||||||
|
restoreOnModalClose?: OverlayHostedModal;
|
||||||
|
preferModalWindow?: boolean;
|
||||||
|
},
|
||||||
|
) => boolean;
|
||||||
|
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||||
|
logWarn: (message: string) => void;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
return await retryOverlayModalOpen(
|
||||||
|
{
|
||||||
|
waitForModalOpen: deps.waitForModalOpen,
|
||||||
|
logWarn: deps.logWarn,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modal: CHANGELOG_MODAL,
|
||||||
|
timeoutMs: CHANGELOG_OPEN_TIMEOUT_MS,
|
||||||
|
retryWarning:
|
||||||
|
'Changelog modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||||
|
sendOpen: () =>
|
||||||
|
openOverlayHostedModal(
|
||||||
|
{
|
||||||
|
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions:
|
||||||
|
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||||
|
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: IPC_CHANNELS.event.changelogOpen,
|
||||||
|
modal: CHANGELOG_MODAL,
|
||||||
|
preferModalWindow: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { readBundledChangelog, resolveBundledChangelogPath } from './bundled-changelog';
|
||||||
|
|
||||||
|
test('bundled changelog path prefers the packaged resources copy', () => {
|
||||||
|
const resolved = resolveBundledChangelogPath({
|
||||||
|
resourcesPath: '/res',
|
||||||
|
appPath: '/app',
|
||||||
|
dirname: '/app/dist/main',
|
||||||
|
joinPath: (...parts) => parts.join('/'),
|
||||||
|
fileExists: (candidate) => candidate === '/res/CHANGELOG.md',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolved, '/res/CHANGELOG.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bundled changelog path falls back to the repo root during development', () => {
|
||||||
|
const resolved = resolveBundledChangelogPath({
|
||||||
|
resourcesPath: '/res',
|
||||||
|
appPath: '/app',
|
||||||
|
dirname: '/repo/dist/main',
|
||||||
|
joinPath: (...parts) => parts.join('/'),
|
||||||
|
fileExists: (candidate) => candidate === '/repo/dist/main/../../CHANGELOG.md',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolved, '/repo/dist/main/../../CHANGELOG.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bundled changelog returns null when no copy is installed', () => {
|
||||||
|
const result = readBundledChangelog({
|
||||||
|
resolvePath: () => null,
|
||||||
|
readFile: () => {
|
||||||
|
throw new Error('should not read');
|
||||||
|
},
|
||||||
|
logWarn: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bundled changelog logs and returns null when the file cannot be read', () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const result = readBundledChangelog({
|
||||||
|
resolvePath: () => '/res/CHANGELOG.md',
|
||||||
|
readFile: () => {
|
||||||
|
throw new Error('EACCES');
|
||||||
|
},
|
||||||
|
logWarn: (message) => warnings.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, null);
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
assert.match(warnings[0] ?? '', /EACCES/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export function resolveBundledChangelogPath(deps: {
|
||||||
|
resourcesPath: string;
|
||||||
|
appPath: string;
|
||||||
|
dirname: string;
|
||||||
|
joinPath: (...parts: string[]) => string;
|
||||||
|
fileExists: (path: string) => boolean;
|
||||||
|
}): string | null {
|
||||||
|
const candidates = [
|
||||||
|
deps.joinPath(deps.resourcesPath, 'CHANGELOG.md'),
|
||||||
|
deps.joinPath(deps.appPath, 'CHANGELOG.md'),
|
||||||
|
deps.joinPath(deps.dirname, '..', 'CHANGELOG.md'),
|
||||||
|
deps.joinPath(deps.dirname, '..', '..', 'CHANGELOG.md'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return candidates.find((candidate) => deps.fileExists(candidate)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readBundledChangelog(deps: {
|
||||||
|
resolvePath: () => string | null;
|
||||||
|
readFile: (path: string) => string;
|
||||||
|
logWarn: (message: string) => void;
|
||||||
|
}): string | null {
|
||||||
|
const changelogPath = deps.resolvePath();
|
||||||
|
if (!changelogPath) return null;
|
||||||
|
try {
|
||||||
|
return deps.readFile(changelogPath);
|
||||||
|
} catch (error) {
|
||||||
|
deps.logWarn(
|
||||||
|
`Failed to read bundled changelog at ${changelogPath}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CHANGELOG_REQUEST_TIMEOUT_MS,
|
||||||
|
createChangelogRuntime,
|
||||||
|
withRequestTimeout,
|
||||||
|
} from './changelog-runtime';
|
||||||
|
import type { FetchLike, FetchResponseLike } from '../update/release-assets';
|
||||||
|
import { createCurlFetch } from '../update/fetch-adapter';
|
||||||
|
|
||||||
|
function okResponse(body: string): FetchResponseLike {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => JSON.parse(body),
|
||||||
|
text: async () => body,
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('request timeout wrapper attaches an abort signal to every request', async () => {
|
||||||
|
const seen: Array<{ url: string; init?: Record<string, unknown> }> = [];
|
||||||
|
const wrapped = withRequestTimeout(async (url, init) => {
|
||||||
|
seen.push({ url, init });
|
||||||
|
return okResponse('body');
|
||||||
|
}, 1_234);
|
||||||
|
|
||||||
|
await wrapped('https://example.test/a');
|
||||||
|
await wrapped('https://example.test/b', { headers: { 'User-Agent': 'SubMiner' } });
|
||||||
|
|
||||||
|
assert.equal(seen.length, 2);
|
||||||
|
for (const request of seen) {
|
||||||
|
assert.ok(request.init?.signal instanceof AbortSignal, 'each request carries a signal');
|
||||||
|
assert.equal((request.init?.signal as AbortSignal).aborted, false);
|
||||||
|
}
|
||||||
|
// Existing init is preserved rather than replaced.
|
||||||
|
assert.deepEqual(seen[1]?.init?.headers, { 'User-Agent': 'SubMiner' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('request timeout wrapper aborts a request that never settles', async () => {
|
||||||
|
let observed: AbortSignal | undefined;
|
||||||
|
const wrapped = withRequestTimeout((_url, init) => {
|
||||||
|
observed = init?.signal as AbortSignal;
|
||||||
|
return new Promise<FetchResponseLike>(() => {
|
||||||
|
// Never resolves, standing in for a stalled connection.
|
||||||
|
});
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
void wrapped('https://example.test/stalled');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||||
|
|
||||||
|
assert.equal(observed?.aborted, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog runtime times out both requests instead of hanging the modal', async () => {
|
||||||
|
const signals: AbortSignal[] = [];
|
||||||
|
// Stays pending until the deadline fires, standing in for a stalled server.
|
||||||
|
const stalling: FetchLike = (_url, init) => {
|
||||||
|
const signal = init?.signal as AbortSignal;
|
||||||
|
signals.push(signal);
|
||||||
|
return new Promise((_resolve, reject) => {
|
||||||
|
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtime = createChangelogRuntime({
|
||||||
|
getInstalledVersion: () => '0.19.2',
|
||||||
|
getUpdateChannel: () => 'stable',
|
||||||
|
resourcesPath: '/res',
|
||||||
|
appPath: '/app',
|
||||||
|
dirname: '/app/dist/main',
|
||||||
|
joinPath: (...parts) => parts.join('/'),
|
||||||
|
fileExists: () => false,
|
||||||
|
readFile: () => '',
|
||||||
|
logWarn: () => {},
|
||||||
|
createFetch: () => withRequestTimeout(stalling, 10),
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await runtime.getChangelogSnapshot();
|
||||||
|
|
||||||
|
// Release lookup and changelog download both hit the deadline rather than hang.
|
||||||
|
assert.equal(signals.length, 2);
|
||||||
|
assert.ok(
|
||||||
|
signals.every((signal) => signal.aborted),
|
||||||
|
'every stalled request was aborted',
|
||||||
|
);
|
||||||
|
// No bundled copy is readable here, so the timeout surfaces as an error state
|
||||||
|
// that does not claim a bundled changelog is on screen.
|
||||||
|
assert.match(snapshot.error ?? '', /^Changelog unavailable: /);
|
||||||
|
assert.match(snapshot.error ?? '', /timed out|timeout|abort/i);
|
||||||
|
assert.deepEqual(snapshot.entries, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog timeout reaches the curl transport, not just global fetch', async () => {
|
||||||
|
// The POSIX transport is curl, whose own --max-time is 60s; the changelog
|
||||||
|
// deadline is shorter, so the signal has to actually terminate the process.
|
||||||
|
let killed: string | undefined;
|
||||||
|
const curlFetch = createCurlFetch({
|
||||||
|
execFile: ((
|
||||||
|
_file: string,
|
||||||
|
_args: readonly string[],
|
||||||
|
_options: unknown,
|
||||||
|
_callback: unknown,
|
||||||
|
) => ({
|
||||||
|
kill: (signal?: string) => {
|
||||||
|
killed = signal;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
})) as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapped = withRequestTimeout(curlFetch, 10);
|
||||||
|
await assert.rejects(wrapped('https://example.test/stalled'));
|
||||||
|
|
||||||
|
assert.equal(killed, 'SIGKILL', 'the stalled curl process is killed at the changelog deadline');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog request timeout is finite', () => {
|
||||||
|
assert.ok(Number.isFinite(CHANGELOG_REQUEST_TIMEOUT_MS));
|
||||||
|
assert.ok(CHANGELOG_REQUEST_TIMEOUT_MS > 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { ChangelogSnapshot } from '../../../types/changelog';
|
||||||
|
import type { UpdateChannel } from '../../../types/config';
|
||||||
|
import { createCurlFetch, createGlobalFetch } from '../update/fetch-adapter';
|
||||||
|
import { fetchLatestStableRelease, type FetchLike } from '../update/release-assets';
|
||||||
|
import { readBundledChangelog, resolveBundledChangelogPath } from './bundled-changelog';
|
||||||
|
import { createChangelogSource } from './changelog-source';
|
||||||
|
|
||||||
|
export interface ChangelogRuntimeDeps {
|
||||||
|
getInstalledVersion: () => string;
|
||||||
|
getUpdateChannel: () => UpdateChannel;
|
||||||
|
resourcesPath: string;
|
||||||
|
appPath: string;
|
||||||
|
dirname: string;
|
||||||
|
joinPath: (...parts: string[]) => string;
|
||||||
|
fileExists: (path: string) => boolean;
|
||||||
|
readFile: (path: string) => string;
|
||||||
|
logWarn: (message: string) => void;
|
||||||
|
/** Injected in tests; production picks curl on POSIX and global fetch on Windows. */
|
||||||
|
createFetch?: () => FetchLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* curl enforces its own `--max-time`, but the global-fetch transport has no
|
||||||
|
* deadline: without this a stalled connection leaves the modal on "Loading
|
||||||
|
* changelog..." with no way back except closing it.
|
||||||
|
*/
|
||||||
|
export const CHANGELOG_REQUEST_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
export function withRequestTimeout(fetchImpl: FetchLike, timeoutMs: number): FetchLike {
|
||||||
|
return (url, init) => {
|
||||||
|
if (typeof AbortSignal?.timeout !== 'function') return fetchImpl(url, init);
|
||||||
|
return fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChangelogRuntime(deps: ChangelogRuntimeDeps): {
|
||||||
|
getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||||
|
} {
|
||||||
|
// curl matches the updater's transport choice: Electron's global fetch is
|
||||||
|
// unreliable for GitHub on some Linux builds.
|
||||||
|
const fetchImpl = withRequestTimeout(
|
||||||
|
deps.createFetch?.() ??
|
||||||
|
(process.platform === 'win32' ? createGlobalFetch() : createCurlFetch()),
|
||||||
|
CHANGELOG_REQUEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const source = createChangelogSource({
|
||||||
|
fetchLatestReleaseTag: async () => {
|
||||||
|
const release = await fetchLatestStableRelease({
|
||||||
|
fetch: fetchImpl,
|
||||||
|
channel: deps.getUpdateChannel(),
|
||||||
|
});
|
||||||
|
return release?.tag_name ?? null;
|
||||||
|
},
|
||||||
|
fetchText: async (url) => {
|
||||||
|
const response = await fetchImpl(url, {
|
||||||
|
headers: { 'User-Agent': 'SubMiner changelog' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Changelog request failed with ${response.status}`);
|
||||||
|
}
|
||||||
|
return await response.text();
|
||||||
|
},
|
||||||
|
readBundledChangelog: () =>
|
||||||
|
readBundledChangelog({
|
||||||
|
resolvePath: () =>
|
||||||
|
resolveBundledChangelogPath({
|
||||||
|
resourcesPath: deps.resourcesPath,
|
||||||
|
appPath: deps.appPath,
|
||||||
|
dirname: deps.dirname,
|
||||||
|
joinPath: deps.joinPath,
|
||||||
|
fileExists: deps.fileExists,
|
||||||
|
}),
|
||||||
|
readFile: deps.readFile,
|
||||||
|
logWarn: deps.logWarn,
|
||||||
|
}),
|
||||||
|
getInstalledVersion: deps.getInstalledVersion,
|
||||||
|
now: () => Date.now(),
|
||||||
|
logWarn: deps.logWarn,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
getChangelogSnapshot: (options?: { refresh?: boolean }) => source.getSnapshot(options),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { ChangelogSnapshot, ChangelogSourceKind } from '../../../types/changelog';
|
||||||
|
import { parseChangelog } from '../../../core/utils/changelog-parse';
|
||||||
|
import { compareSemverLike } from '../update/release-assets';
|
||||||
|
|
||||||
|
export function buildChangelogSnapshot(
|
||||||
|
markdown: string,
|
||||||
|
options: {
|
||||||
|
installedVersion: string;
|
||||||
|
source: ChangelogSourceKind;
|
||||||
|
releaseTag?: string;
|
||||||
|
warning?: string;
|
||||||
|
},
|
||||||
|
): ChangelogSnapshot {
|
||||||
|
const entries = parseChangelog(markdown);
|
||||||
|
const latest = entries.reduce<string | null>(
|
||||||
|
(best, entry) =>
|
||||||
|
best === null || compareSemverLike(entry.version, best) > 0 ? entry.version : best,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const latestEntry = entries.find((entry) => entry.version === latest) ?? entries[0] ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
installedVersion: options.installedVersion,
|
||||||
|
latestVersion: latest,
|
||||||
|
expandedGroupKey: latestEntry?.groupKey ?? null,
|
||||||
|
source: options.source,
|
||||||
|
...(options.releaseTag ? { releaseTag: options.releaseTag } : {}),
|
||||||
|
...(options.warning ? { warning: options.warning } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEmptyChangelogSnapshot(options: {
|
||||||
|
installedVersion: string;
|
||||||
|
error: string;
|
||||||
|
}): ChangelogSnapshot {
|
||||||
|
return {
|
||||||
|
entries: [],
|
||||||
|
installedVersion: options.installedVersion,
|
||||||
|
latestVersion: null,
|
||||||
|
expandedGroupKey: null,
|
||||||
|
source: 'bundled',
|
||||||
|
error: options.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { buildRawChangelogUrl, createChangelogSource } from './changelog-source';
|
||||||
|
import { buildChangelogSnapshot } from './changelog-snapshot';
|
||||||
|
|
||||||
|
const REMOTE = `# Changelog
|
||||||
|
|
||||||
|
## v0.20.0 (2026-09-01)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Remote only entry.
|
||||||
|
|
||||||
|
## v0.19.2 (2026-08-04)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Installed entry.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BUNDLED = `# Changelog
|
||||||
|
|
||||||
|
## v0.19.2 (2026-08-04)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Installed entry.
|
||||||
|
`;
|
||||||
|
|
||||||
|
function createDeps(overrides: Partial<Parameters<typeof createChangelogSource>[0]> = {}) {
|
||||||
|
return {
|
||||||
|
fetchLatestReleaseTag: async () => 'v0.20.0',
|
||||||
|
fetchText: async () => REMOTE,
|
||||||
|
readBundledChangelog: () => BUNDLED,
|
||||||
|
getInstalledVersion: () => '0.19.2',
|
||||||
|
now: () => 1_000,
|
||||||
|
logWarn: () => {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('changelog source reads the changelog at the latest release tag', async () => {
|
||||||
|
const urls: string[] = [];
|
||||||
|
const source = createChangelogSource(
|
||||||
|
createDeps({
|
||||||
|
fetchText: async (url: string) => {
|
||||||
|
urls.push(url);
|
||||||
|
return REMOTE;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await source.getSnapshot();
|
||||||
|
|
||||||
|
assert.deepEqual(urls, [
|
||||||
|
'https://raw.githubusercontent.com/ksyasuda/SubMiner/v0.20.0/CHANGELOG.md',
|
||||||
|
]);
|
||||||
|
assert.equal(snapshot.source, 'remote');
|
||||||
|
assert.equal(snapshot.releaseTag, 'v0.20.0');
|
||||||
|
assert.equal(snapshot.latestVersion, '0.20.0');
|
||||||
|
assert.equal(snapshot.installedVersion, '0.19.2');
|
||||||
|
assert.equal(snapshot.expandedGroupKey, '0.20');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog source falls back to the default branch when no release tag resolves', async () => {
|
||||||
|
const urls: string[] = [];
|
||||||
|
const source = createChangelogSource(
|
||||||
|
createDeps({
|
||||||
|
fetchLatestReleaseTag: async () => null,
|
||||||
|
fetchText: async (url: string) => {
|
||||||
|
urls.push(url);
|
||||||
|
return REMOTE;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await source.getSnapshot();
|
||||||
|
|
||||||
|
assert.deepEqual(urls, ['https://raw.githubusercontent.com/ksyasuda/SubMiner/main/CHANGELOG.md']);
|
||||||
|
assert.equal(snapshot.source, 'remote');
|
||||||
|
assert.equal(snapshot.releaseTag, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog source falls back to the bundled changelog when the download fails', async () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const source = createChangelogSource(
|
||||||
|
createDeps({
|
||||||
|
fetchText: async () => {
|
||||||
|
throw new Error('offline');
|
||||||
|
},
|
||||||
|
logWarn: (message: string) => warnings.push(message),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await source.getSnapshot();
|
||||||
|
|
||||||
|
assert.equal(snapshot.source, 'bundled');
|
||||||
|
assert.match(snapshot.warning ?? '', /offline/);
|
||||||
|
assert.equal(snapshot.latestVersion, '0.19.2');
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog source reports an error when no changelog can be loaded', async () => {
|
||||||
|
const source = createChangelogSource(
|
||||||
|
createDeps({
|
||||||
|
fetchText: async () => {
|
||||||
|
throw new Error('offline');
|
||||||
|
},
|
||||||
|
readBundledChangelog: () => null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await source.getSnapshot();
|
||||||
|
|
||||||
|
assert.deepEqual(snapshot.entries, []);
|
||||||
|
assert.match(snapshot.error ?? '', /offline/);
|
||||||
|
// Nothing is rendered, so the message must not promise a bundled changelog.
|
||||||
|
assert.match(snapshot.error ?? '', /^Changelog unavailable: /);
|
||||||
|
assert.doesNotMatch(snapshot.error ?? '', /Showing the bundled changelog/);
|
||||||
|
assert.equal(snapshot.installedVersion, '0.19.2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog source caches remote results and refreshes on demand', async () => {
|
||||||
|
let fetches = 0;
|
||||||
|
let clock = 0;
|
||||||
|
const source = createChangelogSource(
|
||||||
|
createDeps({
|
||||||
|
now: () => clock,
|
||||||
|
fetchText: async () => {
|
||||||
|
fetches += 1;
|
||||||
|
return REMOTE;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await source.getSnapshot();
|
||||||
|
await source.getSnapshot();
|
||||||
|
assert.equal(fetches, 1);
|
||||||
|
|
||||||
|
await source.getSnapshot({ refresh: true });
|
||||||
|
assert.equal(fetches, 2);
|
||||||
|
|
||||||
|
clock = 11 * 60 * 1000;
|
||||||
|
await source.getSnapshot();
|
||||||
|
assert.equal(fetches, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog source retries the network after a bundled fallback', async () => {
|
||||||
|
let fetches = 0;
|
||||||
|
const source = createChangelogSource(
|
||||||
|
createDeps({
|
||||||
|
fetchText: async () => {
|
||||||
|
fetches += 1;
|
||||||
|
throw new Error('offline');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await source.getSnapshot();
|
||||||
|
await source.getSnapshot();
|
||||||
|
|
||||||
|
assert.equal(fetches, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog source treats an empty remote changelog as a failure', async () => {
|
||||||
|
const source = createChangelogSource(createDeps({ fetchText: async () => ' ' }));
|
||||||
|
|
||||||
|
const snapshot = await source.getSnapshot();
|
||||||
|
|
||||||
|
assert.equal(snapshot.source, 'bundled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog source falls back when the remote body parses to no releases', () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const source = createChangelogSource(
|
||||||
|
createDeps({
|
||||||
|
// A 200 that is not a changelog, e.g. a redirect landing page.
|
||||||
|
fetchText: async () => '<!doctype html><html><body>Moved</body></html>',
|
||||||
|
logWarn: (message: string) => warnings.push(message),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return source.getSnapshot().then((snapshot) => {
|
||||||
|
assert.equal(snapshot.source, 'bundled');
|
||||||
|
assert.equal(snapshot.entries.length, 1);
|
||||||
|
assert.match(snapshot.warning ?? '', /no releases/);
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('raw changelog urls encode the release ref', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildRawChangelogUrl('v1.0.0', 'owner', 'repo'),
|
||||||
|
'https://raw.githubusercontent.com/owner/repo/v1.0.0/CHANGELOG.md',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot expansion uses the newest version even when file order is unsorted', () => {
|
||||||
|
const snapshot = buildChangelogSnapshot(
|
||||||
|
'## v0.18.0 (2026-01-01)\n\n### Fixed\n- Old.\n\n## v0.19.0 (2026-02-01)\n\n### Fixed\n- New.\n',
|
||||||
|
{ installedVersion: '0.18.0', source: 'bundled' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(snapshot.latestVersion, '0.19.0');
|
||||||
|
assert.equal(snapshot.expandedGroupKey, '0.19');
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { ChangelogSnapshot } from '../../../types/changelog';
|
||||||
|
import { buildChangelogSnapshot, buildEmptyChangelogSnapshot } from './changelog-snapshot';
|
||||||
|
|
||||||
|
const DEFAULT_OWNER = 'ksyasuda';
|
||||||
|
const DEFAULT_REPO = 'SubMiner';
|
||||||
|
const DEFAULT_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface ChangelogSourceDeps {
|
||||||
|
/** Resolves the release the changelog should be read from, or null when unknown. */
|
||||||
|
fetchLatestReleaseTag: () => Promise<string | null>;
|
||||||
|
fetchText: (url: string) => Promise<string>;
|
||||||
|
/** Reads the CHANGELOG.md shipped with the install; null when unavailable. */
|
||||||
|
readBundledChangelog: () => string | null;
|
||||||
|
getInstalledVersion: () => string;
|
||||||
|
now: () => number;
|
||||||
|
logWarn: (message: string) => void;
|
||||||
|
owner?: string;
|
||||||
|
repo?: string;
|
||||||
|
cacheTtlMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRawChangelogUrl(ref: string, owner: string, repo: string): string {
|
||||||
|
return `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(ref)}/CHANGELOG.md`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChangelogSource(deps: ChangelogSourceDeps): {
|
||||||
|
getSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||||
|
} {
|
||||||
|
const owner = deps.owner ?? DEFAULT_OWNER;
|
||||||
|
const repo = deps.repo ?? DEFAULT_REPO;
|
||||||
|
const cacheTtlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
||||||
|
|
||||||
|
let cached: { snapshot: ChangelogSnapshot; fetchedAt: number } | null = null;
|
||||||
|
let inFlight: Promise<ChangelogSnapshot> | null = null;
|
||||||
|
|
||||||
|
/** `reason` is the raw failure; each branch phrases it for what it can show. */
|
||||||
|
function fallbackToBundled(reason: string): ChangelogSnapshot {
|
||||||
|
const bundled = deps.readBundledChangelog();
|
||||||
|
if (bundled === null) {
|
||||||
|
// Nothing is on screen, so promising a bundled changelog would be a lie.
|
||||||
|
return buildEmptyChangelogSnapshot({
|
||||||
|
installedVersion: deps.getInstalledVersion(),
|
||||||
|
error: `Changelog unavailable: ${reason}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return buildChangelogSnapshot(bundled, {
|
||||||
|
installedVersion: deps.getInstalledVersion(),
|
||||||
|
source: 'bundled',
|
||||||
|
warning: `Showing the bundled changelog: ${reason}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSnapshot(): Promise<ChangelogSnapshot> {
|
||||||
|
let releaseTag: string | null = null;
|
||||||
|
try {
|
||||||
|
releaseTag = await deps.fetchLatestReleaseTag();
|
||||||
|
} catch (error) {
|
||||||
|
deps.logWarn(`Changelog release lookup failed: ${summarize(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a release tag the default branch still gives the newest published
|
||||||
|
// changelog, so try it before falling back to the bundled copy.
|
||||||
|
const ref = releaseTag ?? 'main';
|
||||||
|
try {
|
||||||
|
const markdown = await deps.fetchText(buildRawChangelogUrl(ref, owner, repo));
|
||||||
|
if (markdown.trim().length === 0) {
|
||||||
|
throw new Error('Remote changelog was empty.');
|
||||||
|
}
|
||||||
|
const snapshot = buildChangelogSnapshot(markdown, {
|
||||||
|
installedVersion: deps.getInstalledVersion(),
|
||||||
|
source: 'remote',
|
||||||
|
...(releaseTag ? { releaseTag } : {}),
|
||||||
|
});
|
||||||
|
// A 200 that isn't a changelog (a redirect landing page, a renamed repo)
|
||||||
|
// parses to nothing; the bundled copy beats showing an empty modal.
|
||||||
|
if (snapshot.entries.length === 0) {
|
||||||
|
throw new Error('Remote changelog contained no releases.');
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
} catch (error) {
|
||||||
|
const message = summarize(error);
|
||||||
|
deps.logWarn(`Changelog download failed (${ref}): ${message}`);
|
||||||
|
return fallbackToBundled(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async getSnapshot(options?: { refresh?: boolean }): Promise<ChangelogSnapshot> {
|
||||||
|
const refresh = options?.refresh === true;
|
||||||
|
if (!refresh && cached && deps.now() - cached.fetchedAt < cacheTtlMs) {
|
||||||
|
return cached.snapshot;
|
||||||
|
}
|
||||||
|
if (inFlight) return await inFlight;
|
||||||
|
|
||||||
|
inFlight = loadSnapshot()
|
||||||
|
.then((snapshot) => {
|
||||||
|
// Only a successful remote read is worth caching; a bundled fallback
|
||||||
|
// should retry the network on the next open.
|
||||||
|
if (snapshot.source === 'remote') {
|
||||||
|
cached = { snapshot, fetchedAt: deps.now() };
|
||||||
|
} else {
|
||||||
|
cached = null;
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inFlight = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return await inFlight;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ test('build tray template handler wires actions and init guards', () => {
|
|||||||
},
|
},
|
||||||
isOverlayRuntimeInitialized: () => initialized,
|
isOverlayRuntimeInitialized: () => initialized,
|
||||||
openSessionHelpModal: () => calls.push('help'),
|
openSessionHelpModal: () => calls.push('help'),
|
||||||
|
openChangelogModal: () => calls.push('changelog'),
|
||||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||||
showTexthookerPage: () => true,
|
showTexthookerPage: () => true,
|
||||||
showFirstRunSetup: () => true,
|
showFirstRunSetup: () => true,
|
||||||
@@ -120,6 +121,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
|
|||||||
initializeOverlayRuntime: () => calls.push('init'),
|
initializeOverlayRuntime: () => calls.push('init'),
|
||||||
isOverlayRuntimeInitialized: () => true,
|
isOverlayRuntimeInitialized: () => true,
|
||||||
openSessionHelpModal: () => calls.push('help'),
|
openSessionHelpModal: () => calls.push('help'),
|
||||||
|
openChangelogModal: () => calls.push('changelog'),
|
||||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||||
showTexthookerPage: () => true,
|
showTexthookerPage: () => true,
|
||||||
showFirstRunSetup: () => false,
|
showFirstRunSetup: () => false,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
|||||||
buildTrayMenuTemplateRuntime: (handlers: {
|
buildTrayMenuTemplateRuntime: (handlers: {
|
||||||
platform?: string;
|
platform?: string;
|
||||||
openSessionHelp: () => void;
|
openSessionHelp: () => void;
|
||||||
|
openChangelog: () => void;
|
||||||
openTexthookerInBrowser: () => void;
|
openTexthookerInBrowser: () => void;
|
||||||
showTexthookerPage: boolean;
|
showTexthookerPage: boolean;
|
||||||
openFirstRunSetup: () => void;
|
openFirstRunSetup: () => void;
|
||||||
@@ -60,6 +61,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
|||||||
initializeOverlayRuntime: () => void;
|
initializeOverlayRuntime: () => void;
|
||||||
isOverlayRuntimeInitialized: () => boolean;
|
isOverlayRuntimeInitialized: () => boolean;
|
||||||
openSessionHelpModal: () => void;
|
openSessionHelpModal: () => void;
|
||||||
|
openChangelogModal: () => void;
|
||||||
openTexthookerInBrowser: () => void;
|
openTexthookerInBrowser: () => void;
|
||||||
showTexthookerPage: () => boolean;
|
showTexthookerPage: () => boolean;
|
||||||
showFirstRunSetup: () => boolean;
|
showFirstRunSetup: () => boolean;
|
||||||
@@ -87,6 +89,12 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
|||||||
}
|
}
|
||||||
deps.openSessionHelpModal();
|
deps.openSessionHelpModal();
|
||||||
},
|
},
|
||||||
|
openChangelog: () => {
|
||||||
|
if (!deps.isOverlayRuntimeInitialized()) {
|
||||||
|
deps.initializeOverlayRuntime();
|
||||||
|
}
|
||||||
|
deps.openChangelogModal();
|
||||||
|
},
|
||||||
openTexthookerInBrowser: () => {
|
openTexthookerInBrowser: () => {
|
||||||
deps.openTexthookerInBrowser();
|
deps.openTexthookerInBrowser();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ test('tray main deps builders return mapped handlers', () => {
|
|||||||
initializeOverlayRuntime: () => calls.push('init'),
|
initializeOverlayRuntime: () => calls.push('init'),
|
||||||
isOverlayRuntimeInitialized: () => false,
|
isOverlayRuntimeInitialized: () => false,
|
||||||
openSessionHelpModal: () => calls.push('help'),
|
openSessionHelpModal: () => calls.push('help'),
|
||||||
|
openChangelogModal: () => calls.push('changelog'),
|
||||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||||
showTexthookerPage: () => true,
|
showTexthookerPage: () => true,
|
||||||
showFirstRunSetup: () => true,
|
showFirstRunSetup: () => true,
|
||||||
@@ -50,6 +51,7 @@ test('tray main deps builders return mapped handlers', () => {
|
|||||||
const template = menuDeps.buildTrayMenuTemplateRuntime({
|
const template = menuDeps.buildTrayMenuTemplateRuntime({
|
||||||
platform: menuDeps.platform,
|
platform: menuDeps.platform,
|
||||||
openSessionHelp: () => calls.push('open-help'),
|
openSessionHelp: () => calls.push('open-help'),
|
||||||
|
openChangelog: () => calls.push('open-changelog'),
|
||||||
openTexthookerInBrowser: () => calls.push('open-texthooker'),
|
openTexthookerInBrowser: () => calls.push('open-texthooker'),
|
||||||
showTexthookerPage: true,
|
showTexthookerPage: true,
|
||||||
openFirstRunSetup: () => calls.push('open-setup'),
|
openFirstRunSetup: () => calls.push('open-setup'),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
|||||||
buildTrayMenuTemplateRuntime: (handlers: {
|
buildTrayMenuTemplateRuntime: (handlers: {
|
||||||
platform?: string;
|
platform?: string;
|
||||||
openSessionHelp: () => void;
|
openSessionHelp: () => void;
|
||||||
|
openChangelog: () => void;
|
||||||
openTexthookerInBrowser: () => void;
|
openTexthookerInBrowser: () => void;
|
||||||
showTexthookerPage: boolean;
|
showTexthookerPage: boolean;
|
||||||
openFirstRunSetup: () => void;
|
openFirstRunSetup: () => void;
|
||||||
@@ -50,6 +51,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
|||||||
initializeOverlayRuntime: () => void;
|
initializeOverlayRuntime: () => void;
|
||||||
isOverlayRuntimeInitialized: () => boolean;
|
isOverlayRuntimeInitialized: () => boolean;
|
||||||
openSessionHelpModal: () => void;
|
openSessionHelpModal: () => void;
|
||||||
|
openChangelogModal: () => void;
|
||||||
openTexthookerInBrowser: () => void;
|
openTexthookerInBrowser: () => void;
|
||||||
showTexthookerPage: () => boolean;
|
showTexthookerPage: () => boolean;
|
||||||
showFirstRunSetup: () => boolean;
|
showFirstRunSetup: () => boolean;
|
||||||
@@ -74,6 +76,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
|||||||
initializeOverlayRuntime: deps.initializeOverlayRuntime,
|
initializeOverlayRuntime: deps.initializeOverlayRuntime,
|
||||||
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
|
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
|
||||||
openSessionHelpModal: deps.openSessionHelpModal,
|
openSessionHelpModal: deps.openSessionHelpModal,
|
||||||
|
openChangelogModal: deps.openChangelogModal,
|
||||||
openTexthookerInBrowser: deps.openTexthookerInBrowser,
|
openTexthookerInBrowser: deps.openTexthookerInBrowser,
|
||||||
showTexthookerPage: deps.showTexthookerPage,
|
showTexthookerPage: deps.showTexthookerPage,
|
||||||
showFirstRunSetup: deps.showFirstRunSetup,
|
showFirstRunSetup: deps.showFirstRunSetup,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
|||||||
},
|
},
|
||||||
isOverlayRuntimeInitialized: () => overlayInitialized,
|
isOverlayRuntimeInitialized: () => overlayInitialized,
|
||||||
openSessionHelpModal: () => {},
|
openSessionHelpModal: () => {},
|
||||||
|
openChangelogModal: () => {},
|
||||||
openTexthookerInBrowser: () => {},
|
openTexthookerInBrowser: () => {},
|
||||||
showTexthookerPage: () => true,
|
showTexthookerPage: () => true,
|
||||||
showFirstRunSetup: () => true,
|
showFirstRunSetup: () => true,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
|||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const template = buildTrayMenuTemplateRuntime({
|
const template = buildTrayMenuTemplateRuntime({
|
||||||
openSessionHelp: () => calls.push('help'),
|
openSessionHelp: () => calls.push('help'),
|
||||||
|
openChangelog: () => calls.push('changelog'),
|
||||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||||
showTexthookerPage: true,
|
showTexthookerPage: true,
|
||||||
openFirstRunSetup: () => calls.push('setup'),
|
openFirstRunSetup: () => calls.push('setup'),
|
||||||
@@ -49,36 +50,53 @@ test('tray menu template contains expected entries and handlers', () => {
|
|||||||
quitApp: () => calls.push('quit'),
|
quitApp: () => calls.push('quit'),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(template.length, 14);
|
// Resolve by label, not index: adding a menu entry should not force every
|
||||||
assert.equal(
|
// later assertion in this test to be renumbered.
|
||||||
template.some((entry) => entry.label === 'Open Runtime Options'),
|
const entryFor = (label: string) => {
|
||||||
false,
|
const entry = template.find((candidate) => candidate.label === label);
|
||||||
|
assert.ok(entry, `expected a "${label}" tray entry`);
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
template.map((entry) => entry.label ?? `<${entry.type}>`),
|
||||||
|
[
|
||||||
|
'Open Help',
|
||||||
|
'View Changelog',
|
||||||
|
'Open Texthooker',
|
||||||
|
'Complete Setup',
|
||||||
|
'Open SubMiner Setup',
|
||||||
|
'Open Yomitan Settings',
|
||||||
|
'Open SubMiner Settings',
|
||||||
|
'Sync Stats && History',
|
||||||
|
'Export Logs',
|
||||||
|
'Configure Jellyfin',
|
||||||
|
'Jellyfin Discovery',
|
||||||
|
'Configure AniList',
|
||||||
|
'Check for Updates',
|
||||||
|
'<separator>',
|
||||||
|
'Quit',
|
||||||
|
],
|
||||||
);
|
);
|
||||||
assert.equal(
|
|
||||||
template.some((entry) => entry.label === 'Open Overlay'),
|
const discovery = entryFor('Jellyfin Discovery');
|
||||||
false,
|
assert.equal(discovery.type, 'checkbox');
|
||||||
);
|
assert.equal(discovery.checked, false);
|
||||||
assert.equal(template[0]!.label, 'Open Help');
|
discovery.click?.({ checked: true });
|
||||||
assert.equal(template[3]!.label, 'Open SubMiner Setup');
|
|
||||||
const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery');
|
entryFor('Open Help').click?.();
|
||||||
assert.equal(discovery?.type, 'checkbox');
|
entryFor('View Changelog').click?.();
|
||||||
assert.equal(discovery?.checked, false);
|
entryFor('Open Texthooker').click?.();
|
||||||
discovery?.click?.({ checked: true });
|
entryFor('Sync Stats && History').click?.();
|
||||||
template[0]!.click?.();
|
entryFor('Export Logs').click?.();
|
||||||
assert.equal(template[1]!.label, 'Open Texthooker');
|
entryFor('Check for Updates').click?.();
|
||||||
template[1]!.click?.();
|
calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad');
|
||||||
assert.equal(template[5]!.label, 'Open SubMiner Settings');
|
entryFor('Quit').click?.();
|
||||||
assert.equal(template[6]!.label, 'Sync Stats && History');
|
|
||||||
template[6]!.click?.();
|
|
||||||
assert.equal(template[7]!.label, 'Export Logs');
|
|
||||||
template[7]!.click?.();
|
|
||||||
assert.equal(template[11]!.label, 'Check for Updates');
|
|
||||||
template[11]!.click?.();
|
|
||||||
template[12]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
|
||||||
template[13]!.click?.();
|
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'jellyfin-discovery:true',
|
'jellyfin-discovery:true',
|
||||||
'help',
|
'help',
|
||||||
|
'changelog',
|
||||||
'texthooker',
|
'texthooker',
|
||||||
'sync-ui',
|
'sync-ui',
|
||||||
'export-logs',
|
'export-logs',
|
||||||
@@ -91,6 +109,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
|||||||
test('tray menu template omits first-run setup entry when setup is complete', () => {
|
test('tray menu template omits first-run setup entry when setup is complete', () => {
|
||||||
const labels = buildTrayMenuTemplateRuntime({
|
const labels = buildTrayMenuTemplateRuntime({
|
||||||
openSessionHelp: () => undefined,
|
openSessionHelp: () => undefined,
|
||||||
|
openChangelog: () => undefined,
|
||||||
openTexthookerInBrowser: () => undefined,
|
openTexthookerInBrowser: () => undefined,
|
||||||
showTexthookerPage: true,
|
showTexthookerPage: true,
|
||||||
openFirstRunSetup: () => undefined,
|
openFirstRunSetup: () => undefined,
|
||||||
@@ -120,6 +139,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
|||||||
test('tray menu template omits texthooker entry when texthooker page is disabled', () => {
|
test('tray menu template omits texthooker entry when texthooker page is disabled', () => {
|
||||||
const labels = buildTrayMenuTemplateRuntime({
|
const labels = buildTrayMenuTemplateRuntime({
|
||||||
openSessionHelp: () => undefined,
|
openSessionHelp: () => undefined,
|
||||||
|
openChangelog: () => undefined,
|
||||||
openTexthookerInBrowser: () => undefined,
|
openTexthookerInBrowser: () => undefined,
|
||||||
showTexthookerPage: false,
|
showTexthookerPage: false,
|
||||||
openFirstRunSetup: () => undefined,
|
openFirstRunSetup: () => undefined,
|
||||||
@@ -147,6 +167,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
|
|||||||
test('tray menu template renders active jellyfin discovery checkbox', () => {
|
test('tray menu template renders active jellyfin discovery checkbox', () => {
|
||||||
const template = buildTrayMenuTemplateRuntime({
|
const template = buildTrayMenuTemplateRuntime({
|
||||||
openSessionHelp: () => undefined,
|
openSessionHelp: () => undefined,
|
||||||
|
openChangelog: () => undefined,
|
||||||
openTexthookerInBrowser: () => undefined,
|
openTexthookerInBrowser: () => undefined,
|
||||||
showTexthookerPage: true,
|
showTexthookerPage: true,
|
||||||
openFirstRunSetup: () => undefined,
|
openFirstRunSetup: () => undefined,
|
||||||
@@ -175,6 +196,7 @@ test('tray menu template renders a visible linux discovery check mark when activ
|
|||||||
const template = buildTrayMenuTemplateRuntime({
|
const template = buildTrayMenuTemplateRuntime({
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
openSessionHelp: () => undefined,
|
openSessionHelp: () => undefined,
|
||||||
|
openChangelog: () => undefined,
|
||||||
openTexthookerInBrowser: () => undefined,
|
openTexthookerInBrowser: () => undefined,
|
||||||
showTexthookerPage: true,
|
showTexthookerPage: true,
|
||||||
openFirstRunSetup: () => undefined,
|
openFirstRunSetup: () => undefined,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function resolveTrayIconPathRuntime(deps: {
|
|||||||
export type TrayMenuActionHandlers = {
|
export type TrayMenuActionHandlers = {
|
||||||
platform?: string;
|
platform?: string;
|
||||||
openSessionHelp: () => void;
|
openSessionHelp: () => void;
|
||||||
|
openChangelog: () => void;
|
||||||
openTexthookerInBrowser: () => void;
|
openTexthookerInBrowser: () => void;
|
||||||
showTexthookerPage: boolean;
|
showTexthookerPage: boolean;
|
||||||
openFirstRunSetup: () => void;
|
openFirstRunSetup: () => void;
|
||||||
@@ -72,6 +73,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
|||||||
label: 'Open Help',
|
label: 'Open Help',
|
||||||
click: handlers.openSessionHelp,
|
click: handlers.openSessionHelp,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'View Changelog',
|
||||||
|
click: handlers.openChangelog,
|
||||||
|
},
|
||||||
...(handlers.showTexthookerPage
|
...(handlers.showTexthookerPage
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -112,3 +112,101 @@ test('createCurlFetch requests updater metadata without Electron networking', as
|
|||||||
assert.equal(calls[0]?.options.encoding, 'buffer');
|
assert.equal(calls[0]?.options.encoding, 'buffer');
|
||||||
assert.equal(calls[0]?.options.timeout, 65_000);
|
assert.equal(calls[0]?.options.timeout, 65_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('curl fetch kills the child process when the caller aborts', async () => {
|
||||||
|
let killed: string | undefined;
|
||||||
|
let settle: ((error: Error | null, stdout: Buffer, stderr: Buffer) => void) | null = null;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const curlFetch = createCurlFetch({
|
||||||
|
execFile: ((
|
||||||
|
_file: string,
|
||||||
|
_args: readonly string[],
|
||||||
|
_options: unknown,
|
||||||
|
callback: (error: Error | null, stdout: Buffer, stderr: Buffer) => void,
|
||||||
|
) => {
|
||||||
|
settle = callback;
|
||||||
|
return {
|
||||||
|
kill: (signal?: string) => {
|
||||||
|
killed = signal;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}) as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = curlFetch('https://example.test/slow', { signal: controller.signal });
|
||||||
|
controller.abort(new Error('deadline reached'));
|
||||||
|
|
||||||
|
await assert.rejects(pending, /deadline reached/);
|
||||||
|
assert.equal(killed, 'SIGKILL', 'the stalled curl process is terminated');
|
||||||
|
assert.ok(settle, 'the callback is still held by the stub');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('curl fetch preserves a non-Error abort reason', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const curlFetch = createCurlFetch({
|
||||||
|
execFile: (() => ({ kill: () => true })) as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = curlFetch('https://example.test/slow', { signal: controller.signal });
|
||||||
|
controller.abort('plain string reason');
|
||||||
|
|
||||||
|
await pending.then(
|
||||||
|
() => assert.fail('expected the aborted request to reject'),
|
||||||
|
(error) => assert.equal(error, 'plain string reason'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('curl fetch synthesises an error when the signal carries no reason', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const curlFetch = createCurlFetch({
|
||||||
|
execFile: (() => ({ kill: () => true })) as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = curlFetch('https://example.test/slow', { signal: controller.signal });
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
// A bare abort() still supplies a DOMException reason, so that is what surfaces.
|
||||||
|
await pending.then(
|
||||||
|
() => assert.fail('expected the aborted request to reject'),
|
||||||
|
(error) => assert.ok(error instanceof Error),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('curl fetch rejects immediately when the signal is already aborted', async () => {
|
||||||
|
let spawned = 0;
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort(new Error('already gone'));
|
||||||
|
|
||||||
|
const curlFetch = createCurlFetch({
|
||||||
|
execFile: (() => {
|
||||||
|
spawned += 1;
|
||||||
|
return { kill: () => true };
|
||||||
|
}) as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
curlFetch('https://example.test/x', { signal: controller.signal }),
|
||||||
|
/already gone/,
|
||||||
|
);
|
||||||
|
assert.equal(spawned, 0, 'no curl process is started for an aborted request');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('curl fetch still resolves normally when no signal is supplied', async () => {
|
||||||
|
const curlFetch = createCurlFetch({
|
||||||
|
execFile: ((
|
||||||
|
_file: string,
|
||||||
|
_args: readonly string[],
|
||||||
|
_options: unknown,
|
||||||
|
callback: (error: Error | null, stdout: Buffer, stderr: Buffer) => void,
|
||||||
|
) => {
|
||||||
|
callback(null, Buffer.from('{"ok":true}'), Buffer.alloc(0));
|
||||||
|
return { kill: () => true };
|
||||||
|
}) as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await curlFetch('https://example.test/x');
|
||||||
|
assert.equal(response.ok, true);
|
||||||
|
assert.deepEqual(await response.json(), { ok: true });
|
||||||
|
});
|
||||||
|
|||||||
@@ -79,8 +79,24 @@ export function createCurlFetch(options: CurlFetchOptions = {}): FetchLike {
|
|||||||
];
|
];
|
||||||
addHeaderArgs(args, init.headers);
|
addHeaderArgs(args, init.headers);
|
||||||
args.push(url);
|
args.push(url);
|
||||||
|
// curl has its own --max-time, but a caller-supplied signal must be able to
|
||||||
|
// cut the request short of it; otherwise a shorter caller deadline is a lie.
|
||||||
|
const signal = init.signal instanceof AbortSignal ? init.signal : undefined;
|
||||||
|
// Mirror fetch: reject with the caller's reason whatever its type, and only
|
||||||
|
// synthesise an error when the signal carries none.
|
||||||
|
const abortReason = (): unknown =>
|
||||||
|
signal?.reason !== undefined ? signal.reason : new Error('curl request aborted');
|
||||||
|
if (signal?.aborted) throw abortReason();
|
||||||
|
|
||||||
const body = await new Promise<Buffer>((resolve, reject) => {
|
const body = await new Promise<Buffer>((resolve, reject) => {
|
||||||
execFile(
|
let onAbort: (() => void) | null = null;
|
||||||
|
const settle = (run: () => void) => {
|
||||||
|
if (onAbort) signal?.removeEventListener('abort', onAbort);
|
||||||
|
onAbort = null;
|
||||||
|
run();
|
||||||
|
};
|
||||||
|
|
||||||
|
const child = execFile(
|
||||||
curlPath,
|
curlPath,
|
||||||
args,
|
args,
|
||||||
{
|
{
|
||||||
@@ -93,12 +109,21 @@ export function createCurlFetch(options: CurlFetchOptions = {}): FetchLike {
|
|||||||
const stderrMessage = Buffer.isBuffer(stderr) ? stderr.toString('utf8') : stderr;
|
const stderrMessage = Buffer.isBuffer(stderr) ? stderr.toString('utf8') : stderr;
|
||||||
const errno = (error as NodeJS.ErrnoException).code;
|
const errno = (error as NodeJS.ErrnoException).code;
|
||||||
const fallback = errno ? `curl failed (${errno})` : 'curl failed';
|
const fallback = errno ? `curl failed (${errno})` : 'curl failed';
|
||||||
reject(new Error(stderrMessage.trim() || fallback));
|
settle(() => reject(new Error(stderrMessage.trim() || fallback)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolve(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout));
|
settle(() => resolve(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout)));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
onAbort = () => {
|
||||||
|
onAbort = null;
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
reject(abortReason());
|
||||||
|
};
|
||||||
|
signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ test('compareSemverLike orders prerelease identifiers within the same base versi
|
|||||||
assert.equal(compareSemverLike('0.15.0', '0.15.0-rc.1') > 0, true);
|
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', () => {
|
test('findReleaseAsset finds exact asset names only', () => {
|
||||||
const release = {
|
const release = {
|
||||||
tag_name: 'v0.14.1',
|
tag_name: 'v0.14.1',
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { UpdateChannel } from '../../../types/config';
|
import type { UpdateChannel } from '../../../types/config';
|
||||||
|
import { compareSemverLike } from '../../../core/utils/semver-compare';
|
||||||
|
|
||||||
|
export { compareSemverLike };
|
||||||
|
|
||||||
export interface GitHubReleaseAsset {
|
export interface GitHubReleaseAsset {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -130,57 +133,3 @@ export function parseReleaseVersion(
|
|||||||
if (!release) return null;
|
if (!release) return null;
|
||||||
return release.tag_name.replace(/^v/i, '');
|
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[] = [];
|
const payloads: OverlayNotificationPayload[] = [];
|
||||||
|
|
||||||
await notifyUpdateAvailable(
|
await notifyUpdateAvailable(
|
||||||
@@ -53,7 +53,10 @@ test('notifyUpdateAvailable adds an install action to overlay update notificatio
|
|||||||
|
|
||||||
const payload = payloads[0];
|
const payload = payloads[0];
|
||||||
assert.ok(payload);
|
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.id, 'subminer-update-available');
|
||||||
assert.equal(payload.persistent, true);
|
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 UPDATE_AVAILABLE_NOTIFICATION_ID = 'subminer-update-available';
|
||||||
export const INSTALL_UPDATE_ACTION_ID = 'install-update';
|
export const INSTALL_UPDATE_ACTION_ID = 'install-update';
|
||||||
|
export const VIEW_CHANGELOG_ACTION_ID = 'view-changelog';
|
||||||
|
|
||||||
export interface UpdateNotificationDeps {
|
export interface UpdateNotificationDeps {
|
||||||
showSystemNotification: (title: string, body: string) => void;
|
showSystemNotification: (title: string, body: string) => void;
|
||||||
@@ -25,7 +26,10 @@ export async function notifyUpdateAvailable(
|
|||||||
body: message,
|
body: message,
|
||||||
variant: 'info',
|
variant: 'info',
|
||||||
persistent: true,
|
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') {
|
if (options.notificationType === 'osd' || options.notificationType === 'osd-system') {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import type {
|
|||||||
YoutubePickerResolveResult,
|
YoutubePickerResolveResult,
|
||||||
OverlayNotificationEventPayload,
|
OverlayNotificationEventPayload,
|
||||||
OverlayNotificationPosition,
|
OverlayNotificationPosition,
|
||||||
|
ChangelogSnapshot,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||||
|
|
||||||
@@ -166,6 +167,7 @@ function createLatestValueIpcListenerWithPayload<T>(
|
|||||||
|
|
||||||
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
|
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
|
||||||
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
|
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
|
||||||
|
const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen);
|
||||||
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
|
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
|
||||||
IPC_CHANNELS.event.characterDictionaryManagerOpen,
|
IPC_CHANNELS.event.characterDictionaryManagerOpen,
|
||||||
);
|
);
|
||||||
@@ -447,6 +449,9 @@ const electronAPI: ElectronAPI = {
|
|||||||
},
|
},
|
||||||
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
|
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
|
||||||
onOpenSessionHelp: onOpenSessionHelpEvent,
|
onOpenSessionHelp: onOpenSessionHelpEvent,
|
||||||
|
onOpenChangelog: onOpenChangelogEvent,
|
||||||
|
getChangelogSnapshot: (options?: { refresh?: boolean }): Promise<ChangelogSnapshot> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options),
|
||||||
onOpenControllerSelect: onOpenControllerSelectEvent,
|
onOpenControllerSelect: onOpenControllerSelectEvent,
|
||||||
onOpenControllerDebug: onOpenControllerDebugEvent,
|
onOpenControllerDebug: onOpenControllerDebugEvent,
|
||||||
onOpenJimaku: onOpenJimakuEvent,
|
onOpenJimaku: onOpenJimakuEvent,
|
||||||
|
|||||||
@@ -456,6 +456,7 @@ function createKeyboardHandlerHarness() {
|
|||||||
let openControllerSelectCount = 0;
|
let openControllerSelectCount = 0;
|
||||||
let openControllerDebugCount = 0;
|
let openControllerDebugCount = 0;
|
||||||
let playlistBrowserKeydownCount = 0;
|
let playlistBrowserKeydownCount = 0;
|
||||||
|
let changelogKeydownCount = 0;
|
||||||
|
|
||||||
const createWordNode = (left: number) => ({
|
const createWordNode = (left: number) => ({
|
||||||
classList: createClassList(),
|
classList: createClassList(),
|
||||||
@@ -504,6 +505,10 @@ function createKeyboardHandlerHarness() {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
handleSessionHelpKeydown: () => false,
|
handleSessionHelpKeydown: () => false,
|
||||||
|
handleChangelogKeydown: () => {
|
||||||
|
changelogKeydownCount += 1;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
openSessionHelpModal: () => {},
|
openSessionHelpModal: () => {},
|
||||||
openControllerSelectModal: () => {
|
openControllerSelectModal: () => {
|
||||||
openControllerSelectCount += 1;
|
openControllerSelectCount += 1;
|
||||||
@@ -522,6 +527,7 @@ function createKeyboardHandlerHarness() {
|
|||||||
openControllerSelectCount: () => openControllerSelectCount,
|
openControllerSelectCount: () => openControllerSelectCount,
|
||||||
openControllerDebugCount: () => openControllerDebugCount,
|
openControllerDebugCount: () => openControllerDebugCount,
|
||||||
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
|
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
|
||||||
|
changelogKeydownCount: () => changelogKeydownCount,
|
||||||
setWordCount: (count: number) => {
|
setWordCount: (count: number) => {
|
||||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
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 () => {
|
test('keyboard mode: configured stats toggle works even while popup is open', async () => {
|
||||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function createKeyboardHandlers(
|
|||||||
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
||||||
|
handleChangelogKeydown: (e: KeyboardEvent) => boolean;
|
||||||
openSessionHelpModal: (opening: {
|
openSessionHelpModal: (opening: {
|
||||||
bindingKey: 'KeyH' | 'KeyK';
|
bindingKey: 'KeyH' | 'KeyK';
|
||||||
fallbackUsed: boolean;
|
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)) {
|
if (handleKeyboardDrivenModeLookupControls(e)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -467,6 +467,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 id="playlistBrowserModal" class="modal hidden" aria-hidden="true">
|
||||||
<div class="modal-content playlist-browser-content">
|
<div class="modal-content playlist-browser-content">
|
||||||
<div class="modal-header">
|
<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');
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type SummaryStub = {
|
||||||
|
classList: ReturnType<typeof createClassList>;
|
||||||
|
tabIndex: number;
|
||||||
|
dataset: Record<string, string>;
|
||||||
|
getClientRects: () => Array<{ width: number; height: number }>;
|
||||||
|
focusCount: number;
|
||||||
|
scrollCount: number;
|
||||||
|
focus: () => void;
|
||||||
|
scrollIntoView: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createSummaryStub(index: number): SummaryStub {
|
||||||
|
const summary: SummaryStub = {
|
||||||
|
classList: createClassList(),
|
||||||
|
tabIndex: -1,
|
||||||
|
dataset: { changelogIndex: String(index) },
|
||||||
|
getClientRects: () => [{ width: 10, height: 10 }],
|
||||||
|
focusCount: 0,
|
||||||
|
scrollCount: 0,
|
||||||
|
focus: () => {
|
||||||
|
summary.focusCount += 1;
|
||||||
|
},
|
||||||
|
scrollIntoView: () => {
|
||||||
|
summary.scrollCount += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createElementStub() {
|
||||||
|
const listeners = new Map<string, Array<(event?: unknown) => void>>();
|
||||||
|
return {
|
||||||
|
value: '',
|
||||||
|
textContent: '',
|
||||||
|
innerHTML: '',
|
||||||
|
classList: createClassList(['hidden']),
|
||||||
|
contains: () => false,
|
||||||
|
setAttribute: () => {},
|
||||||
|
addEventListener: (type: string, listener: (event?: unknown) => void) => {
|
||||||
|
listeners.set(type, [...(listeners.get(type) ?? []), listener]);
|
||||||
|
},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
appendChild: () => {},
|
||||||
|
summaries: [] as SummaryStub[],
|
||||||
|
querySelectorAll(this: { summaries: SummaryStub[] }) {
|
||||||
|
return this.summaries;
|
||||||
|
},
|
||||||
|
focus: () => {},
|
||||||
|
dispatchEventType: (type: string, event?: unknown) => {
|
||||||
|
for (const listener of listeners.get(type) ?? []) listener(event);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 previousDetails = Object.getOwnPropertyDescriptor(globalThis, 'HTMLDetailsElement');
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// getSelectedEntry() narrows with `instanceof HTMLDetailsElement`.
|
||||||
|
class TestDetailsElement {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
Object.defineProperty(globalThis, 'HTMLDetailsElement', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: TestDetailsElement,
|
||||||
|
});
|
||||||
|
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),
|
||||||
|
clearTimeout: (id: unknown) => clearTimeout(id as ReturnType<typeof setTimeout>),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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],
|
||||||
|
['HTMLDetailsElement', previousDetails],
|
||||||
|
] 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 moves selection styling and focus together on J/K', async () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
try {
|
||||||
|
harness.modal.openChangelogModal();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
const summaries = [createSummaryStub(0), createSummaryStub(1), createSummaryStub(2)];
|
||||||
|
harness.dom.changelogList!.summaries = summaries;
|
||||||
|
|
||||||
|
harness.modal.handleChangelogKeydown({ key: 'j', preventDefault: () => {} } as KeyboardEvent);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
summaries.map((summary) => summary.classList.contains('active')),
|
||||||
|
[false, true, false],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
summaries.map((summary) => summary.tabIndex),
|
||||||
|
[-1, 0, -1],
|
||||||
|
);
|
||||||
|
assert.equal(summaries[1]?.focusCount, 1, 'the keyboard path focuses the new selection');
|
||||||
|
assert.equal(summaries[1]?.scrollCount, 1);
|
||||||
|
|
||||||
|
// Wraps backwards past the start.
|
||||||
|
harness.modal.handleChangelogKeydown({ key: 'k', preventDefault: () => {} } as KeyboardEvent);
|
||||||
|
harness.modal.handleChangelogKeydown({ key: 'k', preventDefault: () => {} } as KeyboardEvent);
|
||||||
|
assert.deepEqual(
|
||||||
|
summaries.map((summary) => summary.classList.contains('active')),
|
||||||
|
[false, false, true],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog modal click selection restyles without stealing focus back', async () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
try {
|
||||||
|
harness.modal.wireDomEvents();
|
||||||
|
harness.modal.openChangelogModal();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
const summaries = [createSummaryStub(0), createSummaryStub(1)];
|
||||||
|
harness.dom.changelogList!.summaries = summaries;
|
||||||
|
|
||||||
|
// The handler guards on `instanceof Element`, so the target has to inherit
|
||||||
|
// from the Element stand-in the harness installs.
|
||||||
|
const elementCtor = (globalThis as unknown as { Element: { prototype: object } }).Element;
|
||||||
|
const clickTarget = Object.assign(Object.create(elementCtor.prototype), {
|
||||||
|
closest: (selector: string) =>
|
||||||
|
selector === '.changelog-entry-summary' ? summaries[1] : null,
|
||||||
|
});
|
||||||
|
harness.dom.changelogList!.dispatchEventType('click', { target: clickTarget });
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
summaries.map((summary) => summary.classList.contains('active')),
|
||||||
|
[false, true],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
summaries.map((summary) => summary.tabIndex),
|
||||||
|
[-1, 0],
|
||||||
|
);
|
||||||
|
// The browser already focused the clicked summary; re-focusing would fight it.
|
||||||
|
assert.equal(summaries[1]?.focusCount, 0);
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog modal folds on Enter only from the selected summary', async () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
try {
|
||||||
|
harness.modal.openChangelogModal();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
const detailsCtor = (
|
||||||
|
globalThis as unknown as { HTMLDetailsElement: new () => { open: boolean } }
|
||||||
|
).HTMLDetailsElement;
|
||||||
|
const entry = new detailsCtor();
|
||||||
|
entry.open = true;
|
||||||
|
|
||||||
|
const summaries = [createSummaryStub(0), createSummaryStub(1)];
|
||||||
|
Object.assign(summaries[0]!, { parentElement: entry });
|
||||||
|
harness.dom.changelogList!.summaries = summaries;
|
||||||
|
|
||||||
|
let prevented = 0;
|
||||||
|
const press = (target: unknown) =>
|
||||||
|
harness.modal.handleChangelogKeydown({
|
||||||
|
key: 'Enter',
|
||||||
|
target,
|
||||||
|
preventDefault: () => {
|
||||||
|
prevented += 1;
|
||||||
|
},
|
||||||
|
} as unknown as KeyboardEvent);
|
||||||
|
|
||||||
|
// Close button focused: the button must keep its own Enter activation.
|
||||||
|
assert.equal(press(harness.dom.changelogClose), true);
|
||||||
|
assert.equal(prevented, 0, 'Enter on a button is not swallowed');
|
||||||
|
assert.equal(entry.open, true);
|
||||||
|
|
||||||
|
// A non-selected summary (the nested "Internal changes" fold) is left alone.
|
||||||
|
assert.equal(press(summaries[1]), true);
|
||||||
|
assert.equal(prevented, 0);
|
||||||
|
assert.equal(entry.open, true);
|
||||||
|
|
||||||
|
// The selected summary does fold, exactly once.
|
||||||
|
assert.equal(press(summaries[0]), true);
|
||||||
|
assert.equal(prevented, 1);
|
||||||
|
assert.equal(entry.open, false);
|
||||||
|
} 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import type { ChangelogSnapshot } from '../../types/changelog';
|
||||||
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
|
import {
|
||||||
|
createChangelogEntryNode,
|
||||||
|
describeChangelogSource,
|
||||||
|
resolveEntryBadge,
|
||||||
|
shouldEntryStartExpanded,
|
||||||
|
} from './changelog-render';
|
||||||
|
import { createModalFocusGuard } from './modal-focus-guard';
|
||||||
|
|
||||||
|
export function createChangelogModal(
|
||||||
|
ctx: RendererContext,
|
||||||
|
options: {
|
||||||
|
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||||
|
syncSettingsModalSubtitleSuppression: () => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
let priorFocus: Element | null = null;
|
||||||
|
let loadToken = 0;
|
||||||
|
|
||||||
|
function getSummaries(): HTMLElement[] {
|
||||||
|
return Array.from(
|
||||||
|
ctx.dom.changelogList.querySelectorAll('.changelog-entry-summary'),
|
||||||
|
) as HTMLElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySelectionStyles(index: number): HTMLElement[] {
|
||||||
|
const summaries = getSummaries();
|
||||||
|
ctx.state.changelogSelectedIndex = index;
|
||||||
|
summaries.forEach((summary, idx) => {
|
||||||
|
summary.classList.toggle('active', idx === index);
|
||||||
|
summary.tabIndex = idx === index ? 0 : -1;
|
||||||
|
});
|
||||||
|
return summaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelected(index: number): void {
|
||||||
|
const count = getSummaries().length;
|
||||||
|
if (count === 0) return;
|
||||||
|
|
||||||
|
const wrapped = index % count;
|
||||||
|
const next = wrapped < 0 ? wrapped + count : wrapped;
|
||||||
|
|
||||||
|
// Only the keyboard path moves focus; clicking already focused the summary.
|
||||||
|
const active = applySelectionStyles(next)[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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const focus = createModalFocusGuard({
|
||||||
|
isOpen: () => ctx.state.changelogModalOpen,
|
||||||
|
getModalRoot: () => ctx.dom.changelogModal,
|
||||||
|
getPreferredFocusTargets: () => getSummaries(),
|
||||||
|
getFallbackFocusTarget: () => ctx.dom.changelogClose,
|
||||||
|
isModalLayer: ctx.platform.isModalLayer,
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
focus.attach();
|
||||||
|
focus.requestOverlayFocus();
|
||||||
|
window.focus();
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
focus.detach();
|
||||||
|
|
||||||
|
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 === ' ') {
|
||||||
|
// Only the selected release summary folds from here. The Close/Refresh
|
||||||
|
// buttons and the nested "Internal changes" fold activate themselves, and
|
||||||
|
// swallowing Enter/Space would make them unreachable by keyboard.
|
||||||
|
if (e.target !== summaries[ctx.state.changelogSelectedIndex]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
applySelectionStyles(index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
closeChangelogModal,
|
||||||
|
handleChangelogKeydown,
|
||||||
|
openChangelogModal,
|
||||||
|
wireDomEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createModalFocusGuard } from './modal-focus-guard';
|
||||||
|
|
||||||
|
type Listener = (event?: unknown) => void;
|
||||||
|
|
||||||
|
function createRoot(contains: boolean) {
|
||||||
|
const listeners: Array<{ type: string; listener: Listener }> = [];
|
||||||
|
return {
|
||||||
|
contains: () => contains,
|
||||||
|
addEventListener: (type: string, listener: Listener) => {
|
||||||
|
listeners.push({ type, listener });
|
||||||
|
},
|
||||||
|
removeEventListener: (type: string, listener: Listener) => {
|
||||||
|
const index = listeners.findIndex(
|
||||||
|
(entry) => entry.type === type && entry.listener === listener,
|
||||||
|
);
|
||||||
|
if (index >= 0) listeners.splice(index, 1);
|
||||||
|
},
|
||||||
|
listeners,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type Harness = {
|
||||||
|
guard: ReturnType<typeof createModalFocusGuard>;
|
||||||
|
root: ReturnType<typeof createRoot>;
|
||||||
|
focusMainWindowCalls: () => number;
|
||||||
|
focused: () => string[];
|
||||||
|
documentListeners: () => string[];
|
||||||
|
windowListeners: () => string[];
|
||||||
|
handlerFor: (scope: 'document' | 'window', type: string) => Listener | undefined;
|
||||||
|
setActiveElement: (value: unknown) => void;
|
||||||
|
advanceClock: (ms: number) => void;
|
||||||
|
runTimers: () => void;
|
||||||
|
clearedTimers: () => number[];
|
||||||
|
pendingTimerCount: () => number;
|
||||||
|
restore: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createHarness(
|
||||||
|
options: {
|
||||||
|
isOpen?: () => boolean;
|
||||||
|
isModalLayer?: boolean;
|
||||||
|
contains?: boolean;
|
||||||
|
preferredVisible?: boolean;
|
||||||
|
preferredAcceptsFocus?: boolean;
|
||||||
|
extraPreferred?: boolean;
|
||||||
|
fallback?: 'element' | null;
|
||||||
|
} = {},
|
||||||
|
): Harness {
|
||||||
|
const previous = (['window', 'document', 'HTMLElement', 'Element'] as const).map(
|
||||||
|
(name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const,
|
||||||
|
);
|
||||||
|
|
||||||
|
class TestElement {}
|
||||||
|
for (const name of ['HTMLElement', 'Element'] as const) {
|
||||||
|
Object.defineProperty(globalThis, name, {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: TestElement,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let focusMainWindowCalls = 0;
|
||||||
|
let now = 1_000;
|
||||||
|
const realDateNow = Date.now;
|
||||||
|
Date.now = () => now;
|
||||||
|
const focused: string[] = [];
|
||||||
|
const documentListeners: Array<{ type: string; listener: Listener }> = [];
|
||||||
|
const windowListeners: Array<{ type: string; listener: Listener }> = [];
|
||||||
|
|
||||||
|
const register = (registry: Array<{ type: string; listener: Listener }>) => ({
|
||||||
|
add: (type: string, listener: Listener) => {
|
||||||
|
registry.push({ type, listener });
|
||||||
|
},
|
||||||
|
remove: (type: string, listener: Listener) => {
|
||||||
|
const index = registry.findIndex(
|
||||||
|
(entry) => entry.type === type && entry.listener === listener,
|
||||||
|
);
|
||||||
|
if (index >= 0) registry.splice(index, 1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const windowRegistry = register(windowListeners);
|
||||||
|
const documentRegistry = register(documentListeners);
|
||||||
|
const timers = new Map<number, () => void>();
|
||||||
|
let nextTimerId = 0;
|
||||||
|
const clearedTimers: number[] = [];
|
||||||
|
let activeElement: unknown = null;
|
||||||
|
|
||||||
|
const root = createRoot(options.contains ?? false);
|
||||||
|
|
||||||
|
const preferred = Object.assign(new TestElement(), {
|
||||||
|
// A position:fixed element has a null offsetParent but still has rects.
|
||||||
|
offsetParent: null,
|
||||||
|
getClientRects: () => (options.preferredVisible === false ? [] : [{ width: 10, height: 10 }]),
|
||||||
|
focus: () => {
|
||||||
|
focused.push('preferred');
|
||||||
|
// A rendered-but-unfocusable target (e.g. disabled) never becomes active.
|
||||||
|
if (options.preferredAcceptsFocus !== false) activeElement = preferred;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const secondPreferred = Object.assign(new TestElement(), {
|
||||||
|
getClientRects: () => [{ width: 10, height: 10 }],
|
||||||
|
focus: () => {
|
||||||
|
focused.push('second');
|
||||||
|
activeElement = secondPreferred;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fallback =
|
||||||
|
options.fallback === null
|
||||||
|
? null
|
||||||
|
: Object.assign(new TestElement(), {
|
||||||
|
focus: () => {
|
||||||
|
focused.push('fallback');
|
||||||
|
activeElement = fallback;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
focusMainWindow: async () => {
|
||||||
|
focusMainWindowCalls += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focus: () => {
|
||||||
|
focused.push('window');
|
||||||
|
},
|
||||||
|
addEventListener: windowRegistry.add,
|
||||||
|
removeEventListener: windowRegistry.remove,
|
||||||
|
setTimeout: (callback: () => void) => {
|
||||||
|
nextTimerId += 1;
|
||||||
|
timers.set(nextTimerId, callback);
|
||||||
|
return nextTimerId;
|
||||||
|
},
|
||||||
|
clearTimeout: (id: number) => {
|
||||||
|
clearedTimers.push(id);
|
||||||
|
timers.delete(id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: {
|
||||||
|
get activeElement() {
|
||||||
|
return activeElement;
|
||||||
|
},
|
||||||
|
addEventListener: documentRegistry.add,
|
||||||
|
removeEventListener: documentRegistry.remove,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const guard = createModalFocusGuard({
|
||||||
|
isOpen: options.isOpen ?? (() => true),
|
||||||
|
getModalRoot: () => root as unknown as Element,
|
||||||
|
getPreferredFocusTargets: () =>
|
||||||
|
(options.extraPreferred
|
||||||
|
? [preferred, secondPreferred]
|
||||||
|
: [preferred]) as unknown as HTMLElement[],
|
||||||
|
getFallbackFocusTarget: () => fallback as unknown as Element | null,
|
||||||
|
isModalLayer: options.isModalLayer ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
guard,
|
||||||
|
root,
|
||||||
|
focusMainWindowCalls: () => focusMainWindowCalls,
|
||||||
|
focused: () => focused,
|
||||||
|
documentListeners: () => documentListeners.map((entry) => entry.type),
|
||||||
|
windowListeners: () => windowListeners.map((entry) => entry.type),
|
||||||
|
handlerFor: (scope: 'document' | 'window', type: string) =>
|
||||||
|
(scope === 'document' ? documentListeners : windowListeners).find(
|
||||||
|
(entry) => entry.type === type,
|
||||||
|
)?.listener,
|
||||||
|
setActiveElement: (value: unknown) => {
|
||||||
|
activeElement = value;
|
||||||
|
},
|
||||||
|
advanceClock: (ms: number) => {
|
||||||
|
now += ms;
|
||||||
|
},
|
||||||
|
runTimers: () => {
|
||||||
|
const pending = [...timers.values()];
|
||||||
|
timers.clear();
|
||||||
|
for (const callback of pending) callback();
|
||||||
|
},
|
||||||
|
clearedTimers: () => clearedTimers,
|
||||||
|
pendingTimerCount: () => timers.size,
|
||||||
|
restore: () => {
|
||||||
|
Date.now = realDateNow;
|
||||||
|
for (const [name, descriptor] of previous) {
|
||||||
|
if (descriptor) {
|
||||||
|
Object.defineProperty(globalThis, name, descriptor);
|
||||||
|
} else {
|
||||||
|
delete (globalThis as Record<string, unknown>)[name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('modal focus guard attaches once and detaches every listener', () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
try {
|
||||||
|
harness.guard.attach();
|
||||||
|
harness.guard.attach();
|
||||||
|
|
||||||
|
assert.deepEqual(harness.documentListeners(), ['focusin']);
|
||||||
|
assert.deepEqual(harness.windowListeners(), ['blur', 'focus']);
|
||||||
|
assert.deepEqual(
|
||||||
|
harness.root.listeners.map((entry) => entry.type),
|
||||||
|
['pointerdown', 'click'],
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.guard.detach();
|
||||||
|
|
||||||
|
assert.deepEqual(harness.documentListeners(), []);
|
||||||
|
assert.deepEqual(harness.windowListeners(), []);
|
||||||
|
assert.deepEqual(harness.root.listeners, []);
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal focus guard pulls focus back when focusin lands outside the modal', () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
try {
|
||||||
|
harness.guard.attach();
|
||||||
|
|
||||||
|
const focusin = harness.handlerFor('document', 'focusin');
|
||||||
|
assert.ok(focusin, 'attach registers a focusin handler');
|
||||||
|
|
||||||
|
// focusin is not cancelable, so recovery is the only observable effect.
|
||||||
|
focusin?.({ target: {} });
|
||||||
|
assert.deepEqual(harness.focused(), ['preferred']);
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal focus guard ignores focusin while the modal is closed', () => {
|
||||||
|
const harness = createHarness({ isOpen: () => false });
|
||||||
|
try {
|
||||||
|
harness.guard.attach();
|
||||||
|
harness.handlerFor('document', 'focusin')?.({ target: {} });
|
||||||
|
|
||||||
|
assert.deepEqual(harness.focused(), []);
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal focus guard restores focus to the first rendered target', () => {
|
||||||
|
// The target is position:fixed (null offsetParent) yet visible, so it must
|
||||||
|
// still win over the fallback.
|
||||||
|
const harness = createHarness();
|
||||||
|
try {
|
||||||
|
harness.guard.enforceModalFocus();
|
||||||
|
assert.deepEqual(harness.focused(), ['preferred']);
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal focus guard tries the next target when one refuses focus', () => {
|
||||||
|
const harness = createHarness({ preferredAcceptsFocus: false, extraPreferred: true });
|
||||||
|
try {
|
||||||
|
assert.equal(harness.guard.focusFallbackTarget(), true);
|
||||||
|
assert.deepEqual(harness.focused(), ['preferred', 'second']);
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal focus guard reaches the fallback when no preferred target takes focus', () => {
|
||||||
|
const harness = createHarness({ preferredAcceptsFocus: false });
|
||||||
|
try {
|
||||||
|
assert.equal(harness.guard.focusFallbackTarget(), true);
|
||||||
|
assert.deepEqual(harness.focused(), ['preferred', 'fallback']);
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal focus guard falls back when no preferred target is rendered', () => {
|
||||||
|
const harness = createHarness({ preferredVisible: false });
|
||||||
|
try {
|
||||||
|
harness.guard.enforceModalFocus();
|
||||||
|
assert.deepEqual(harness.focused(), ['fallback']);
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal focus guard focuses the window when nothing else can take focus', () => {
|
||||||
|
const harness = createHarness({ preferredVisible: false, fallback: null });
|
||||||
|
try {
|
||||||
|
assert.equal(harness.guard.focusFallbackTarget(), false);
|
||||||
|
assert.deepEqual(harness.focused(), ['window']);
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal focus guard leaves focus alone while it is already inside the modal', () => {
|
||||||
|
const harness = createHarness({ contains: true });
|
||||||
|
try {
|
||||||
|
harness.setActiveElement(
|
||||||
|
Object.create((globalThis as { Element: { prototype: object } }).Element.prototype),
|
||||||
|
);
|
||||||
|
harness.guard.enforceModalFocus();
|
||||||
|
|
||||||
|
assert.deepEqual(harness.focused(), []);
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal focus guard does nothing while the modal is closed', () => {
|
||||||
|
const harness = createHarness({ isOpen: () => false });
|
||||||
|
try {
|
||||||
|
harness.guard.enforceModalFocus();
|
||||||
|
assert.deepEqual(harness.focused(), []);
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal focus guard clears recovery state on detach so a reopen is not blocked', () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
try {
|
||||||
|
harness.guard.attach();
|
||||||
|
harness.guard.enforceModalFocus();
|
||||||
|
assert.deepEqual(harness.focused(), ['preferred']);
|
||||||
|
assert.equal(harness.pendingTimerCount(), 1, 'recovery armed the debounce timer');
|
||||||
|
|
||||||
|
// Close while recovery is still in flight.
|
||||||
|
harness.guard.detach();
|
||||||
|
assert.equal(harness.clearedTimers().length, 1, 'the pending debounce timer is cancelled');
|
||||||
|
assert.equal(harness.pendingTimerCount(), 0);
|
||||||
|
|
||||||
|
// Immediate reopen: recovery must work straight away, not 120 ms later.
|
||||||
|
harness.guard.attach();
|
||||||
|
harness.setActiveElement(null);
|
||||||
|
harness.guard.enforceModalFocus();
|
||||||
|
|
||||||
|
assert.deepEqual(harness.focused(), ['preferred', 'preferred']);
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal focus guard debounces recovery so a focus fight cannot spin', () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
try {
|
||||||
|
harness.guard.enforceModalFocus();
|
||||||
|
harness.setActiveElement(null);
|
||||||
|
|
||||||
|
// Re-entry guard: the recovery timer has not fired yet.
|
||||||
|
harness.guard.enforceModalFocus();
|
||||||
|
assert.deepEqual(harness.focused(), ['preferred']);
|
||||||
|
|
||||||
|
// Timer cleared the re-entry flag, but the debounce window still holds.
|
||||||
|
harness.runTimers();
|
||||||
|
harness.guard.enforceModalFocus();
|
||||||
|
assert.deepEqual(harness.focused(), ['preferred'], 'debounce still blocks the retry');
|
||||||
|
|
||||||
|
// Past the window, recovery resumes.
|
||||||
|
harness.advanceClock(200);
|
||||||
|
harness.setActiveElement(null);
|
||||||
|
harness.guard.enforceModalFocus();
|
||||||
|
assert.deepEqual(harness.focused(), ['preferred', 'preferred']);
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal focus guard asks the main window for focus off the modal layer only', () => {
|
||||||
|
const onModalLayer = createHarness({ isModalLayer: true });
|
||||||
|
try {
|
||||||
|
onModalLayer.guard.requestOverlayFocus();
|
||||||
|
assert.equal(onModalLayer.focusMainWindowCalls(), 0);
|
||||||
|
} finally {
|
||||||
|
onModalLayer.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const onOverlayLayer = createHarness({ isModalLayer: false });
|
||||||
|
try {
|
||||||
|
onOverlayLayer.guard.requestOverlayFocus();
|
||||||
|
assert.equal(onOverlayLayer.focusMainWindowCalls(), 1);
|
||||||
|
} finally {
|
||||||
|
onOverlayLayer.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Keeps focus inside an overlay-hosted modal.
|
||||||
|
*
|
||||||
|
* The overlay can lose focus to mpv or to the compositor while a modal is up,
|
||||||
|
* which leaves the modal visible but inert. Recovery is debounced (and guarded
|
||||||
|
* against re-entry) so a focus fight with the window manager cannot spin.
|
||||||
|
*/
|
||||||
|
export type ModalFocusGuardDeps = {
|
||||||
|
isOpen: () => boolean;
|
||||||
|
/** Modal root; focus inside it counts as "still in the modal". */
|
||||||
|
getModalRoot: () => Element;
|
||||||
|
/** Preferred focus targets in order; the first rendered one wins. */
|
||||||
|
getPreferredFocusTargets: () => HTMLElement[];
|
||||||
|
/** Used when no preferred target is rendered, e.g. the close button. */
|
||||||
|
getFallbackFocusTarget: () => Element | null;
|
||||||
|
/** Modal-layer windows own their focus; other layers ask the main window. */
|
||||||
|
isModalLayer: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FOCUS_RECOVERY_DEBOUNCE_MS = 120;
|
||||||
|
|
||||||
|
export function createModalFocusGuard(deps: ModalFocusGuardDeps) {
|
||||||
|
let focusinGuard: ((event: FocusEvent) => void) | null = null;
|
||||||
|
let windowFocusGuard: (() => void) | null = null;
|
||||||
|
let pointerFocusGuard: ((event: Event) => void) | null = null;
|
||||||
|
let pointerFocusRoot: Element | null = null;
|
||||||
|
let isRecovering = false;
|
||||||
|
let lastRecoveryAt = 0;
|
||||||
|
// Browser setTimeout id; typed loosely because @types/node widens the global.
|
||||||
|
let recoveryTimer: number | null = null;
|
||||||
|
|
||||||
|
function isModalFocusTarget(target: EventTarget | null): boolean {
|
||||||
|
return target instanceof Element && deps.getModalRoot().contains(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestOverlayFocus(): void {
|
||||||
|
if (!deps.isModalLayer) {
|
||||||
|
// Best-effort: a rejected focus request must not surface as an unhandled
|
||||||
|
// rejection, since this runs from blur/focus handlers.
|
||||||
|
void Promise.resolve(window.electronAPI.focusMainWindow()).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusFallbackTarget(): boolean {
|
||||||
|
requestOverlayFocus();
|
||||||
|
|
||||||
|
// getClientRects() rather than offsetParent: the latter is null for
|
||||||
|
// position:fixed elements, which would skip a perfectly visible target.
|
||||||
|
// Rendered is not the same as focusable, so keep trying until one sticks
|
||||||
|
// instead of giving up on the first candidate that refuses focus.
|
||||||
|
for (const target of deps.getPreferredFocusTargets()) {
|
||||||
|
if (target.getClientRects().length === 0) continue;
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
|
if (document.activeElement === target) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = deps.getFallbackFocusTarget();
|
||||||
|
if (fallback instanceof HTMLElement) {
|
||||||
|
fallback.focus({ preventScroll: true });
|
||||||
|
return document.activeElement === fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceModalFocus(): void {
|
||||||
|
if (!deps.isOpen()) return;
|
||||||
|
if (isModalFocusTarget(document.activeElement)) return;
|
||||||
|
if (isRecovering) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastRecoveryAt < FOCUS_RECOVERY_DEBOUNCE_MS) return;
|
||||||
|
|
||||||
|
isRecovering = true;
|
||||||
|
lastRecoveryAt = now;
|
||||||
|
focusFallbackTarget();
|
||||||
|
recoveryTimer = window.setTimeout(() => {
|
||||||
|
recoveryTimer = null;
|
||||||
|
isRecovering = false;
|
||||||
|
}, FOCUS_RECOVERY_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idempotent; safe to call on every open. */
|
||||||
|
function attach(): void {
|
||||||
|
if (focusinGuard === null) {
|
||||||
|
// focusin is not cancelable, so there is nothing to preventDefault here;
|
||||||
|
// focus is taken back afterwards instead.
|
||||||
|
focusinGuard = (event: FocusEvent) => {
|
||||||
|
if (!deps.isOpen()) return;
|
||||||
|
if (!isModalFocusTarget(event.target)) {
|
||||||
|
enforceModalFocus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('focusin', focusinGuard);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointerFocusGuard === null) {
|
||||||
|
pointerFocusGuard = () => {
|
||||||
|
requestOverlayFocus();
|
||||||
|
enforceModalFocus();
|
||||||
|
};
|
||||||
|
// Remember the root we bound to: resolving it again on detach could
|
||||||
|
// return a different element and leak the listeners on the old one.
|
||||||
|
pointerFocusRoot = deps.getModalRoot();
|
||||||
|
pointerFocusRoot.addEventListener('pointerdown', pointerFocusGuard);
|
||||||
|
pointerFocusRoot.addEventListener('click', pointerFocusGuard);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (windowFocusGuard === null) {
|
||||||
|
windowFocusGuard = () => {
|
||||||
|
requestOverlayFocus();
|
||||||
|
enforceModalFocus();
|
||||||
|
};
|
||||||
|
window.addEventListener('blur', windowFocusGuard);
|
||||||
|
window.addEventListener('focus', windowFocusGuard);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detach(): void {
|
||||||
|
if (focusinGuard) {
|
||||||
|
document.removeEventListener('focusin', focusinGuard);
|
||||||
|
focusinGuard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointerFocusGuard) {
|
||||||
|
pointerFocusRoot?.removeEventListener('pointerdown', pointerFocusGuard);
|
||||||
|
pointerFocusRoot?.removeEventListener('click', pointerFocusGuard);
|
||||||
|
pointerFocusGuard = null;
|
||||||
|
pointerFocusRoot = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (windowFocusGuard) {
|
||||||
|
window.removeEventListener('blur', windowFocusGuard);
|
||||||
|
window.removeEventListener('focus', windowFocusGuard);
|
||||||
|
windowFocusGuard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closing mid-recovery must not leave the debounce armed: a modal reopened
|
||||||
|
// straight away would otherwise get no focus recovery for the next 120 ms.
|
||||||
|
if (recoveryTimer !== null) {
|
||||||
|
window.clearTimeout(recoveryTimer);
|
||||||
|
recoveryTimer = null;
|
||||||
|
}
|
||||||
|
isRecovering = false;
|
||||||
|
lastRecoveryAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
attach,
|
||||||
|
detach,
|
||||||
|
enforceModalFocus,
|
||||||
|
focusFallbackTarget,
|
||||||
|
isModalFocusTarget,
|
||||||
|
requestOverlayFocus,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -279,6 +279,7 @@ test('modal-layer session help does not focus hidden main overlay and still clos
|
|||||||
addEventListener: () => {},
|
addEventListener: () => {},
|
||||||
removeEventListener: () => {},
|
removeEventListener: () => {},
|
||||||
setTimeout: (callback: () => void) => setTimeout(callback, 0),
|
setTimeout: (callback: () => void) => setTimeout(callback, 0),
|
||||||
|
clearTimeout: (id: unknown) => clearTimeout(id as ReturnType<typeof setTimeout>),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Object.defineProperty(globalThis, 'document', {
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from './session-help-sections';
|
} from './session-help-sections';
|
||||||
import { createSessionHelpSectionNode } from './session-help-render';
|
import { createSessionHelpSectionNode } from './session-help-render';
|
||||||
import { buildVisibleSessionHelpSections, createSessionHelpTabBar } from './session-help-tabs';
|
import { buildVisibleSessionHelpSections, createSessionHelpTabBar } from './session-help-tabs';
|
||||||
|
import { createModalFocusGuard } from './modal-focus-guard';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
buildSessionHelpSections,
|
buildSessionHelpSections,
|
||||||
@@ -69,11 +70,6 @@ export function createSessionHelpModal(
|
|||||||
let helpFilterValue = '';
|
let helpFilterValue = '';
|
||||||
let helpSections: SessionHelpSection[] = [];
|
let helpSections: SessionHelpSection[] = [];
|
||||||
let activeTabId: SessionHelpTabId = 'essentials';
|
let activeTabId: SessionHelpTabId = 'essentials';
|
||||||
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;
|
|
||||||
|
|
||||||
function getItems(): HTMLButtonElement[] {
|
function getItems(): HTMLButtonElement[] {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
@@ -102,47 +98,13 @@ export function createSessionHelpModal(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSessionHelpModalFocusTarget(target: EventTarget | null): boolean {
|
const focus = createModalFocusGuard({
|
||||||
return target instanceof Element && ctx.dom.sessionHelpModal.contains(target);
|
isOpen: () => ctx.state.sessionHelpModalOpen,
|
||||||
}
|
getModalRoot: () => ctx.dom.sessionHelpModal,
|
||||||
|
getPreferredFocusTargets: () => getItems(),
|
||||||
function focusFallbackTarget(): boolean {
|
getFallbackFocusTarget: () => ctx.dom.sessionHelpClose,
|
||||||
if (!ctx.platform.isModalLayer) {
|
isModalLayer: ctx.platform.isModalLayer,
|
||||||
void window.electronAPI.focusMainWindow();
|
});
|
||||||
}
|
|
||||||
const items = getItems();
|
|
||||||
const firstItem = items.find((item) => item.offsetParent !== null);
|
|
||||||
if (firstItem) {
|
|
||||||
firstItem.focus({ preventScroll: true });
|
|
||||||
return document.activeElement === firstItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.dom.sessionHelpClose instanceof HTMLElement) {
|
|
||||||
ctx.dom.sessionHelpClose.focus({ preventScroll: true });
|
|
||||||
return document.activeElement === ctx.dom.sessionHelpClose;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.focus();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function enforceModalFocus(): void {
|
|
||||||
if (!ctx.state.sessionHelpModalOpen) return;
|
|
||||||
if (!isSessionHelpModalFocusTarget(document.activeElement)) {
|
|
||||||
if (isRecoveringModalFocus) return;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastFocusRecoveryAt < 120) return;
|
|
||||||
|
|
||||||
isRecoveringModalFocus = true;
|
|
||||||
lastFocusRecoveryAt = now;
|
|
||||||
focusFallbackTarget();
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
|
||||||
isRecoveringModalFocus = false;
|
|
||||||
}, 120);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFilterInputFocused(): boolean {
|
function isFilterInputFocused(): boolean {
|
||||||
return document.activeElement === ctx.dom.sessionHelpFilter;
|
return document.activeElement === ctx.dom.sessionHelpFilter;
|
||||||
@@ -192,48 +154,6 @@ export function createSessionHelpModal(
|
|||||||
setSelected(0);
|
setSelected(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestOverlayFocus(): void {
|
|
||||||
if (!ctx.platform.isModalLayer) {
|
|
||||||
void window.electronAPI.focusMainWindow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addPointerFocusListener(): void {
|
|
||||||
if (modalPointerFocusGuard) return;
|
|
||||||
|
|
||||||
modalPointerFocusGuard = () => {
|
|
||||||
requestOverlayFocus();
|
|
||||||
enforceModalFocus();
|
|
||||||
};
|
|
||||||
ctx.dom.sessionHelpModal.addEventListener('pointerdown', modalPointerFocusGuard);
|
|
||||||
ctx.dom.sessionHelpModal.addEventListener('click', modalPointerFocusGuard);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removePointerFocusListener(): void {
|
|
||||||
if (!modalPointerFocusGuard) return;
|
|
||||||
ctx.dom.sessionHelpModal.removeEventListener('pointerdown', modalPointerFocusGuard);
|
|
||||||
ctx.dom.sessionHelpModal.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 showRenderError(message: string): void {
|
function showRenderError(message: string): void {
|
||||||
helpSections = [];
|
helpSections = [];
|
||||||
helpFilterValue = '';
|
helpFilterValue = '';
|
||||||
@@ -310,22 +230,10 @@ export function createSessionHelpModal(
|
|||||||
}
|
}
|
||||||
ctx.dom.sessionHelpStatus.textContent = 'Loading session help data...';
|
ctx.dom.sessionHelpStatus.textContent = 'Loading session help data...';
|
||||||
|
|
||||||
if (focusGuard === null) {
|
focus.attach();
|
||||||
focusGuard = (event: FocusEvent) => {
|
focus.requestOverlayFocus();
|
||||||
if (!ctx.state.sessionHelpModalOpen) return;
|
|
||||||
if (!isSessionHelpModalFocusTarget(event.target)) {
|
|
||||||
event.preventDefault();
|
|
||||||
enforceModalFocus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('focusin', focusGuard);
|
|
||||||
}
|
|
||||||
|
|
||||||
addPointerFocusListener();
|
|
||||||
startFocusRecoveryGuards();
|
|
||||||
requestOverlayFocus();
|
|
||||||
window.focus();
|
window.focus();
|
||||||
enforceModalFocus();
|
focus.enforceModalFocus();
|
||||||
|
|
||||||
void render().then((dataLoaded) => {
|
void render().then((dataLoaded) => {
|
||||||
if (!ctx.state.sessionHelpModalOpen) return;
|
if (!ctx.state.sessionHelpModalOpen) return;
|
||||||
@@ -353,12 +261,7 @@ export function createSessionHelpModal(
|
|||||||
ctx.dom.overlay.classList.remove('interactive');
|
ctx.dom.overlay.classList.remove('interactive');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (focusGuard) {
|
focus.detach();
|
||||||
document.removeEventListener('focusin', focusGuard);
|
|
||||||
focusGuard = null;
|
|
||||||
}
|
|
||||||
removePointerFocusListener();
|
|
||||||
stopFocusRecoveryGuards();
|
|
||||||
|
|
||||||
if (priorFocus instanceof HTMLElement && priorFocus.isConnected) {
|
if (priorFocus instanceof HTMLElement && priorFocus.isConnected) {
|
||||||
priorFocus.focus({ preventScroll: true });
|
priorFocus.focus({ preventScroll: true });
|
||||||
@@ -395,7 +298,7 @@ export function createSessionHelpModal(
|
|||||||
helpFilterValue = '';
|
helpFilterValue = '';
|
||||||
ctx.dom.sessionHelpFilter.value = '';
|
ctx.dom.sessionHelpFilter.value = '';
|
||||||
applyFilterAndRender();
|
applyFilterAndRender();
|
||||||
focusFallbackTarget();
|
focus.focusFallbackTarget();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -442,7 +345,7 @@ export function createSessionHelpModal(
|
|||||||
ctx.dom.sessionHelpFilter.addEventListener('keydown', (event: KeyboardEvent) => {
|
ctx.dom.sessionHelpFilter.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
focusFallbackTarget();
|
focus.focusFallbackTarget();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
test('overlay notification renderer updates same-id progress without replacing the spinner', () => {
|
||||||
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||||
|
|||||||
@@ -293,7 +293,9 @@ export function createOverlayNotificationRenderer(
|
|||||||
window.electronAPI.sendOverlayNotificationAction?.(entry.id, action.id, {
|
window.electronAPI.sendOverlayNotificationAction?.(entry.id, action.id, {
|
||||||
noteId: action.noteId,
|
noteId: action.noteId,
|
||||||
});
|
});
|
||||||
remove(entry.id);
|
if (action.keepOpen !== true) {
|
||||||
|
remove(entry.id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
actions.append(button);
|
actions.append(button);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { createKikuModal } from './modals/kiku.js';
|
|||||||
import { prepareForKikuFieldGroupingOpen } from './kiku-open.js';
|
import { prepareForKikuFieldGroupingOpen } from './kiku-open.js';
|
||||||
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
|
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
|
||||||
import { createSessionHelpModal } from './modals/session-help.js';
|
import { createSessionHelpModal } from './modals/session-help.js';
|
||||||
|
import { createChangelogModal } from './modals/changelog.js';
|
||||||
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
||||||
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
|
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
|
||||||
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
|
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
|
||||||
@@ -149,6 +150,12 @@ const modalDescriptors = [
|
|||||||
close: () => sessionHelpModal.closeSessionHelpModal(),
|
close: () => sessionHelpModal.closeSessionHelpModal(),
|
||||||
suppressesSubtitles: true,
|
suppressesSubtitles: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'changelog',
|
||||||
|
isOpen: () => ctx.state.changelogModalOpen,
|
||||||
|
close: () => changelogModal.closeChangelogModal(),
|
||||||
|
suppressesSubtitles: true,
|
||||||
|
},
|
||||||
] satisfies readonly ModalDescriptor<OverlayHostedModal>[];
|
] satisfies readonly ModalDescriptor<OverlayHostedModal>[];
|
||||||
|
|
||||||
const modalRegistry = createModalRegistry(modalDescriptors);
|
const modalRegistry = createModalRegistry(modalDescriptors);
|
||||||
@@ -213,6 +220,10 @@ const sessionHelpModal = createSessionHelpModal(ctx, {
|
|||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
});
|
});
|
||||||
|
const changelogModal = createChangelogModal(ctx, {
|
||||||
|
modalStateReader: { isAnyModalOpen },
|
||||||
|
syncSettingsModalSubtitleSuppression,
|
||||||
|
});
|
||||||
const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
|
const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
|
||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
shouldRestoreOpenOnStartup: async () =>
|
shouldRestoreOpenOnStartup: async () =>
|
||||||
@@ -266,6 +277,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
|||||||
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
||||||
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||||
|
handleChangelogKeydown: changelogModal.handleChangelogKeydown,
|
||||||
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
||||||
openControllerSelectModal: () => {
|
openControllerSelectModal: () => {
|
||||||
if (controllerSelectModal.openControllerSelectModal()) {
|
if (controllerSelectModal.openControllerSelectModal()) {
|
||||||
@@ -525,6 +537,12 @@ function registerModalOpenHandlers(): void {
|
|||||||
window.electronAPI.notifyOverlayModalOpened('session-help');
|
window.electronAPI.notifyOverlayModalOpened('session-help');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
window.electronAPI.onOpenChangelog(() => {
|
||||||
|
runGuarded('changelog:open', () => {
|
||||||
|
changelogModal.openChangelogModal();
|
||||||
|
window.electronAPI.notifyOverlayModalOpened('changelog');
|
||||||
|
});
|
||||||
|
});
|
||||||
window.electronAPI.onOpenControllerSelect(() => {
|
window.electronAPI.onOpenControllerSelect(() => {
|
||||||
runGuarded('controller-select:open', () => {
|
runGuarded('controller-select:open', () => {
|
||||||
if (controllerSelectModal.openControllerSelectModal()) {
|
if (controllerSelectModal.openControllerSelectModal()) {
|
||||||
@@ -794,6 +812,7 @@ async function init(): Promise<void> {
|
|||||||
controllerSelectModal.wireDomEvents();
|
controllerSelectModal.wireDomEvents();
|
||||||
controllerDebugModal.wireDomEvents();
|
controllerDebugModal.wireDomEvents();
|
||||||
sessionHelpModal.wireDomEvents();
|
sessionHelpModal.wireDomEvents();
|
||||||
|
changelogModal.wireDomEvents();
|
||||||
subtitleSidebarModal.wireDomEvents();
|
subtitleSidebarModal.wireDomEvents();
|
||||||
characterDictionaryModal.wireDomEvents();
|
characterDictionaryModal.wireDomEvents();
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ export type RendererState = {
|
|||||||
|
|
||||||
sessionHelpModalOpen: boolean;
|
sessionHelpModalOpen: boolean;
|
||||||
sessionHelpSelectedIndex: number;
|
sessionHelpSelectedIndex: number;
|
||||||
|
changelogModalOpen: boolean;
|
||||||
|
changelogSelectedIndex: number;
|
||||||
playlistBrowserModalOpen: boolean;
|
playlistBrowserModalOpen: boolean;
|
||||||
playlistBrowserSnapshot: PlaylistBrowserSnapshot | null;
|
playlistBrowserSnapshot: PlaylistBrowserSnapshot | null;
|
||||||
playlistBrowserStatus: string;
|
playlistBrowserStatus: string;
|
||||||
@@ -228,6 +230,8 @@ export function createRendererState(): RendererState {
|
|||||||
|
|
||||||
sessionHelpModalOpen: false,
|
sessionHelpModalOpen: false,
|
||||||
sessionHelpSelectedIndex: 0,
|
sessionHelpSelectedIndex: 0,
|
||||||
|
changelogModalOpen: false,
|
||||||
|
changelogSelectedIndex: 0,
|
||||||
playlistBrowserModalOpen: false,
|
playlistBrowserModalOpen: false,
|
||||||
playlistBrowserSnapshot: null,
|
playlistBrowserSnapshot: null,
|
||||||
playlistBrowserStatus: '',
|
playlistBrowserStatus: '',
|
||||||
|
|||||||
@@ -3182,3 +3182,267 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
|
|||||||
text-align: center;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -120,6 +120,15 @@ export type RendererDom = {
|
|||||||
subtitleSidebarStatus: HTMLDivElement;
|
subtitleSidebarStatus: HTMLDivElement;
|
||||||
subtitleSidebarList: HTMLUListElement;
|
subtitleSidebarList: HTMLUListElement;
|
||||||
|
|
||||||
|
changelogModal: HTMLDivElement;
|
||||||
|
changelogClose: HTMLButtonElement;
|
||||||
|
changelogRefresh: HTMLButtonElement;
|
||||||
|
changelogInstalled: HTMLSpanElement;
|
||||||
|
changelogSource: HTMLSpanElement;
|
||||||
|
changelogWarning: HTMLDivElement;
|
||||||
|
changelogStatus: HTMLDivElement;
|
||||||
|
changelogList: HTMLDivElement;
|
||||||
|
|
||||||
sessionHelpModal: HTMLDivElement;
|
sessionHelpModal: HTMLDivElement;
|
||||||
sessionHelpClose: HTMLButtonElement;
|
sessionHelpClose: HTMLButtonElement;
|
||||||
sessionHelpShortcut: HTMLDivElement;
|
sessionHelpShortcut: HTMLDivElement;
|
||||||
@@ -289,6 +298,14 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
subtitleSidebarStatus: getRequiredElement<HTMLDivElement>('subtitleSidebarStatus'),
|
subtitleSidebarStatus: getRequiredElement<HTMLDivElement>('subtitleSidebarStatus'),
|
||||||
subtitleSidebarList: getRequiredElement<HTMLUListElement>('subtitleSidebarList'),
|
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'),
|
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
|
||||||
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
||||||
sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'),
|
sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const OVERLAY_HOSTED_MODALS = [
|
|||||||
'subtitle-sidebar',
|
'subtitle-sidebar',
|
||||||
'session-help',
|
'session-help',
|
||||||
'character-dictionary',
|
'character-dictionary',
|
||||||
|
'changelog',
|
||||||
] as const;
|
] as const;
|
||||||
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
||||||
|
|
||||||
@@ -123,6 +124,7 @@ export const IPC_CHANNELS = {
|
|||||||
syncUiDeleteSnapshot: 'sync-ui:delete-snapshot',
|
syncUiDeleteSnapshot: 'sync-ui:delete-snapshot',
|
||||||
syncUiRevealSnapshot: 'sync-ui:reveal-snapshot',
|
syncUiRevealSnapshot: 'sync-ui:reveal-snapshot',
|
||||||
syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file',
|
syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file',
|
||||||
|
getChangelogSnapshot: 'changelog:get-snapshot',
|
||||||
},
|
},
|
||||||
event: {
|
event: {
|
||||||
subtitleSet: 'subtitle:set',
|
subtitleSet: 'subtitle:set',
|
||||||
@@ -146,6 +148,7 @@ export const IPC_CHANNELS = {
|
|||||||
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
||||||
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
||||||
sessionHelpOpen: 'session-help:open',
|
sessionHelpOpen: 'session-help:open',
|
||||||
|
changelogOpen: 'changelog:open',
|
||||||
characterDictionaryManagerOpen: 'character-dictionary:manager-open',
|
characterDictionaryManagerOpen: 'character-dictionary:manager-open',
|
||||||
controllerSelectOpen: 'controller-select:open',
|
controllerSelectOpen: 'controller-select:open',
|
||||||
controllerDebugOpen: 'controller-debug:open',
|
controllerDebugOpen: 'controller-debug:open',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './types/anki';
|
export * from './types/anki';
|
||||||
|
export * from './types/changelog';
|
||||||
export * from './types/config';
|
export * from './types/config';
|
||||||
export * from './types/integrations';
|
export * from './types/integrations';
|
||||||
export * from './types/notification';
|
export * from './types/notification';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -20,6 +20,8 @@ export interface OverlayNotificationAction {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
noteId?: number;
|
noteId?: number;
|
||||||
|
/** Leaves the notification on screen after the action fires (default: dismiss). */
|
||||||
|
keepOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OverlayNotificationPayload {
|
export interface OverlayNotificationPayload {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
KikuMergePreviewRequest,
|
KikuMergePreviewRequest,
|
||||||
KikuMergePreviewResponse,
|
KikuMergePreviewResponse,
|
||||||
} from './anki';
|
} from './anki';
|
||||||
|
import type { ChangelogSnapshot } from './changelog';
|
||||||
import type { ResolvedConfig, ShortcutsConfig } from './config';
|
import type { ResolvedConfig, ShortcutsConfig } from './config';
|
||||||
import type {
|
import type {
|
||||||
CompiledSessionBinding,
|
CompiledSessionBinding,
|
||||||
@@ -507,6 +508,8 @@ export interface ElectronAPI {
|
|||||||
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
|
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
|
||||||
onOpenRuntimeOptions: (callback: () => void) => void;
|
onOpenRuntimeOptions: (callback: () => void) => void;
|
||||||
onOpenSessionHelp: (callback: () => void) => void;
|
onOpenSessionHelp: (callback: () => void) => void;
|
||||||
|
onOpenChangelog: (callback: () => void) => void;
|
||||||
|
getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||||
onOpenControllerSelect: (callback: () => void) => void;
|
onOpenControllerSelect: (callback: () => void) => void;
|
||||||
onOpenControllerDebug: (callback: () => void) => void;
|
onOpenControllerDebug: (callback: () => void) => void;
|
||||||
onOpenJimaku: (callback: () => void) => void;
|
onOpenJimaku: (callback: () => void) => void;
|
||||||
@@ -563,7 +566,8 @@ export interface ElectronAPI {
|
|||||||
| 'controller-debug'
|
| 'controller-debug'
|
||||||
| 'subtitle-sidebar'
|
| 'subtitle-sidebar'
|
||||||
| 'session-help'
|
| 'session-help'
|
||||||
| 'character-dictionary',
|
| 'character-dictionary'
|
||||||
|
| 'changelog',
|
||||||
) => void;
|
) => void;
|
||||||
notifyOverlayModalOpened: (
|
notifyOverlayModalOpened: (
|
||||||
modal:
|
modal:
|
||||||
@@ -578,7 +582,8 @@ export interface ElectronAPI {
|
|||||||
| 'controller-debug'
|
| 'controller-debug'
|
||||||
| 'subtitle-sidebar'
|
| 'subtitle-sidebar'
|
||||||
| 'session-help'
|
| 'session-help'
|
||||||
| 'character-dictionary',
|
| 'character-dictionary'
|
||||||
|
| 'changelog',
|
||||||
) => void;
|
) => void;
|
||||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||||
|
|||||||
Reference in New Issue
Block a user