fix: curl fetch for Linux updater, overlay restart restore, Yomitan late

- Use /usr/bin/curl on Linux for update checks to avoid Electron net-service crashes
- Restore visible overlay on manual restart even when auto-start visibility is disabled
- Reload overlay windows after Yomitan extension loads to fix popup race on startup
This commit is contained in:
2026-05-17 22:12:38 -07:00
parent 2b13c82d69
commit 48447c2f1a
14 changed files with 344 additions and 10 deletions
+4
View File
@@ -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.
+1 -1
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed Yomitan popups not opening when playback/overlay startup races the Yomitan extension load.
+1
View File
@@ -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.
+9 -2
View File
@@ -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", {
+1
View File
@@ -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,
+34
View File
@@ -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 = "",
+17 -1
View File
@@ -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({
+48 -1
View File
@@ -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');
});
+80
View File
@@ -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<string, unknown>) => Promise<FetchResponseLike>;
@@ -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<string, unknown>)) {
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<Buffer>((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),
};
};
}
@@ -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']);
});
@@ -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;
}
@@ -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<Extension | null> | 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<Extension | null>((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]);
});
+25 -5
View File
@@ -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<void>;
};
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<void> {
if (!extension || extension === lastNotifiedExtension) {
return;
}
lastNotifiedExtension = extension;
await deps.onYomitanExtensionLoaded?.(extension);
}
return {
loadYomitanExtension: (): Promise<ReturnType<typeof deps.getYomitanExtension>> =>
loadYomitanExtensionHandler(),
ensureYomitanExtensionLoaded: (): Promise<ReturnType<typeof deps.getYomitanExtension>> =>
ensureYomitanExtensionLoadedHandler(),
loadYomitanExtension: async (): Promise<ReturnType<typeof deps.getYomitanExtension>> => {
const extension = await loadYomitanExtensionHandler();
await notifyYomitanExtensionLoaded(extension);
return extension;
},
ensureYomitanExtensionLoaded: async (): Promise<
ReturnType<typeof deps.getYomitanExtension>
> => {
const extension = await ensureYomitanExtensionLoadedHandler();
await notifyYomitanExtensionLoaded(extension);
return extension;
},
};
}