mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 00:55:16 -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:
+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