mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12: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:
@@ -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
|
type: fixed
|
||||||
area: overlay
|
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.
|
||||||
@@ -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 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.
|
||||||
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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()
|
local function notify_auto_play_ready()
|
||||||
release_auto_play_ready_gate("tokenization-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
|
return
|
||||||
end
|
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", {
|
run_control_command_async("show-visible-overlay", {
|
||||||
socket_path = opts.socket_path,
|
socket_path = opts.socket_path,
|
||||||
})
|
})
|
||||||
@@ -514,6 +519,8 @@ function M.create(ctx)
|
|||||||
|
|
||||||
state.overlay_running = false
|
state.overlay_running = false
|
||||||
state.texthooker_running = false
|
state.texthooker_running = false
|
||||||
|
state.suppress_ready_overlay_restore = false
|
||||||
|
state.force_ready_overlay_restore = true
|
||||||
disarm_auto_play_ready_gate()
|
disarm_auto_play_ready_gate()
|
||||||
|
|
||||||
local start_args = build_command_args("start", {
|
local start_args = build_command_args("start", {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function M.new()
|
|||||||
auto_play_ready_timeout = nil,
|
auto_play_ready_timeout = nil,
|
||||||
auto_play_ready_osd_timer = nil,
|
auto_play_ready_osd_timer = nil,
|
||||||
suppress_ready_overlay_restore = false,
|
suppress_ready_overlay_restore = false,
|
||||||
|
force_ready_overlay_restore = false,
|
||||||
current_media_identity = nil,
|
current_media_identity = nil,
|
||||||
pending_reload_media_identity = nil,
|
pending_reload_media_identity = nil,
|
||||||
session_binding_generation = 0,
|
session_binding_generation = 0,
|
||||||
|
|||||||
@@ -600,6 +600,40 @@ do
|
|||||||
)
|
)
|
||||||
end
|
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
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
process_list = "",
|
process_list = "",
|
||||||
|
|||||||
+17
-1
@@ -500,7 +500,11 @@ import {
|
|||||||
createElectronAppUpdater,
|
createElectronAppUpdater,
|
||||||
isNativeUpdaterSupported,
|
isNativeUpdaterSupported,
|
||||||
} from './main/runtime/update/app-updater';
|
} 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 { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor';
|
||||||
import { createFetchHttpExecutor } from './main/runtime/update/fetch-http-executor';
|
import { createFetchHttpExecutor } from './main/runtime/update/fetch-http-executor';
|
||||||
import {
|
import {
|
||||||
@@ -537,6 +541,7 @@ import {
|
|||||||
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
|
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
|
||||||
import { isYoutubePlaybackActive } from './main/runtime/youtube-playback';
|
import { isYoutubePlaybackActive } from './main/runtime/youtube-playback';
|
||||||
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
|
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 { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
|
||||||
import {
|
import {
|
||||||
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
|
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
|
||||||
@@ -4739,6 +4744,7 @@ const electronNetFetch = createElectronNetFetch({
|
|||||||
fetch: (url, init) => net.fetch(url, init as RequestInit),
|
fetch: (url, init) => net.fetch(url, init as RequestInit),
|
||||||
});
|
});
|
||||||
const globalFetchForUpdater = createGlobalFetch();
|
const globalFetchForUpdater = createGlobalFetch();
|
||||||
|
const curlFetch = createCurlFetch();
|
||||||
|
|
||||||
function createNativeUpdaterHttpExecutor() {
|
function createNativeUpdaterHttpExecutor() {
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
@@ -4754,6 +4760,7 @@ function getFetchForUpdater() {
|
|||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
return globalFetchForUpdater;
|
return globalFetchForUpdater;
|
||||||
}
|
}
|
||||||
|
if (process.platform === 'linux') return curlFetch;
|
||||||
return electronNetFetch;
|
return electronNetFetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5820,6 +5827,15 @@ const yomitanExtensionRuntime = createYomitanExtensionRuntime({
|
|||||||
setLoadInFlight: (promise) => {
|
setLoadInFlight: (promise) => {
|
||||||
yomitanLoadInFlight = 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 } =
|
const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||||
runtimeRegistry.overlay.createOverlayRuntimeBootstrapHandlers({
|
runtimeRegistry.overlay.createOverlayRuntimeBootstrapHandlers({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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';
|
import type { FetchResponseLike } from './release-assets';
|
||||||
|
|
||||||
test('createElectronNetFetch delegates updater requests to Electron net.fetch', async () => {
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { execFile as defaultExecFile } from 'node:child_process';
|
||||||
import type { FetchLike, FetchResponseLike } from './release-assets';
|
import type { FetchLike, FetchResponseLike } from './release-assets';
|
||||||
|
import type { CurlExecFile } from './curl-http-executor';
|
||||||
|
|
||||||
export interface ElectronNetFetchLike {
|
export interface ElectronNetFetchLike {
|
||||||
fetch: (url: string, init?: Record<string, unknown>) => Promise<FetchResponseLike>;
|
fetch: (url: string, init?: Record<string, unknown>) => Promise<FetchResponseLike>;
|
||||||
@@ -20,3 +22,81 @@ function getGlobalFetch(): GlobalFetchLike {
|
|||||||
export function createGlobalFetch(fetchImpl?: GlobalFetchLike): FetchLike {
|
export function createGlobalFetch(fetchImpl?: GlobalFetchLike): FetchLike {
|
||||||
return (url, init) => (fetchImpl ?? getGlobalFetch())(url, init as RequestInit);
|
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.equal(receivedExternalProfilePath, '/tmp/gsm-profile');
|
||||||
assert.deepEqual(yomitanSession, { id: 'session' });
|
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,
|
createEnsureYomitanExtensionLoadedHandler,
|
||||||
createLoadYomitanExtensionHandler,
|
createLoadYomitanExtensionHandler,
|
||||||
} from './yomitan-extension-loader';
|
} from './yomitan-extension-loader';
|
||||||
|
import type { Extension } from 'electron';
|
||||||
import {
|
import {
|
||||||
createBuildEnsureYomitanExtensionLoadedMainDepsHandler,
|
createBuildEnsureYomitanExtensionLoadedMainDepsHandler,
|
||||||
createBuildLoadYomitanExtensionMainDepsHandler,
|
createBuildLoadYomitanExtensionMainDepsHandler,
|
||||||
@@ -17,7 +18,9 @@ type EnsureYomitanExtensionLoadedMainDeps = Omit<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
export type YomitanExtensionRuntimeDeps = LoadYomitanExtensionMainDeps &
|
export type YomitanExtensionRuntimeDeps = LoadYomitanExtensionMainDeps &
|
||||||
EnsureYomitanExtensionLoadedMainDeps;
|
EnsureYomitanExtensionLoadedMainDeps & {
|
||||||
|
onYomitanExtensionLoaded?: (extension: Extension) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
export function createYomitanExtensionRuntime(deps: YomitanExtensionRuntimeDeps) {
|
export function createYomitanExtensionRuntime(deps: YomitanExtensionRuntimeDeps) {
|
||||||
const buildLoadYomitanExtensionMainDepsHandler = createBuildLoadYomitanExtensionMainDepsHandler({
|
const buildLoadYomitanExtensionMainDepsHandler = createBuildLoadYomitanExtensionMainDepsHandler({
|
||||||
@@ -46,10 +49,27 @@ export function createYomitanExtensionRuntime(deps: YomitanExtensionRuntimeDeps)
|
|||||||
buildEnsureYomitanExtensionLoadedMainDepsHandler(),
|
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 {
|
return {
|
||||||
loadYomitanExtension: (): Promise<ReturnType<typeof deps.getYomitanExtension>> =>
|
loadYomitanExtension: async (): Promise<ReturnType<typeof deps.getYomitanExtension>> => {
|
||||||
loadYomitanExtensionHandler(),
|
const extension = await loadYomitanExtensionHandler();
|
||||||
ensureYomitanExtensionLoaded: (): Promise<ReturnType<typeof deps.getYomitanExtension>> =>
|
await notifyYomitanExtensionLoaded(extension);
|
||||||
ensureYomitanExtensionLoadedHandler(),
|
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