mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-25 12:55:18 -07:00
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:
@@ -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,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.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed Yomitan popups not opening when playback/overlay startup races the Yomitan extension load.
|
||||
+1
-1
@@ -89,7 +89,7 @@ Notes:
|
||||
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
||||
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
|
||||
- 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.
|
||||
- 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. 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.
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
+13
-1
@@ -499,7 +499,7 @@ import {
|
||||
createElectronAppUpdater,
|
||||
isNativeUpdaterSupported,
|
||||
} from './main/runtime/update/app-updater';
|
||||
import { createElectronNetFetch } from './main/runtime/update/fetch-adapter';
|
||||
import { createCurlFetch, createElectronNetFetch } from './main/runtime/update/fetch-adapter';
|
||||
import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor';
|
||||
import {
|
||||
fetchLatestStableRelease,
|
||||
@@ -535,6 +535,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,
|
||||
@@ -4698,8 +4699,10 @@ let updateService: ReturnType<typeof createUpdateService> | null = null;
|
||||
const electronNetFetch = createElectronNetFetch({
|
||||
fetch: (url, init) => net.fetch(url, init as RequestInit),
|
||||
});
|
||||
const curlFetch = createCurlFetch();
|
||||
|
||||
function getFetchForUpdater() {
|
||||
if (process.platform === 'linux') return curlFetch;
|
||||
return electronNetFetch;
|
||||
}
|
||||
|
||||
@@ -5764,6 +5767,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({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElectronNetFetch } from './fetch-adapter';
|
||||
import { createCurlFetch, createElectronNetFetch } from './fetch-adapter';
|
||||
import type { FetchResponseLike } from './release-assets';
|
||||
|
||||
test('createElectronNetFetch delegates updater requests to Electron net.fetch', async () => {
|
||||
@@ -33,3 +33,50 @@ test('createElectronNetFetch delegates updater requests to Electron net.fetch',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
@@ -7,3 +9,81 @@ export interface ElectronNetFetchLike {
|
||||
export function createElectronNetFetch(net: ElectronNetFetchLike): FetchLike {
|
||||
return (url, init) => net.fetch(url, init);
|
||||
}
|
||||
|
||||
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]);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user