diff --git a/changes/linux-updater-curl-fetch.md b/changes/linux-updater-curl-fetch.md new file mode 100644 index 00000000..1973ddcc --- /dev/null +++ b/changes/linux-updater-curl-fetch.md @@ -0,0 +1,4 @@ +type: fixed +area: updater + +- Fixed Linux automatic update checks to avoid Electron networking, preventing native Electron network-service crashes during video startup. diff --git a/changes/overlay-restart-visible.md b/changes/overlay-restart-visible.md index 2be9e94b..c52746b8 100644 --- a/changes/overlay-restart-visible.md +++ b/changes/overlay-restart-visible.md @@ -1,4 +1,4 @@ type: fixed area: overlay -- Kept the visible overlay open after restarting SubMiner from the mpv `y-r` shortcut. +- Kept the visible overlay open after restarting SubMiner from the mpv `y-r` shortcut, including readiness-time restore when visible-overlay auto-start is disabled. diff --git a/changes/yomitan-popup-late-load.md b/changes/yomitan-popup-late-load.md new file mode 100644 index 00000000..65940ab5 --- /dev/null +++ b/changes/yomitan-popup-late-load.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed Yomitan popups not opening when playback/overlay startup races the Yomitan extension load. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 9386aa9b..a4bf694a 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -91,6 +91,7 @@ Notes: - macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS ZIP, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer. - macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks. - Windows tray app updates use the standard `electron-updater`/NSIS path. Keep `latest.yml`, the Windows NSIS installer, and installer blockmap published; updater HTTP is routed through main-process fetch to avoid Electron main-process network crashes during update checks. +- Linux GitHub release metadata and asset downloads also use `/usr/bin/curl` instead of Electron networking for the same reason. - Local macOS build-output apps outside `/Applications` or `~/Applications` skip native update checks. To validate auto-update end to end, install the signed and notarized app bundle into one of those Applications folders and point it at a published updater feed. - The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code. - Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior. diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index 382ddf13..f5529e28 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -164,10 +164,15 @@ function M.create(ctx) local function notify_auto_play_ready() release_auto_play_ready_gate("tokenization-ready") - if state.suppress_ready_overlay_restore then + local force_ready_overlay_restore = state.force_ready_overlay_restore == true + state.force_ready_overlay_restore = false + if state.suppress_ready_overlay_restore and not force_ready_overlay_restore then return end - if state.overlay_running and resolve_visible_overlay_startup() then + if force_ready_overlay_restore then + state.suppress_ready_overlay_restore = false + end + if state.overlay_running and (force_ready_overlay_restore or resolve_visible_overlay_startup()) then run_control_command_async("show-visible-overlay", { socket_path = opts.socket_path, }) @@ -514,6 +519,8 @@ function M.create(ctx) state.overlay_running = false state.texthooker_running = false + state.suppress_ready_overlay_restore = false + state.force_ready_overlay_restore = true disarm_auto_play_ready_gate() local start_args = build_command_args("start", { diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua index 579c86bb..c27f60dd 100644 --- a/plugin/subminer/state.lua +++ b/plugin/subminer/state.lua @@ -33,6 +33,7 @@ function M.new() auto_play_ready_timeout = nil, auto_play_ready_osd_timer = nil, suppress_ready_overlay_restore = false, + force_ready_overlay_restore = false, current_media_identity = nil, pending_reload_media_identity = nil, session_binding_generation = 0, diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 487068f0..964782f8 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -600,6 +600,40 @@ do ) end +do + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "no", + auto_start_visible_overlay = "no", + }, + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for restart ready restore scenario: " .. tostring(err)) + assert_true( + recorded.script_messages["subminer-toggle"] ~= nil, + "subminer-toggle script message not registered" + ) + assert_true( + recorded.script_messages["subminer-restart"] ~= nil, + "subminer-restart script message not registered" + ) + assert_true( + recorded.script_messages["subminer-autoplay-ready"] ~= nil, + "subminer-autoplay-ready script message not registered" + ) + recorded.script_messages["subminer-toggle"]() + recorded.script_messages["subminer-restart"]() + recorded.script_messages["subminer-autoplay-ready"]() + assert_true( + count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1, + "manual restart should re-assert visible overlay on readiness even when auto-start visibility is disabled" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "", diff --git a/src/main.ts b/src/main.ts index c0a710c9..edaf328c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -500,7 +500,11 @@ import { createElectronAppUpdater, isNativeUpdaterSupported, } from './main/runtime/update/app-updater'; -import { createElectronNetFetch, createGlobalFetch } from './main/runtime/update/fetch-adapter'; +import { + createCurlFetch, + createElectronNetFetch, + createGlobalFetch, +} from './main/runtime/update/fetch-adapter'; import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor'; import { createFetchHttpExecutor } from './main/runtime/update/fetch-http-executor'; import { @@ -537,6 +541,7 @@ import { import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime'; import { isYoutubePlaybackActive } from './main/runtime/youtube-playback'; import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy'; +import { reloadOverlayWindowsForYomitanContentScripts } from './main/runtime/yomitan-extension-overlay-reload'; import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log'; import { getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime, @@ -4739,6 +4744,7 @@ const electronNetFetch = createElectronNetFetch({ fetch: (url, init) => net.fetch(url, init as RequestInit), }); const globalFetchForUpdater = createGlobalFetch(); +const curlFetch = createCurlFetch(); function createNativeUpdaterHttpExecutor() { if (process.platform === 'darwin') { @@ -4754,6 +4760,7 @@ function getFetchForUpdater() { if (process.platform === 'win32') { return globalFetchForUpdater; } + if (process.platform === 'linux') return curlFetch; return electronNetFetch; } @@ -5820,6 +5827,15 @@ const yomitanExtensionRuntime = createYomitanExtensionRuntime({ setLoadInFlight: (promise) => { yomitanLoadInFlight = promise; }, + onYomitanExtensionLoaded: () => { + const reloaded = reloadOverlayWindowsForYomitanContentScripts( + getOverlayWindows(), + (message, error) => logger.warn(message, error), + ); + if (reloaded > 0) { + logger.debug(`Reloaded ${reloaded} overlay window(s) after Yomitan extension load.`); + } + }, }); const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = runtimeRegistry.overlay.createOverlayRuntimeBootstrapHandlers({ diff --git a/src/main/runtime/update/fetch-adapter.test.ts b/src/main/runtime/update/fetch-adapter.test.ts index 9c61591f..910f4643 100644 --- a/src/main/runtime/update/fetch-adapter.test.ts +++ b/src/main/runtime/update/fetch-adapter.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createElectronNetFetch, createGlobalFetch } from './fetch-adapter'; +import { createCurlFetch, createElectronNetFetch, createGlobalFetch } from './fetch-adapter'; import type { FetchResponseLike } from './release-assets'; test('createElectronNetFetch delegates updater requests to Electron net.fetch', async () => { @@ -62,3 +62,50 @@ test('createGlobalFetch delegates updater requests to main-process fetch', async }, ]); }); + +test('createCurlFetch requests updater metadata without Electron networking', async () => { + const calls: Array<{ + file: string; + args: readonly string[]; + options: { encoding: 'utf8' | 'buffer'; maxBuffer?: number; timeout?: number }; + }> = []; + const payload = Buffer.from(JSON.stringify([{ tag_name: 'v1.2.3', assets: [] }])); + + const fetch = createCurlFetch({ + curlPath: '/usr/bin/curl', + execFile: (file, args, options, callback) => { + calls.push({ file, args, options }); + callback(null, payload, Buffer.alloc(0)); + return { kill: () => undefined }; + }, + }); + + const response = await fetch('https://api.github.com/repos/ksyasuda/SubMiner/releases', { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'SubMiner updater', + }, + }); + + assert.equal(response.ok, true); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), [{ tag_name: 'v1.2.3', assets: [] }]); + assert.equal(await response.text(), '[{"tag_name":"v1.2.3","assets":[]}]'); + assert.deepEqual(Buffer.from(await response.arrayBuffer()), payload); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.file, '/usr/bin/curl'); + assert.deepEqual(calls[0]?.args, [ + '--fail', + '--location', + '--silent', + '--show-error', + '--connect-timeout', + '30', + '--header', + 'Accept: application/vnd.github+json', + '--header', + 'User-Agent: SubMiner updater', + 'https://api.github.com/repos/ksyasuda/SubMiner/releases', + ]); + assert.equal(calls[0]?.options.encoding, 'buffer'); +}); diff --git a/src/main/runtime/update/fetch-adapter.ts b/src/main/runtime/update/fetch-adapter.ts index 122262fb..f5e2d8c8 100644 --- a/src/main/runtime/update/fetch-adapter.ts +++ b/src/main/runtime/update/fetch-adapter.ts @@ -1,4 +1,6 @@ +import { execFile as defaultExecFile } from 'node:child_process'; import type { FetchLike, FetchResponseLike } from './release-assets'; +import type { CurlExecFile } from './curl-http-executor'; export interface ElectronNetFetchLike { fetch: (url: string, init?: Record) => Promise; @@ -20,3 +22,81 @@ function getGlobalFetch(): GlobalFetchLike { export function createGlobalFetch(fetchImpl?: GlobalFetchLike): FetchLike { return (url, init) => (fetchImpl ?? getGlobalFetch())(url, init as RequestInit); } + +type CurlFetchOptions = { + execFile?: CurlExecFile; + curlPath?: string; +}; + +function addHeaderArgs(args: string[], headers: unknown): void { + if (!headers) return; + if (Array.isArray(headers)) { + for (const header of headers) { + if (Array.isArray(header) && header.length >= 2) { + args.push('--header', `${header[0]}: ${header[1]}`); + } + } + return; + } + if (typeof headers === 'object' && 'forEach' in headers) { + (headers as { forEach: (callback: (value: string, name: string) => void) => void }).forEach( + (value, name) => { + args.push('--header', `${name}: ${value}`); + }, + ); + return; + } + if (typeof headers !== 'object') return; + for (const [name, value] of Object.entries(headers as Record)) { + if (value === undefined) continue; + const values = Array.isArray(value) ? value : [value]; + for (const item of values) { + args.push('--header', `${name}: ${String(item)}`); + } + } +} + +function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { + const result = new ArrayBuffer(buffer.length); + new Uint8Array(result).set(buffer); + return result; +} + +export function createCurlFetch(options: CurlFetchOptions = {}): FetchLike { + const execFile = options.execFile ?? (defaultExecFile as unknown as CurlExecFile); + const curlPath = options.curlPath ?? '/usr/bin/curl'; + + return async (url, init = {}) => { + const args = ['--fail', '--location', '--silent', '--show-error', '--connect-timeout', '30']; + addHeaderArgs(args, init.headers); + args.push(url); + const body = await new Promise((resolve, reject) => { + execFile( + curlPath, + args, + { + encoding: 'buffer', + maxBuffer: 600 * 1024 * 1024, + }, + (error, stdout, stderr) => { + if (error) { + const stderrMessage = Buffer.isBuffer(stderr) ? stderr.toString('utf8') : stderr; + const errno = (error as NodeJS.ErrnoException).code; + const fallback = errno ? `curl failed (${errno})` : 'curl failed'; + reject(new Error(stderrMessage.trim() || fallback)); + return; + } + resolve(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout)); + }, + ); + }); + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => JSON.parse(body.toString('utf8')), + text: async () => body.toString('utf8'), + arrayBuffer: async () => bufferToArrayBuffer(body), + }; + }; +} diff --git a/src/main/runtime/yomitan-extension-overlay-reload.test.ts b/src/main/runtime/yomitan-extension-overlay-reload.test.ts new file mode 100644 index 00000000..4b81cd8d --- /dev/null +++ b/src/main/runtime/yomitan-extension-overlay-reload.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { reloadOverlayWindowsForYomitanContentScripts } from './yomitan-extension-overlay-reload'; + +test('reloadOverlayWindowsForYomitanContentScripts reloads only live overlay windows', () => { + const calls: string[] = []; + const windows = [ + { + isDestroyed: () => false, + webContents: { + isDestroyed: () => false, + reload: () => calls.push('live'), + }, + }, + { + isDestroyed: () => true, + webContents: { + isDestroyed: () => false, + reload: () => calls.push('destroyed-window'), + }, + }, + { + isDestroyed: () => false, + webContents: { + isDestroyed: () => true, + reload: () => calls.push('destroyed-webcontents'), + }, + }, + ]; + + assert.equal(reloadOverlayWindowsForYomitanContentScripts(windows), 1); + assert.deepEqual(calls, ['live']); +}); diff --git a/src/main/runtime/yomitan-extension-overlay-reload.ts b/src/main/runtime/yomitan-extension-overlay-reload.ts new file mode 100644 index 00000000..99188307 --- /dev/null +++ b/src/main/runtime/yomitan-extension-overlay-reload.ts @@ -0,0 +1,36 @@ +type ReloadableWebContents = { + isDestroyed?: () => boolean; + reload: () => void; +}; + +type ReloadableOverlayWindow = { + isDestroyed: () => boolean; + webContents?: ReloadableWebContents; +}; + +export function reloadOverlayWindowsForYomitanContentScripts( + windows: ReloadableOverlayWindow[], + logWarn?: (message: string, error: unknown) => void, +): number { + let reloadCount = 0; + + for (const window of windows) { + if (window.isDestroyed()) { + continue; + } + + const webContents = window.webContents; + if (!webContents || webContents.isDestroyed?.()) { + continue; + } + + try { + webContents.reload(); + reloadCount += 1; + } catch (error) { + logWarn?.('Failed to reload overlay window after Yomitan extension load.', error); + } + } + + return reloadCount; +} diff --git a/src/main/runtime/yomitan-extension-runtime.test.ts b/src/main/runtime/yomitan-extension-runtime.test.ts index 71398697..d9e88639 100644 --- a/src/main/runtime/yomitan-extension-runtime.test.ts +++ b/src/main/runtime/yomitan-extension-runtime.test.ts @@ -114,3 +114,54 @@ test('yomitan extension runtime direct load delegates to core', async () => { assert.equal(receivedExternalProfilePath, '/tmp/gsm-profile'); assert.deepEqual(yomitanSession, { id: 'session' }); }); + +test('yomitan extension runtime notifies once after concurrent ensure load resolves', async () => { + let extension: Extension | null = null; + let inFlight: Promise | null = null; + const notifications: Extension[] = []; + const releaseLoadState: { releaseLoad: ((value: Extension | null) => void) | null } = { + releaseLoad: null, + }; + + const runtime = createYomitanExtensionRuntime({ + loadYomitanExtensionCore: async (options) => { + return await new Promise((resolve) => { + releaseLoadState.releaseLoad = (value) => { + options.setYomitanExtension(value); + resolve(value); + }; + }); + }, + userDataPath: '/tmp', + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => {}, + setYomitanParserReadyPromise: () => {}, + setYomitanParserInitPromise: () => {}, + setYomitanExtension: (next) => { + extension = next; + }, + setYomitanSession: () => {}, + getYomitanExtension: () => extension, + getLoadInFlight: () => inFlight, + setLoadInFlight: (promise) => { + inFlight = promise; + }, + onYomitanExtensionLoaded: (loadedExtension) => { + notifications.push(loadedExtension); + }, + }); + + const first = runtime.ensureYomitanExtensionLoaded(); + const second = runtime.ensureYomitanExtensionLoaded(); + const fakeExtension = { id: 'yomitan' } as Extension; + const releaseLoad = releaseLoadState.releaseLoad; + if (!releaseLoad) { + throw new Error('expected in-flight yomitan load resolver'); + } + + releaseLoad(fakeExtension); + + assert.equal(await first, fakeExtension); + assert.equal(await second, fakeExtension); + assert.deepEqual(notifications, [fakeExtension]); +}); diff --git a/src/main/runtime/yomitan-extension-runtime.ts b/src/main/runtime/yomitan-extension-runtime.ts index 60093a68..1d225416 100644 --- a/src/main/runtime/yomitan-extension-runtime.ts +++ b/src/main/runtime/yomitan-extension-runtime.ts @@ -2,6 +2,7 @@ import { createEnsureYomitanExtensionLoadedHandler, createLoadYomitanExtensionHandler, } from './yomitan-extension-loader'; +import type { Extension } from 'electron'; import { createBuildEnsureYomitanExtensionLoadedMainDepsHandler, createBuildLoadYomitanExtensionMainDepsHandler, @@ -17,7 +18,9 @@ type EnsureYomitanExtensionLoadedMainDeps = Omit< >; export type YomitanExtensionRuntimeDeps = LoadYomitanExtensionMainDeps & - EnsureYomitanExtensionLoadedMainDeps; + EnsureYomitanExtensionLoadedMainDeps & { + onYomitanExtensionLoaded?: (extension: Extension) => void | Promise; + }; export function createYomitanExtensionRuntime(deps: YomitanExtensionRuntimeDeps) { const buildLoadYomitanExtensionMainDepsHandler = createBuildLoadYomitanExtensionMainDepsHandler({ @@ -46,10 +49,27 @@ export function createYomitanExtensionRuntime(deps: YomitanExtensionRuntimeDeps) buildEnsureYomitanExtensionLoadedMainDepsHandler(), ); + let lastNotifiedExtension: Extension | null = null; + async function notifyYomitanExtensionLoaded(extension: Extension | null): Promise { + if (!extension || extension === lastNotifiedExtension) { + return; + } + lastNotifiedExtension = extension; + await deps.onYomitanExtensionLoaded?.(extension); + } + return { - loadYomitanExtension: (): Promise> => - loadYomitanExtensionHandler(), - ensureYomitanExtensionLoaded: (): Promise> => - ensureYomitanExtensionLoadedHandler(), + loadYomitanExtension: async (): Promise> => { + const extension = await loadYomitanExtensionHandler(); + await notifyYomitanExtensionLoaded(extension); + return extension; + }, + ensureYomitanExtensionLoaded: async (): Promise< + ReturnType + > => { + const extension = await ensureYomitanExtensionLoadedHandler(); + await notifyYomitanExtensionLoaded(extension); + return extension; + }, }; }