diff --git a/changes/changelog-modal.md b/changes/changelog-modal.md new file mode 100644 index 00000000..c4457f79 --- /dev/null +++ b/changes/changelog-modal.md @@ -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. diff --git a/docs-site/architecture.md b/docs-site/architecture.md index d0b8d7fb..a88ad7bf 100644 --- a/docs-site/architecture.md +++ b/docs-site/architecture.md @@ -75,8 +75,8 @@ src/ renderer/ # Overlay renderer (modularized UI/runtime) handlers/ # Keyboard/mouse/gamepad interaction modules modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help, - # character dictionary, playlist browser, subtitle sidebar, - # YouTube track picker, controller config/debug/select) + # changelog, character dictionary, playlist browser, subtitle + # sidebar, YouTube track picker, controller config/debug/select) positioning/ # Subtitle position controller (drag-to-reposition) settings/ # Settings window UI (model, controls, markup) types/ # Domain type modules (anki, config, integrations, ...) diff --git a/docs-site/usage.md b/docs-site/usage.md index 49db3bbc..eb836119 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -145,6 +145,8 @@ The tray menu includes `Export Logs`, which creates the same sanitized local-dat Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config. +The tray menu also includes `View Changelog`, which opens the in-app changelog modal. It fetches the changelog from the newest published release, so you see release notes for versions newer than the one you run; if the download fails it falls back to the changelog bundled with your install and says so. Versions in the current `0.x` line are expanded by default and older lines are folded, matching this site's [Changelog](/changelog). A badge marks the version you have installed, and newer versions are tagged `New`. The same modal opens from the `What's New` button on the update-available overlay notification. + ### Logging and App Mode - `--log-level` controls logger verbosity. @@ -368,6 +370,8 @@ Press `V` to cycle the primary SubMiner subtitle bar through hidden → visible `Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. The same help view is also available through the `y-h` chord in mpv. +The changelog modal (tray > `View Changelog`) works the same way: it renders over mpv when a video is playing and in its own window otherwise. Use `J`/`K` or the arrow keys to move between versions, `Enter` to fold or unfold one, `R` to refetch, and `Esc` to close. + Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior. ### Drag-and-Drop diff --git a/package.json b/package.json index d39e5cb4..c5e4f09e 100644 --- a/package.json +++ b/package.json @@ -260,6 +260,10 @@ { "from": "dist/launcher/subminer", "to": "launcher/subminer" + }, + { + "from": "CHANGELOG.md", + "to": "CHANGELOG.md" } ] }, diff --git a/release/release-notes.md b/release/release-notes.md deleted file mode 100644 index f869714d..00000000 --- a/release/release-notes.md +++ /dev/null @@ -1,40 +0,0 @@ -## Highlights -### Changed - -- **Subsync Reference & Target Picker** - - You can now choose both sides of a sync run: which subtitle is the timing reference and which one gets retimed. - - The video file itself can be used as the reference for local files (audio-based sync), though a subtitle track stays the default. - - Works for both alass and ffsubsync, and retiming the secondary subtitle track no longer overwrites your primary one. - -### Fixed - -- **Startup Logging** - - Background startup now respects your configured log level even when no `--log-level` flag is passed. -- **Streaming Subtitle Tokenization** - - Jellyfin playback now seeds tokenization straight from the downloaded subtitle file, so episodes no longer fall back to slow, line-by-line tokenizing while waiting on playback events. - - Subtitle cues are no longer dropped when switching to a subtitle track embedded in the stream. - - Prefetching now runs through the whole episode instead of stopping once the cache filled, and the cache clears between episodes so slowdowns don't carry over to later titles. - - The tokenization cache was expanded from 256 to 2,500 lines, leaving more room for repeated lines (like openings and endings) to stay cached across episodes. -- **Subtitle Line Display** - - Subtitle lines now appear immediately at their cue time even if tokenization hasn't finished, upgrading in place with annotations once ready. - - A failed tokenization attempt is no longer cached as plain text, so the line gets another chance at full annotations later. - -## What's Changed - -- feat(subsync): add reference and target subtitle track picker by @ksyasuda in #181 -- fix(logging): surface subtitle processing debug/warn logs by @ksyasuda in #182 -- fix(streaming): keep subtitle tokenization prefetch warm for full episodes by @ksyasuda in #183 -- fix(overlay): show plain subtitle line immediately on tokenization cache miss by @ksyasuda in #184 - -## Installation - -See the README and docs/installation guide for full setup steps. - -## Assets - -- Linux: `SubMiner.AppImage` -- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip` -- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip` -- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher - -Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`. diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 78e17c75..032ed2b2 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -1,6 +1,7 @@ import electron from 'electron'; import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron'; import type { + ChangelogSnapshot, CompiledSessionBinding, ControllerConfigUpdate, PlaylistBrowserMutationResult, @@ -122,6 +123,7 @@ export interface IpcServiceDeps { removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise; moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise; appendClipboardVideoToQueue: () => { ok: boolean; message: string }; + getChangelogSnapshot?: (options?: { refresh?: boolean }) => Promise; getPlaylistBrowserSnapshot: () => Promise; appendPlaylistBrowserFile: (filePath: string) => Promise; playPlaylistBrowserIndex: (index: number) => Promise; @@ -297,6 +299,7 @@ export interface IpcDepsRuntimeOptions { removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise; moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise; appendClipboardVideoToQueue: () => { ok: boolean; message: string }; + getChangelogSnapshot?: (options?: { refresh?: boolean }) => Promise; getPlaylistBrowserSnapshot: () => Promise; appendPlaylistBrowserFile: (filePath: string) => Promise; playPlaylistBrowserIndex: (index: number) => Promise; @@ -418,6 +421,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService entries: [], })), appendClipboardVideoToQueue: options.appendClipboardVideoToQueue, + getChangelogSnapshot: options.getChangelogSnapshot, getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot, appendPlaylistBrowserFile: options.appendPlaylistBrowserFile, playPlaylistBrowserIndex: options.playPlaylistBrowserIndex, @@ -820,6 +824,17 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar return deps.appendClipboardVideoToQueue(); }); + ipc.handle(IPC_CHANNELS.request.getChangelogSnapshot, async (_event, payload: unknown) => { + const refresh = + typeof payload === 'object' && payload !== null && 'refresh' in payload + ? (payload as { refresh?: unknown }).refresh === true + : false; + if (!deps.getChangelogSnapshot) { + throw new Error('Changelog service is unavailable.'); + } + return await deps.getChangelogSnapshot({ refresh }); + }); + ipc.handle(IPC_CHANNELS.request.getPlaylistBrowserSnapshot, async () => { return await deps.getPlaylistBrowserSnapshot(); }); diff --git a/src/core/utils/changelog-parse.test.ts b/src/core/utils/changelog-parse.test.ts new file mode 100644 index 00000000..ff0bd3ee --- /dev/null +++ b/src/core/utils/changelog-parse.test.ts @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +import { parseChangelog, resolveChangelogGroupKey } from './changelog-parse'; + +const SAMPLE = `# Changelog + +## v0.19.2 (2026-08-04) + +### Changed +- Subsync: picks both tracks now. + +### Fixed +- Overlay: shows the plain line immediately. + +
+Internal changes + +### Internal +- Patched \`undici\`. + +
+ +## v0.19.1 (2026-08-01) + +### Added +- **Word Card Type:** + - Adds a setting. + - Flags clear each other. + +## v0.18.0 (2026-07-01) + +### Fixed +- Something older. +`; + +test('changelog parser reads versions, dates, and sections in file order', () => { + const entries = parseChangelog(SAMPLE); + + assert.deepEqual( + entries.map((entry) => `${entry.version}@${entry.date}`), + ['0.19.2@2026-08-04', '0.19.1@2026-08-01', '0.18.0@2026-07-01'], + ); + assert.deepEqual( + entries[0]?.sections.map((section) => section.heading), + ['Changed', 'Fixed', 'Internal'], + ); + assert.deepEqual(entries[0]?.sections[1]?.items, [ + { text: 'Overlay: shows the plain line immediately.', children: [] }, + ]); +}); + +test('changelog parser flags sections inside the details block as internal', () => { + const entries = parseChangelog(SAMPLE); + const sections = entries[0]?.sections ?? []; + + assert.deepEqual( + sections.map((section) => section.internal), + [false, false, true], + ); + assert.deepEqual(sections[2]?.items, [{ text: 'Patched `undici`.', children: [] }]); +}); + +test('changelog parser groups entries by major.minor', () => { + const entries = parseChangelog(SAMPLE); + + assert.deepEqual( + entries.map((entry) => entry.groupKey), + ['0.19', '0.19', '0.18'], + ); + assert.equal(resolveChangelogGroupKey('1.2.3'), '1.2'); +}); + +test('changelog parser keeps bullets that precede any section heading', () => { + const entries = parseChangelog('## v0.1.0 (2025-01-01)\n\n- Initial release.\n'); + + assert.deepEqual(entries[0]?.sections, [ + { + heading: 'Changes', + items: [{ text: 'Initial release.', children: [] }], + internal: false, + }, + ]); +}); + +test('changelog parser drops empty sections and tolerates missing dates', () => { + const entries = parseChangelog('## v0.2.0\n\n### Added\n\n### Fixed\n- One fix.\n'); + + assert.equal(entries[0]?.date, ''); + assert.deepEqual( + entries[0]?.sections.map((section) => section.heading), + ['Fixed'], + ); +}); + +test('changelog parser keeps indented sub-bullets nested under their lead bullet', () => { + const entries = parseChangelog(SAMPLE); + const added = entries[1]?.sections.find((section) => section.heading === 'Added'); + + assert.deepEqual(added?.items, [ + { + text: '**Word Card Type:**', + children: [ + { text: 'Adds a setting.', children: [] }, + { text: 'Flags clear each other.', children: [] }, + ], + }, + ]); +}); + +test('changelog parser nests three bullet levels and rejoins wrapped lines', () => { + const entries = parseChangelog( + [ + '## v0.9.0 (2025-05-05)', + '', + '### Added', + '- Top level', + ' - Second level', + ' - Third level', + ' continued on the next line', + ' - Back to second level', + '- Another top level', + '', + ].join('\n'), + ); + + assert.deepEqual(entries[0]?.sections[0]?.items, [ + { + text: 'Top level', + children: [ + { + text: 'Second level', + children: [{ text: 'Third level continued on the next line', children: [] }], + }, + { text: 'Back to second level', children: [] }, + ], + }, + { text: 'Another top level', children: [] }, + ]); +}); + +test('changelog parser handles the repo CHANGELOG.md', () => { + const markdown = fs.readFileSync(path.join(process.cwd(), 'CHANGELOG.md'), 'utf8'); + const entries = parseChangelog(markdown); + + assert.ok(entries.length > 3); + for (const entry of entries) { + assert.match(entry.version, /^\d+\.\d+\.\d+/); + assert.ok(entry.sections.length > 0, `expected sections for v${entry.version}`); + for (const section of entry.sections) { + for (const item of section.items) { + assert.ok(item.text.length > 0, `empty bullet in v${entry.version}`); + } + } + } + + // Older entries group notes under a bold lead bullet; nesting must survive. + const breaking = entries + .find((entry) => entry.version === '0.15.0') + ?.sections.find((section) => section.heading === 'Breaking Changes'); + assert.deepEqual( + breaking?.items.map((item) => `${item.text}:${item.children.length}`), + ['**Subsync:**:2', '**N+1 Highlighting:**:2'], + ); +}); diff --git a/src/core/utils/changelog-parse.ts b/src/core/utils/changelog-parse.ts new file mode 100644 index 00000000..b8f16cf2 --- /dev/null +++ b/src/core/utils/changelog-parse.ts @@ -0,0 +1,123 @@ +import type { ChangelogEntry, ChangelogItem, ChangelogSection } from '../../types/changelog'; + +const VERSION_HEADING = /^##\s+v(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\s*(?:\(([^)]*)\))?\s*$/; +const SECTION_HEADING = /^###\s+(.+?)\s*$/; +const BULLET = /^(\s*)[-*]\s+(.*)$/; + +/** + * Entries are grouped by `major.minor` so the whole current minor line renders + * expanded, matching how docs-site/changelog.md splits current vs previous. + */ +export function resolveChangelogGroupKey(version: string): string { + const match = version.match(/^(\d+)\.(\d+)/); + if (!match) return version; + return `${match[1]}.${match[2]}`; +} + +/** + * Parses the repo CHANGELOG.md into version entries. Bullets keep their inline + * markdown and their nesting: older entries group related notes under a bold + * lead bullet with indented children, and flattening them loses that structure. + */ +export function parseChangelog(markdown: string): ChangelogEntry[] { + const entries: ChangelogEntry[] = []; + let entry: ChangelogEntry | null = null; + let section: ChangelogSection | null = null; + let internal = false; + // Open bullets from outermost to innermost, used to place the next bullet. + let openItems: Array<{ indent: number; item: ChangelogItem }> = []; + + function startSection(heading: string): void { + section = { heading, items: [], internal }; + openItems = []; + entry?.sections.push(section); + } + + function addBullet(indent: number, text: string): void { + if (!section) { + // Bullets before any "###" heading (older entries) land in a generic group. + startSection('Changes'); + } + const item: ChangelogItem = { text, children: [] }; + + while (openItems.length > 0 && (openItems[openItems.length - 1]?.indent ?? 0) >= indent) { + openItems.pop(); + } + const parent = openItems[openItems.length - 1]; + if (parent) { + parent.item.children.push(item); + } else { + section?.items.push(item); + } + openItems.push({ indent, item }); + } + + function appendContinuation(text: string): void { + const current = openItems[openItems.length - 1]; + if (!current) return; + current.item.text = `${current.item.text} ${text}`; + } + + for (const rawLine of markdown.split(/\r?\n/)) { + const line = rawLine.trimEnd(); + const trimmed = line.trim(); + + const versionMatch = trimmed.match(VERSION_HEADING); + if (versionMatch) { + const version = versionMatch[1] ?? ''; + entry = { + version, + date: versionMatch[2]?.trim() ?? '', + groupKey: resolveChangelogGroupKey(version), + sections: [], + }; + entries.push(entry); + section = null; + internal = false; + openItems = []; + continue; + } + + if (!entry) continue; + + if (trimmed.startsWith(' ({ + ...item, + sections: item.sections.filter((entrySection) => entrySection.items.length > 0), + })); +} diff --git a/src/core/utils/semver-compare.ts b/src/core/utils/semver-compare.ts new file mode 100644 index 00000000..2c0ef2ab --- /dev/null +++ b/src/core/utils/semver-compare.ts @@ -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; + } => { + // 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; +} diff --git a/src/main.ts b/src/main.ts index 9b28f23f..bdc9a902 100644 --- a/src/main.ts +++ b/src/main.ts @@ -468,6 +468,8 @@ import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku import { openTsukihimeModal as openTsukihimeModalRuntime } from './main/runtime/tsukihime-open'; import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open'; import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open'; +import { openChangelogModal as openChangelogModalRuntime } from './main/runtime/changelog-open'; +import { createChangelogRuntime } from './main/runtime/changelog/changelog-runtime'; import { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open'; import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open'; import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open'; @@ -506,6 +508,7 @@ import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer' import { INSTALL_UPDATE_ACTION_ID, UPDATE_AVAILABLE_NOTIFICATION_ID, + VIEW_CHANGELOG_ACTION_ID, } from './main/runtime/update/update-notifications'; import { createOverlayNotificationsRuntime } from './main/runtime/overlay-notifications-runtime'; import { @@ -2828,6 +2831,14 @@ function openSessionHelpOverlay(): void { ); } +function openChangelogOverlay(): void { + openOverlayHostedModalWithOsd( + openChangelogModalRuntime, + 'Changelog overlay unavailable.', + 'Failed to open changelog overlay.', + ); +} + function openCharacterDictionaryManagerOverlay(): void { openCharacterDictionaryManagerWithConfigGate({ isCharacterDictionaryEnabled: () => configService.getConfig().subtitleStyle.nameMatchEnabled, @@ -5095,6 +5106,18 @@ flushPendingMpvLogWrites = () => { void flushMpvLog(); }; +const { getChangelogSnapshot } = createChangelogRuntime({ + getInstalledVersion: () => app.getVersion(), + getUpdateChannel: () => configService.getConfig().updates.channel, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath(), + dirname: __dirname, + joinPath: (...parts) => path.join(...parts), + fileExists: (candidate) => fs.existsSync(candidate), + readFile: (candidate) => fs.readFileSync(candidate, 'utf8'), + logWarn: (message) => logger.warn(message), +}); + const { getUpdateService } = createUpdateServiceRuntime({ userDataPath: USER_DATA_PATH, getUpdatesConfig: () => configService.getConfig().updates, @@ -5463,6 +5486,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ logger.warn('Failed to install update from overlay notification action:', error); }); } + if ( + notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID && + actionId === VIEW_CHANGELOG_ACTION_ID + ) { + openChangelogOverlay(); + } if (actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined) { void openAnkiCardFromNotification(noteId).catch((error) => { logger.warn('Failed to open Anki card from overlay notification action:', error); @@ -5728,6 +5757,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ return result; }, appendClipboardVideoToQueue: () => appendClipboardVideoToQueueHandler(), + getChangelogSnapshot: (options) => getChangelogSnapshot(options), ...playlistBrowserMainDeps, getImmersionTracker: () => appState.immersionTracker, }, @@ -6118,6 +6148,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = initializeOverlayRuntime: () => initializeOverlayRuntime(), isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, openSessionHelpModal: () => openSessionHelpOverlay(), + openChangelogModal: () => openChangelogOverlay(), openTexthookerInBrowser: () => handleCliCommand(parseArgs(['--texthooker', '--open-browser'])), showTexthookerPage: () => shouldShowTexthookerTrayEntry(configService.getConfig()), diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 9f7a1f71..cbbe4beb 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -109,6 +109,7 @@ export interface MainIpcRuntimeServiceDepsParams { removeCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['removeCharacterDictionaryManagedEntry']; moveCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['moveCharacterDictionaryManagedEntry']; appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue']; + getChangelogSnapshot?: IpcDepsRuntimeOptions['getChangelogSnapshot']; getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot']; appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile']; playPlaylistBrowserIndex: IpcDepsRuntimeOptions['playPlaylistBrowserIndex']; @@ -302,6 +303,7 @@ export function createMainIpcRuntimeServiceDeps( removeCharacterDictionaryManagedEntry: params.removeCharacterDictionaryManagedEntry, moveCharacterDictionaryManagedEntry: params.moveCharacterDictionaryManagedEntry, appendClipboardVideoToQueue: params.appendClipboardVideoToQueue, + getChangelogSnapshot: params.getChangelogSnapshot, getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot, appendPlaylistBrowserFile: params.appendPlaylistBrowserFile, playPlaylistBrowserIndex: params.playPlaylistBrowserIndex, diff --git a/src/main/runtime/changelog-open.ts b/src/main/runtime/changelog-open.ts new file mode 100644 index 00000000..1a0f1ae5 --- /dev/null +++ b/src/main/runtime/changelog-open.ts @@ -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; + logWarn: (message: string) => void; +}): Promise { + 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, + }, + ), + }, + ); +} diff --git a/src/main/runtime/changelog/bundled-changelog.test.ts b/src/main/runtime/changelog/bundled-changelog.test.ts new file mode 100644 index 00000000..ed981d6f --- /dev/null +++ b/src/main/runtime/changelog/bundled-changelog.test.ts @@ -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/); +}); diff --git a/src/main/runtime/changelog/bundled-changelog.ts b/src/main/runtime/changelog/bundled-changelog.ts new file mode 100644 index 00000000..8a30c84e --- /dev/null +++ b/src/main/runtime/changelog/bundled-changelog.ts @@ -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; + } +} diff --git a/src/main/runtime/changelog/changelog-runtime.ts b/src/main/runtime/changelog/changelog-runtime.ts new file mode 100644 index 00000000..1f2208e1 --- /dev/null +++ b/src/main/runtime/changelog/changelog-runtime.ts @@ -0,0 +1,69 @@ +import type { ChangelogSnapshot } from '../../../types/changelog'; +import type { UpdateChannel } from '../../../types/config'; +import { createCurlFetch, createGlobalFetch } from '../update/fetch-adapter'; +import { fetchLatestStableRelease, type FetchLike } from '../update/release-assets'; +import { readBundledChangelog, resolveBundledChangelogPath } from './bundled-changelog'; +import { createChangelogSource } from './changelog-source'; + +export interface ChangelogRuntimeDeps { + getInstalledVersion: () => string; + getUpdateChannel: () => UpdateChannel; + resourcesPath: string; + appPath: string; + dirname: string; + joinPath: (...parts: string[]) => string; + fileExists: (path: string) => boolean; + readFile: (path: string) => string; + logWarn: (message: string) => void; + /** Injected in tests; production picks curl on POSIX and global fetch on Windows. */ + createFetch?: () => FetchLike; +} + +export function createChangelogRuntime(deps: ChangelogRuntimeDeps): { + getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise; +} { + // curl matches the updater's transport choice: Electron's global fetch is + // unreliable for GitHub on some Linux builds. + const fetchImpl = + deps.createFetch?.() ?? + (process.platform === 'win32' ? createGlobalFetch() : createCurlFetch()); + + const source = createChangelogSource({ + fetchLatestReleaseTag: async () => { + const release = await fetchLatestStableRelease({ + fetch: fetchImpl, + channel: deps.getUpdateChannel(), + }); + return release?.tag_name ?? null; + }, + fetchText: async (url) => { + const response = await fetchImpl(url, { + headers: { 'User-Agent': 'SubMiner changelog' }, + }); + if (!response.ok) { + throw new Error(`Changelog request failed with ${response.status}`); + } + return await response.text(); + }, + readBundledChangelog: () => + readBundledChangelog({ + resolvePath: () => + resolveBundledChangelogPath({ + resourcesPath: deps.resourcesPath, + appPath: deps.appPath, + dirname: deps.dirname, + joinPath: deps.joinPath, + fileExists: deps.fileExists, + }), + readFile: deps.readFile, + logWarn: deps.logWarn, + }), + getInstalledVersion: deps.getInstalledVersion, + now: () => Date.now(), + logWarn: deps.logWarn, + }); + + return { + getChangelogSnapshot: (options?: { refresh?: boolean }) => source.getSnapshot(options), + }; +} diff --git a/src/main/runtime/changelog/changelog-snapshot.ts b/src/main/runtime/changelog/changelog-snapshot.ts new file mode 100644 index 00000000..2d68088e --- /dev/null +++ b/src/main/runtime/changelog/changelog-snapshot.ts @@ -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( + (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, + }; +} diff --git a/src/main/runtime/changelog/changelog-source.test.ts b/src/main/runtime/changelog/changelog-source.test.ts new file mode 100644 index 00000000..6889f68b --- /dev/null +++ b/src/main/runtime/changelog/changelog-source.test.ts @@ -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[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 () => 'Moved', + 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'); +}); diff --git a/src/main/runtime/changelog/changelog-source.ts b/src/main/runtime/changelog/changelog-source.ts new file mode 100644 index 00000000..8f03ea57 --- /dev/null +++ b/src/main/runtime/changelog/changelog-source.ts @@ -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; + fetchText: (url: string) => Promise; + /** 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; +} { + 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 | 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 { + 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 { + 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; + }, + }; +} diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts index 4ab7c629..5544e6a9 100644 --- a/src/main/runtime/tray-main-actions.test.ts +++ b/src/main/runtime/tray-main-actions.test.ts @@ -65,6 +65,7 @@ test('build tray template handler wires actions and init guards', () => { }, isOverlayRuntimeInitialized: () => initialized, openSessionHelpModal: () => calls.push('help'), + openChangelogModal: () => calls.push('changelog'), openTexthookerInBrowser: () => calls.push('texthooker'), showTexthookerPage: () => true, showFirstRunSetup: () => true, @@ -120,6 +121,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => { initializeOverlayRuntime: () => calls.push('init'), isOverlayRuntimeInitialized: () => true, openSessionHelpModal: () => calls.push('help'), + openChangelogModal: () => calls.push('changelog'), openTexthookerInBrowser: () => calls.push('texthooker'), showTexthookerPage: () => true, showFirstRunSetup: () => false, diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts index 65f4f013..76692f69 100644 --- a/src/main/runtime/tray-main-actions.ts +++ b/src/main/runtime/tray-main-actions.ts @@ -39,6 +39,7 @@ export function createBuildTrayMenuTemplateHandler(deps: { buildTrayMenuTemplateRuntime: (handlers: { platform?: string; openSessionHelp: () => void; + openChangelog: () => void; openTexthookerInBrowser: () => void; showTexthookerPage: boolean; openFirstRunSetup: () => void; @@ -60,6 +61,7 @@ export function createBuildTrayMenuTemplateHandler(deps: { initializeOverlayRuntime: () => void; isOverlayRuntimeInitialized: () => boolean; openSessionHelpModal: () => void; + openChangelogModal: () => void; openTexthookerInBrowser: () => void; showTexthookerPage: () => boolean; showFirstRunSetup: () => boolean; @@ -87,6 +89,12 @@ export function createBuildTrayMenuTemplateHandler(deps: { } deps.openSessionHelpModal(); }, + openChangelog: () => { + if (!deps.isOverlayRuntimeInitialized()) { + deps.initializeOverlayRuntime(); + } + deps.openChangelogModal(); + }, openTexthookerInBrowser: () => { deps.openTexthookerInBrowser(); }, diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts index e4dcfd44..aa514f52 100644 --- a/src/main/runtime/tray-main-deps.test.ts +++ b/src/main/runtime/tray-main-deps.test.ts @@ -25,6 +25,7 @@ test('tray main deps builders return mapped handlers', () => { initializeOverlayRuntime: () => calls.push('init'), isOverlayRuntimeInitialized: () => false, openSessionHelpModal: () => calls.push('help'), + openChangelogModal: () => calls.push('changelog'), openTexthookerInBrowser: () => calls.push('texthooker'), showTexthookerPage: () => true, showFirstRunSetup: () => true, @@ -50,6 +51,7 @@ test('tray main deps builders return mapped handlers', () => { const template = menuDeps.buildTrayMenuTemplateRuntime({ platform: menuDeps.platform, openSessionHelp: () => calls.push('open-help'), + openChangelog: () => calls.push('open-changelog'), openTexthookerInBrowser: () => calls.push('open-texthooker'), showTexthookerPage: true, openFirstRunSetup: () => calls.push('open-setup'), diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts index 4cef541f..fa3e173e 100644 --- a/src/main/runtime/tray-main-deps.ts +++ b/src/main/runtime/tray-main-deps.ts @@ -29,6 +29,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { buildTrayMenuTemplateRuntime: (handlers: { platform?: string; openSessionHelp: () => void; + openChangelog: () => void; openTexthookerInBrowser: () => void; showTexthookerPage: boolean; openFirstRunSetup: () => void; @@ -50,6 +51,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { initializeOverlayRuntime: () => void; isOverlayRuntimeInitialized: () => boolean; openSessionHelpModal: () => void; + openChangelogModal: () => void; openTexthookerInBrowser: () => void; showTexthookerPage: () => boolean; showFirstRunSetup: () => boolean; @@ -74,6 +76,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { initializeOverlayRuntime: deps.initializeOverlayRuntime, isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized, openSessionHelpModal: deps.openSessionHelpModal, + openChangelogModal: deps.openChangelogModal, openTexthookerInBrowser: deps.openTexthookerInBrowser, showTexthookerPage: deps.showTexthookerPage, showFirstRunSetup: deps.showFirstRunSetup, diff --git a/src/main/runtime/tray-runtime-handlers.test.ts b/src/main/runtime/tray-runtime-handlers.test.ts index 53eebdb1..18918db9 100644 --- a/src/main/runtime/tray-runtime-handlers.test.ts +++ b/src/main/runtime/tray-runtime-handlers.test.ts @@ -25,6 +25,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () => }, isOverlayRuntimeInitialized: () => overlayInitialized, openSessionHelpModal: () => {}, + openChangelogModal: () => {}, openTexthookerInBrowser: () => {}, showTexthookerPage: () => true, showFirstRunSetup: () => true, diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts index 3f2aecf3..4edd847f 100644 --- a/src/main/runtime/tray-runtime.test.ts +++ b/src/main/runtime/tray-runtime.test.ts @@ -30,6 +30,7 @@ test('tray menu template contains expected entries and handlers', () => { const calls: string[] = []; const template = buildTrayMenuTemplateRuntime({ openSessionHelp: () => calls.push('help'), + openChangelog: () => calls.push('changelog'), openTexthookerInBrowser: () => calls.push('texthooker'), showTexthookerPage: true, openFirstRunSetup: () => calls.push('setup'), @@ -49,7 +50,7 @@ test('tray menu template contains expected entries and handlers', () => { quitApp: () => calls.push('quit'), }); - assert.equal(template.length, 14); + assert.equal(template.length, 15); assert.equal( template.some((entry) => entry.label === 'Open Runtime Options'), false, @@ -59,26 +60,29 @@ test('tray menu template contains expected entries and handlers', () => { false, ); assert.equal(template[0]!.label, 'Open Help'); - assert.equal(template[3]!.label, 'Open SubMiner Setup'); + assert.equal(template[4]!.label, 'Open SubMiner Setup'); const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery'); assert.equal(discovery?.type, 'checkbox'); assert.equal(discovery?.checked, false); discovery?.click?.({ checked: true }); template[0]!.click?.(); - assert.equal(template[1]!.label, 'Open Texthooker'); + assert.equal(template[1]!.label, 'View Changelog'); template[1]!.click?.(); - assert.equal(template[5]!.label, 'Open SubMiner Settings'); - assert.equal(template[6]!.label, 'Sync Stats && History'); - template[6]!.click?.(); - assert.equal(template[7]!.label, 'Export Logs'); + assert.equal(template[2]!.label, 'Open Texthooker'); + template[2]!.click?.(); + assert.equal(template[6]!.label, 'Open SubMiner Settings'); + assert.equal(template[7]!.label, 'Sync Stats && History'); template[7]!.click?.(); - assert.equal(template[11]!.label, 'Check for Updates'); - template[11]!.click?.(); - template[12]!.type === 'separator' ? calls.push('separator') : calls.push('bad'); - template[13]!.click?.(); + assert.equal(template[8]!.label, 'Export Logs'); + template[8]!.click?.(); + assert.equal(template[12]!.label, 'Check for Updates'); + template[12]!.click?.(); + template[13]!.type === 'separator' ? calls.push('separator') : calls.push('bad'); + template[14]!.click?.(); assert.deepEqual(calls, [ 'jellyfin-discovery:true', 'help', + 'changelog', 'texthooker', 'sync-ui', 'export-logs', @@ -91,6 +95,7 @@ test('tray menu template contains expected entries and handlers', () => { test('tray menu template omits first-run setup entry when setup is complete', () => { const labels = buildTrayMenuTemplateRuntime({ openSessionHelp: () => undefined, + openChangelog: () => undefined, openTexthookerInBrowser: () => undefined, showTexthookerPage: true, openFirstRunSetup: () => undefined, @@ -120,6 +125,7 @@ test('tray menu template omits first-run setup entry when setup is complete', () test('tray menu template omits texthooker entry when texthooker page is disabled', () => { const labels = buildTrayMenuTemplateRuntime({ openSessionHelp: () => undefined, + openChangelog: () => undefined, openTexthookerInBrowser: () => undefined, showTexthookerPage: false, openFirstRunSetup: () => undefined, @@ -147,6 +153,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled test('tray menu template renders active jellyfin discovery checkbox', () => { const template = buildTrayMenuTemplateRuntime({ openSessionHelp: () => undefined, + openChangelog: () => undefined, openTexthookerInBrowser: () => undefined, showTexthookerPage: true, openFirstRunSetup: () => undefined, @@ -175,6 +182,7 @@ test('tray menu template renders a visible linux discovery check mark when activ const template = buildTrayMenuTemplateRuntime({ platform: 'linux', openSessionHelp: () => undefined, + openChangelog: () => undefined, openTexthookerInBrowser: () => undefined, showTexthookerPage: true, openFirstRunSetup: () => undefined, diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts index 636c8ef8..60598f37 100644 --- a/src/main/runtime/tray-runtime.ts +++ b/src/main/runtime/tray-runtime.ts @@ -32,6 +32,7 @@ export function resolveTrayIconPathRuntime(deps: { export type TrayMenuActionHandlers = { platform?: string; openSessionHelp: () => void; + openChangelog: () => void; openTexthookerInBrowser: () => void; showTexthookerPage: boolean; openFirstRunSetup: () => void; @@ -72,6 +73,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): label: 'Open Help', click: handlers.openSessionHelp, }, + { + label: 'View Changelog', + click: handlers.openChangelog, + }, ...(handlers.showTexthookerPage ? [ { diff --git a/src/main/runtime/update/release-assets.test.ts b/src/main/runtime/update/release-assets.test.ts index d33515f4..b8b7a36a 100644 --- a/src/main/runtime/update/release-assets.test.ts +++ b/src/main/runtime/update/release-assets.test.ts @@ -51,6 +51,13 @@ test('compareSemverLike orders prerelease identifiers within the same base versi assert.equal(compareSemverLike('0.15.0', '0.15.0-rc.1') > 0, true); }); +test('compareSemverLike ignores build metadata, which carries no precedence', () => { + assert.equal(compareSemverLike('0.15.0+build.2', '0.15.0+build.1'), 0); + assert.equal(compareSemverLike('0.15.0-rc.1+build.2', '0.15.0-rc.1+build.1'), 0); + assert.equal(compareSemverLike('0.15.1+build.1', '0.15.0+build.9') > 0, true); + assert.equal(compareSemverLike('0.15.0+build.1', '0.15.0-rc.1') > 0, true); +}); + test('findReleaseAsset finds exact asset names only', () => { const release = { tag_name: 'v0.14.1', diff --git a/src/main/runtime/update/release-assets.ts b/src/main/runtime/update/release-assets.ts index b6b4e0b9..4214e991 100644 --- a/src/main/runtime/update/release-assets.ts +++ b/src/main/runtime/update/release-assets.ts @@ -1,4 +1,7 @@ import type { UpdateChannel } from '../../../types/config'; +import { compareSemverLike } from '../../../core/utils/semver-compare'; + +export { compareSemverLike }; export interface GitHubReleaseAsset { name: string; @@ -130,57 +133,3 @@ export function parseReleaseVersion( if (!release) return null; return release.tag_name.replace(/^v/i, ''); } - -export function compareSemverLike(a: string, b: string): number { - const parse = ( - value: string, - ): { - core: number[]; - prerelease: Array; - } => { - 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; -} diff --git a/src/main/runtime/update/update-notifications.test.ts b/src/main/runtime/update/update-notifications.test.ts index f0856283..cf0054a7 100644 --- a/src/main/runtime/update/update-notifications.test.ts +++ b/src/main/runtime/update/update-notifications.test.ts @@ -36,7 +36,7 @@ test('notifyUpdateAvailable routes notification surfaces from config', async () ]); }); -test('notifyUpdateAvailable adds an install action to overlay update notifications', async () => { +test('notifyUpdateAvailable adds install and changelog actions to overlay update notifications', async () => { const payloads: OverlayNotificationPayload[] = []; await notifyUpdateAvailable( @@ -53,7 +53,10 @@ test('notifyUpdateAvailable adds an install action to overlay update notificatio const payload = payloads[0]; assert.ok(payload); - assert.deepEqual(payload.actions, [{ id: 'install-update', label: 'Update' }]); + assert.deepEqual(payload.actions, [ + { id: 'install-update', label: 'Update' }, + { id: 'view-changelog', label: "What's New", keepOpen: true }, + ]); assert.equal(payload.id, 'subminer-update-available'); assert.equal(payload.persistent, true); }); diff --git a/src/main/runtime/update/update-notifications.ts b/src/main/runtime/update/update-notifications.ts index 850f5c1f..eb59efc0 100644 --- a/src/main/runtime/update/update-notifications.ts +++ b/src/main/runtime/update/update-notifications.ts @@ -3,6 +3,7 @@ import type { OverlayNotificationPayload } from '../../../types/notification'; export const UPDATE_AVAILABLE_NOTIFICATION_ID = 'subminer-update-available'; export const INSTALL_UPDATE_ACTION_ID = 'install-update'; +export const VIEW_CHANGELOG_ACTION_ID = 'view-changelog'; export interface UpdateNotificationDeps { showSystemNotification: (title: string, body: string) => void; @@ -25,7 +26,10 @@ export async function notifyUpdateAvailable( body: message, variant: 'info', persistent: true, - actions: [{ id: INSTALL_UPDATE_ACTION_ID, label: 'Update' }], + actions: [ + { id: INSTALL_UPDATE_ACTION_ID, label: 'Update' }, + { id: VIEW_CHANGELOG_ACTION_ID, label: "What's New", keepOpen: true }, + ], }); } if (options.notificationType === 'osd' || options.notificationType === 'osd-system') { diff --git a/src/preload.ts b/src/preload.ts index b64819b6..eb447050 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -68,6 +68,7 @@ import type { YoutubePickerResolveResult, OverlayNotificationEventPayload, OverlayNotificationPosition, + ChangelogSnapshot, } from './types'; import { IPC_CHANNELS } from './shared/ipc/contracts'; @@ -166,6 +167,7 @@ function createLatestValueIpcListenerWithPayload( const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen); const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen); +const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen); const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener( IPC_CHANNELS.event.characterDictionaryManagerOpen, ); @@ -447,6 +449,9 @@ const electronAPI: ElectronAPI = { }, onOpenRuntimeOptions: onOpenRuntimeOptionsEvent, onOpenSessionHelp: onOpenSessionHelpEvent, + onOpenChangelog: onOpenChangelogEvent, + getChangelogSnapshot: (options?: { refresh?: boolean }): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options), onOpenControllerSelect: onOpenControllerSelectEvent, onOpenControllerDebug: onOpenControllerDebugEvent, onOpenJimaku: onOpenJimakuEvent, diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 8c3b532f..0e79ed19 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -456,6 +456,7 @@ function createKeyboardHandlerHarness() { let openControllerSelectCount = 0; let openControllerDebugCount = 0; let playlistBrowserKeydownCount = 0; + let changelogKeydownCount = 0; const createWordNode = (left: number) => ({ classList: createClassList(), @@ -504,6 +505,10 @@ function createKeyboardHandlerHarness() { return true; }, handleSessionHelpKeydown: () => false, + handleChangelogKeydown: () => { + changelogKeydownCount += 1; + return true; + }, openSessionHelpModal: () => {}, openControllerSelectModal: () => { openControllerSelectCount += 1; @@ -522,6 +527,7 @@ function createKeyboardHandlerHarness() { openControllerSelectCount: () => openControllerSelectCount, openControllerDebugCount: () => openControllerDebugCount, playlistBrowserKeydownCount: () => playlistBrowserKeydownCount, + changelogKeydownCount: () => changelogKeydownCount, setWordCount: (count: number) => { wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70)); }, @@ -1404,6 +1410,50 @@ test('keyboard mode: playlist browser modal handles h before lookup controls', a } }); +test('keyboard mode: changelog modal handles h/l fold keys before lookup controls', async () => { + const { ctx, testGlobals, handlers, changelogKeydownCount } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + ctx.state.changelogModalOpen = true; + ctx.state.keyboardSelectedWordIndex = 2; + + // H and L fold/unfold changelog entries; they must not move the subtitle + // word selection or seek mpv behind the open modal. + testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' }); + testGlobals.dispatchKeydown({ key: 'l', code: 'KeyL' }); + + assert.equal(changelogKeydownCount(), 2); + assert.equal(ctx.state.keyboardSelectedWordIndex, 2); + } finally { + testGlobals.restore(); + } +}); + +test('keyboard mode: changelog modal handles arrow keys before yomitan popup', async () => { + const { ctx, testGlobals, handlers, changelogKeydownCount } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + ctx.state.changelogModalOpen = true; + ctx.state.yomitanPopupVisible = true; + testGlobals.setPopupVisible(true); + + testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' }); + + assert.equal(changelogKeydownCount(), 1); + assert.equal( + testGlobals.commandEvents.some( + (event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown', + ), + false, + ); + } finally { + testGlobals.restore(); + } +}); + test('keyboard mode: configured stats toggle works even while popup is open', async () => { const { handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 6dc7f0f7..04f63381 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -22,6 +22,7 @@ export function createKeyboardHandlers( handleControllerSelectKeydown: (e: KeyboardEvent) => boolean; handleControllerDebugKeydown: (e: KeyboardEvent) => boolean; handleSessionHelpKeydown: (e: KeyboardEvent) => boolean; + handleChangelogKeydown: (e: KeyboardEvent) => boolean; openSessionHelpModal: (opening: { bindingKey: 'KeyH' | 'KeyK'; fallbackUsed: boolean; @@ -1095,6 +1096,14 @@ export function createKeyboardHandlers( } } + // Ahead of the keyboard-driven lookup controls: the changelog modal binds + // arrows/H/L for folding, and those would otherwise move the subtitle word + // selection (and seek mpv) behind the open modal. + if (ctx.state.changelogModalOpen) { + options.handleChangelogKeydown(e); + return; + } + if (handleKeyboardDrivenModeLookupControls(e)) { e.preventDefault(); return; diff --git a/src/renderer/index.html b/src/renderer/index.html index 9279c761..49c09027 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -467,6 +467,26 @@ +