mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-05 19:21:35 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
52f6182548
|
|||
|
4e8abcc25e
|
|||
|
3a97239ade
|
|||
|
a0dde4ee3e
|
@@ -1,5 +1,23 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.19.2 (2026-08-04)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Streaming Subtitle Tokenization: Jellyfin streams now seed subtitle tokenization directly from the downloaded subtitle file instead of relying on an mpv event that could be missed, and prefetching now runs to the end of the file and clears between episodes. The tokenization cache was raised from 256 to 2500 lines, and parsed cues are no longer lost when the active subtitle track briefly can't be resolved (e.g. switching to an embedded track). Together these prevent episodes from falling back to slow, line-by-line tokenization during playback.
|
||||||
|
- Overlay: Subtitle lines now appear immediately at their cue time even on a tokenization cache miss, upgrading in place once tokens and annotations are ready, instead of waiting on a line still being processed. A failed tokenization is no longer cached as plain text, so repeated lines get another chance at annotations.
|
||||||
|
- Background Logging: Background startup now respects the configured logging level when no explicit log level is passed.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- Patched three high-severity dependency advisories (`undici`, `brace-expansion`, `fast-uri`).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## v0.19.1 (2026-08-01)
|
## v0.19.1 (2026-08-01)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: logging
|
|
||||||
|
|
||||||
- Background startup now respects the configured logging level when `--log-level` is not explicitly provided.
|
|
||||||
@@ -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.
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: dependencies
|
|
||||||
|
|
||||||
- Patched three high-severity dependency advisories flagged by `bun audit`: `undici` (cross-user information disclosure via degenerate private cache directives), `brace-expansion` (denial of service via unbounded intermediate arrays), and `fast-uri` (host confusion via backslash authority introducer).
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: streaming
|
|
||||||
|
|
||||||
- Jellyfin playback now seeds the subtitle tokenization prefetch straight from the subtitle file it downloads, instead of waiting on an mpv track-selection event that could be missed or coalesced and leave a whole episode tokenizing line by line.
|
|
||||||
- Streamed media no longer drops its parsed subtitle cues when the active subtitle track briefly cannot be resolved, such as when cycling onto a subtitle track embedded in the stream.
|
|
||||||
- Subtitle prefetching now runs to the end of a file instead of stopping as soon as the tokenization cache fills, so the back half of an episode no longer gets tokenized line by line during playback. Previously the cache was also never cleared between episodes, so the stall carried over to every later title in a session.
|
|
||||||
- Raised the tokenization cache from 256 to 2500 lines. It is now purely a memory bound rather than a limit on how much gets prefetched, and it leaves room for lines that repeat across episodes so openings and endings stay warm between titles.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: subsync
|
|
||||||
|
|
||||||
- The subsync modal now lets you pick both sides of an alass run: the reference subtitle (correct timing, defaults to the loaded secondary subtitle track) and the out-of-sync subtitle that gets retimed (defaults to the active primary track).
|
|
||||||
- alass can now use the loaded video file itself as the reference (audio-based, local files only). It is offered in the reference list but is never the default.
|
|
||||||
- The out-of-sync subtitle picker also applies to ffsubsync, so a track other than the active primary one can be retimed.
|
|
||||||
- Retiming the secondary subtitle track now reloads the synced result back into the secondary slot and leaves the primary track selected, instead of replacing the primary subtitle.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Subtitle lines no longer wait for tokenization to finish before appearing, even when the previous line is still being processed. On a tokenization cache miss, the plain line is shown immediately at its cue time and upgrades in place once tokens and annotations are ready; stale results cannot replace newer cues, and the basic plain-text websocket no longer receives a duplicate event for the annotation-only upgrade. A failed tokenization is no longer cached as the plain line, so a repeated line gets another chance at annotations instead of staying plain for the rest of the session.
|
|
||||||
@@ -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, ...)
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.19.2 (2026-08-04)
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- Streaming Subtitle Tokenization: Jellyfin streams now seed subtitle tokenization directly from the downloaded subtitle file instead of relying on an mpv event that could be missed, and prefetching now runs to the end of the file and clears between episodes. The tokenization cache was raised from 256 to 2500 lines, and parsed cues are no longer lost when the active subtitle track briefly can't be resolved (e.g. switching to an embedded track). Together these prevent episodes from falling back to slow, line-by-line tokenization during playback.
|
||||||
|
- Overlay: Subtitle lines now appear immediately at their cue time even on a tokenization cache miss, upgrading in place once tokens and annotations are ready, instead of waiting on a line still being processed. A failed tokenization is no longer cached as plain text, so repeated lines get another chance at annotations.
|
||||||
|
- Background Logging: Background startup now respects the configured logging level when no explicit log level is passed.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
- Patched three high-severity dependency advisories (`undici`, `brace-expansion`, `fast-uri`).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## v0.19.1 (2026-08-01)
|
## v0.19.1 (2026-08-01)
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+5
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.19.1",
|
"version": "0.19.2",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -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,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,202 @@
|
|||||||
|
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'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// An unrecognized heading does not just vanish: its notes fold into the
|
||||||
|
// previous release, so the version list has to stay exact.
|
||||||
|
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,87 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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: Array<AbortSignal | undefined> = [];
|
||||||
|
const failing: FetchLike = (_url, init) => {
|
||||||
|
signals.push(init?.signal as AbortSignal | undefined);
|
||||||
|
return Promise.reject(new Error('stalled'));
|
||||||
|
};
|
||||||
|
|
||||||
|
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: () => failing,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await runtime.getChangelogSnapshot();
|
||||||
|
|
||||||
|
// Release lookup and changelog download both go through the timeout wrapper.
|
||||||
|
assert.equal(signals.length, 2);
|
||||||
|
assert.ok(signals.every((signal) => signal instanceof AbortSignal));
|
||||||
|
// No bundled copy is readable here, so the failure surfaces rather than hangs.
|
||||||
|
assert.match(snapshot.error ?? '', /stalled/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,201 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { buildRawChangelogUrl, createChangelogSource } from './changelog-source';
|
||||||
|
import { buildChangelogSnapshot } from './changelog-snapshot';
|
||||||
|
|
||||||
|
const REMOTE = `# Changelog
|
||||||
|
|
||||||
|
## v0.20.0 (2026-09-01)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Remote only entry.
|
||||||
|
|
||||||
|
## v0.19.2 (2026-08-04)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Installed entry.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BUNDLED = `# Changelog
|
||||||
|
|
||||||
|
## v0.19.2 (2026-08-04)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Installed entry.
|
||||||
|
`;
|
||||||
|
|
||||||
|
function createDeps(overrides: Partial<Parameters<typeof createChangelogSource>[0]> = {}) {
|
||||||
|
return {
|
||||||
|
fetchLatestReleaseTag: async () => 'v0.20.0',
|
||||||
|
fetchText: async () => REMOTE,
|
||||||
|
readBundledChangelog: () => BUNDLED,
|
||||||
|
getInstalledVersion: () => '0.19.2',
|
||||||
|
now: () => 1_000,
|
||||||
|
logWarn: () => {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('changelog source reads the changelog at the latest release tag', async () => {
|
||||||
|
const urls: string[] = [];
|
||||||
|
const source = createChangelogSource(
|
||||||
|
createDeps({
|
||||||
|
fetchText: async (url: string) => {
|
||||||
|
urls.push(url);
|
||||||
|
return REMOTE;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await source.getSnapshot();
|
||||||
|
|
||||||
|
assert.deepEqual(urls, [
|
||||||
|
'https://raw.githubusercontent.com/ksyasuda/SubMiner/v0.20.0/CHANGELOG.md',
|
||||||
|
]);
|
||||||
|
assert.equal(snapshot.source, 'remote');
|
||||||
|
assert.equal(snapshot.releaseTag, 'v0.20.0');
|
||||||
|
assert.equal(snapshot.latestVersion, '0.20.0');
|
||||||
|
assert.equal(snapshot.installedVersion, '0.19.2');
|
||||||
|
assert.equal(snapshot.expandedGroupKey, '0.20');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog source falls back to the default branch when no release tag resolves', async () => {
|
||||||
|
const urls: string[] = [];
|
||||||
|
const source = createChangelogSource(
|
||||||
|
createDeps({
|
||||||
|
fetchLatestReleaseTag: async () => null,
|
||||||
|
fetchText: async (url: string) => {
|
||||||
|
urls.push(url);
|
||||||
|
return REMOTE;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await source.getSnapshot();
|
||||||
|
|
||||||
|
assert.deepEqual(urls, ['https://raw.githubusercontent.com/ksyasuda/SubMiner/main/CHANGELOG.md']);
|
||||||
|
assert.equal(snapshot.source, 'remote');
|
||||||
|
assert.equal(snapshot.releaseTag, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog source falls back to the bundled changelog when the download fails', async () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const source = createChangelogSource(
|
||||||
|
createDeps({
|
||||||
|
fetchText: async () => {
|
||||||
|
throw new Error('offline');
|
||||||
|
},
|
||||||
|
logWarn: (message: string) => warnings.push(message),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await source.getSnapshot();
|
||||||
|
|
||||||
|
assert.equal(snapshot.source, 'bundled');
|
||||||
|
assert.match(snapshot.warning ?? '', /offline/);
|
||||||
|
assert.equal(snapshot.latestVersion, '0.19.2');
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog source reports an error when no changelog can be loaded', async () => {
|
||||||
|
const source = createChangelogSource(
|
||||||
|
createDeps({
|
||||||
|
fetchText: async () => {
|
||||||
|
throw new Error('offline');
|
||||||
|
},
|
||||||
|
readBundledChangelog: () => null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await source.getSnapshot();
|
||||||
|
|
||||||
|
assert.deepEqual(snapshot.entries, []);
|
||||||
|
assert.match(snapshot.error ?? '', /offline/);
|
||||||
|
assert.equal(snapshot.installedVersion, '0.19.2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog source caches remote results and refreshes on demand', async () => {
|
||||||
|
let fetches = 0;
|
||||||
|
let clock = 0;
|
||||||
|
const source = createChangelogSource(
|
||||||
|
createDeps({
|
||||||
|
now: () => clock,
|
||||||
|
fetchText: async () => {
|
||||||
|
fetches += 1;
|
||||||
|
return REMOTE;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await source.getSnapshot();
|
||||||
|
await source.getSnapshot();
|
||||||
|
assert.equal(fetches, 1);
|
||||||
|
|
||||||
|
await source.getSnapshot({ refresh: true });
|
||||||
|
assert.equal(fetches, 2);
|
||||||
|
|
||||||
|
clock = 11 * 60 * 1000;
|
||||||
|
await source.getSnapshot();
|
||||||
|
assert.equal(fetches, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog source retries the network after a bundled fallback', async () => {
|
||||||
|
let fetches = 0;
|
||||||
|
const source = createChangelogSource(
|
||||||
|
createDeps({
|
||||||
|
fetchText: async () => {
|
||||||
|
fetches += 1;
|
||||||
|
throw new Error('offline');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await source.getSnapshot();
|
||||||
|
await source.getSnapshot();
|
||||||
|
|
||||||
|
assert.equal(fetches, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog source treats an empty remote changelog as a failure', async () => {
|
||||||
|
const source = createChangelogSource(createDeps({ fetchText: async () => ' ' }));
|
||||||
|
|
||||||
|
const snapshot = await source.getSnapshot();
|
||||||
|
|
||||||
|
assert.equal(snapshot.source, 'bundled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changelog source falls back when the remote body parses to no releases', () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const source = createChangelogSource(
|
||||||
|
createDeps({
|
||||||
|
// A 200 that is not a changelog, e.g. a redirect landing page.
|
||||||
|
fetchText: async () => '<!doctype html><html><body>Moved</body></html>',
|
||||||
|
logWarn: (message: string) => warnings.push(message),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return source.getSnapshot().then((snapshot) => {
|
||||||
|
assert.equal(snapshot.source, 'bundled');
|
||||||
|
assert.equal(snapshot.entries.length, 1);
|
||||||
|
assert.match(snapshot.warning ?? '', /no releases/);
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('raw changelog urls encode the release ref', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildRawChangelogUrl('v1.0.0', 'owner', 'repo'),
|
||||||
|
'https://raw.githubusercontent.com/owner/repo/v1.0.0/CHANGELOG.md',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot expansion uses the newest version even when file order is unsorted', () => {
|
||||||
|
const snapshot = buildChangelogSnapshot(
|
||||||
|
'## v0.18.0 (2026-01-01)\n\n### Fixed\n- Old.\n\n## v0.19.0 (2026-02-01)\n\n### Fixed\n- New.\n',
|
||||||
|
{ installedVersion: '0.18.0', source: 'bundled' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(snapshot.latestVersion, '0.19.0');
|
||||||
|
assert.equal(snapshot.expandedGroupKey, '0.19');
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type { ChangelogSnapshot } from '../../../types/changelog';
|
||||||
|
import { buildChangelogSnapshot, buildEmptyChangelogSnapshot } from './changelog-snapshot';
|
||||||
|
|
||||||
|
const DEFAULT_OWNER = 'ksyasuda';
|
||||||
|
const DEFAULT_REPO = 'SubMiner';
|
||||||
|
const DEFAULT_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface ChangelogSourceDeps {
|
||||||
|
/** Resolves the release the changelog should be read from, or null when unknown. */
|
||||||
|
fetchLatestReleaseTag: () => Promise<string | null>;
|
||||||
|
fetchText: (url: string) => Promise<string>;
|
||||||
|
/** Reads the CHANGELOG.md shipped with the install; null when unavailable. */
|
||||||
|
readBundledChangelog: () => string | null;
|
||||||
|
getInstalledVersion: () => string;
|
||||||
|
now: () => number;
|
||||||
|
logWarn: (message: string) => void;
|
||||||
|
owner?: string;
|
||||||
|
repo?: string;
|
||||||
|
cacheTtlMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRawChangelogUrl(ref: string, owner: string, repo: string): string {
|
||||||
|
return `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(ref)}/CHANGELOG.md`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChangelogSource(deps: ChangelogSourceDeps): {
|
||||||
|
getSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||||
|
} {
|
||||||
|
const owner = deps.owner ?? DEFAULT_OWNER;
|
||||||
|
const repo = deps.repo ?? DEFAULT_REPO;
|
||||||
|
const cacheTtlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
||||||
|
|
||||||
|
let cached: { snapshot: ChangelogSnapshot; fetchedAt: number } | null = null;
|
||||||
|
let inFlight: Promise<ChangelogSnapshot> | null = null;
|
||||||
|
|
||||||
|
function fallbackToBundled(warning: string): ChangelogSnapshot {
|
||||||
|
const bundled = deps.readBundledChangelog();
|
||||||
|
if (bundled === null) {
|
||||||
|
return buildEmptyChangelogSnapshot({
|
||||||
|
installedVersion: deps.getInstalledVersion(),
|
||||||
|
error: warning,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return buildChangelogSnapshot(bundled, {
|
||||||
|
installedVersion: deps.getInstalledVersion(),
|
||||||
|
source: 'bundled',
|
||||||
|
warning,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSnapshot(): Promise<ChangelogSnapshot> {
|
||||||
|
let releaseTag: string | null = null;
|
||||||
|
try {
|
||||||
|
releaseTag = await deps.fetchLatestReleaseTag();
|
||||||
|
} catch (error) {
|
||||||
|
deps.logWarn(`Changelog release lookup failed: ${summarize(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a release tag the default branch still gives the newest published
|
||||||
|
// changelog, so try it before falling back to the bundled copy.
|
||||||
|
const ref = releaseTag ?? 'main';
|
||||||
|
try {
|
||||||
|
const markdown = await deps.fetchText(buildRawChangelogUrl(ref, owner, repo));
|
||||||
|
if (markdown.trim().length === 0) {
|
||||||
|
throw new Error('Remote changelog was empty.');
|
||||||
|
}
|
||||||
|
const snapshot = buildChangelogSnapshot(markdown, {
|
||||||
|
installedVersion: deps.getInstalledVersion(),
|
||||||
|
source: 'remote',
|
||||||
|
...(releaseTag ? { releaseTag } : {}),
|
||||||
|
});
|
||||||
|
// A 200 that isn't a changelog (a redirect landing page, a renamed repo)
|
||||||
|
// parses to nothing; the bundled copy beats showing an empty modal.
|
||||||
|
if (snapshot.entries.length === 0) {
|
||||||
|
throw new Error('Remote changelog contained no releases.');
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
} catch (error) {
|
||||||
|
const message = summarize(error);
|
||||||
|
deps.logWarn(`Changelog download failed (${ref}): ${message}`);
|
||||||
|
return fallbackToBundled(`Showing the bundled changelog: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async getSnapshot(options?: { refresh?: boolean }): Promise<ChangelogSnapshot> {
|
||||||
|
const refresh = options?.refresh === true;
|
||||||
|
if (!refresh && cached && deps.now() - cached.fetchedAt < cacheTtlMs) {
|
||||||
|
return cached.snapshot;
|
||||||
|
}
|
||||||
|
if (inFlight) return await inFlight;
|
||||||
|
|
||||||
|
inFlight = loadSnapshot()
|
||||||
|
.then((snapshot) => {
|
||||||
|
// Only a successful remote read is worth caching; a bundled fallback
|
||||||
|
// should retry the network on the next open.
|
||||||
|
if (snapshot.source === 'remote') {
|
||||||
|
cached = { snapshot, fetchedAt: deps.now() };
|
||||||
|
} else {
|
||||||
|
cached = null;
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inFlight = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return await inFlight;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ test('build tray template handler wires actions and init guards', () => {
|
|||||||
},
|
},
|
||||||
isOverlayRuntimeInitialized: () => initialized,
|
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
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,507 @@
|
|||||||
|
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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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,360 @@
|
|||||||
|
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;
|
||||||
|
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: Array<() => void> = [];
|
||||||
|
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) => {
|
||||||
|
timers.push(callback);
|
||||||
|
return timers.length;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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: () => {
|
||||||
|
while (timers.length > 0) timers.shift()?.();
|
||||||
|
},
|
||||||
|
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 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,145 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
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();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
attach,
|
||||||
|
detach,
|
||||||
|
enforceModalFocus,
|
||||||
|
focusFallbackTarget,
|
||||||
|
isModalFocusTarget,
|
||||||
|
requestOverlayFocus,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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