mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
feat(config): add configuration window (#70)
This commit is contained in:
@@ -258,7 +258,7 @@ test('mac native updater supports Developer ID signed packaged app bundles', asy
|
||||
assert.deepEqual(logged, []);
|
||||
});
|
||||
|
||||
test('linux native updater is unsupported even for writable direct AppImage installs', async () => {
|
||||
test('linux native updater is supported for direct AppImage installs', async () => {
|
||||
const logged: string[] = [];
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'linux',
|
||||
@@ -270,10 +270,8 @@ test('linux native updater is unsupported even for writable direct AppImage inst
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
]);
|
||||
assert.equal(supported, true);
|
||||
assert.deepEqual(logged, []);
|
||||
});
|
||||
|
||||
test('linux native updater is unsupported when APPIMAGE is missing', async () => {
|
||||
@@ -288,25 +286,7 @@ test('linux native updater is unsupported when APPIMAGE is missing', async () =>
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('linux native updater is unsupported for non-writable AppImage installs', async () => {
|
||||
const logged: string[] = [];
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
execPath: '/tmp/.mount_SubMiner/SubMiner',
|
||||
env: {
|
||||
APPIMAGE: '/home/tester/.local/bin/SubMiner.AppImage',
|
||||
},
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
'Skipping native Linux updater because APPIMAGE is not set (not launched from an AppImage).',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -324,7 +304,7 @@ test('linux native updater is unsupported for package-managed AppImage installs'
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
'Skipping native Linux updater because the AppImage is managed by a system package.',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -108,15 +108,25 @@ export async function isNativeUpdaterSupported(options: {
|
||||
options.log?.('Skipping native updater because this build is not packaged.');
|
||||
return false;
|
||||
}
|
||||
if (options.platform === 'linux') {
|
||||
options.log?.(
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (options.platform === 'win32') {
|
||||
return true;
|
||||
}
|
||||
if (options.platform === 'linux') {
|
||||
const appImagePath = options.env?.APPIMAGE;
|
||||
if (!appImagePath) {
|
||||
options.log?.(
|
||||
'Skipping native Linux updater because APPIMAGE is not set (not launched from an AppImage).',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (isKnownLinuxPackageManagedAppImage(appImagePath)) {
|
||||
options.log?.(
|
||||
'Skipping native Linux updater because the AppImage is managed by a system package.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (options.platform !== 'darwin') {
|
||||
options.log?.('Skipping native updater because this platform uses GitHub metadata checks.');
|
||||
return false;
|
||||
|
||||
@@ -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,53 @@ 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',
|
||||
'--max-time',
|
||||
'60',
|
||||
'--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');
|
||||
assert.equal(calls[0]?.options.timeout, 65_000);
|
||||
});
|
||||
|
||||
@@ -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,91 @@ 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',
|
||||
'--max-time',
|
||||
'60',
|
||||
];
|
||||
addHeaderArgs(args, init.headers);
|
||||
args.push(url);
|
||||
const body = await new Promise<Buffer>((resolve, reject) => {
|
||||
execFile(
|
||||
curlPath,
|
||||
args,
|
||||
{
|
||||
encoding: 'buffer',
|
||||
maxBuffer: 600 * 1024 * 1024,
|
||||
timeout: 65_000,
|
||||
},
|
||||
(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),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type ShowMessageBox,
|
||||
} from './update-dialogs';
|
||||
|
||||
test('update dialog presenter focuses app before showing macOS dialogs', async () => {
|
||||
test('update dialog presenter focuses app and yields the run loop before showing macOS dialogs', async () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
calls.push(`dialog:${options.message}`);
|
||||
@@ -14,7 +14,80 @@ test('update dialog presenter focuses app before showing macOS dialogs', async (
|
||||
};
|
||||
const presenter = createUpdateDialogPresenter({
|
||||
platform: 'darwin',
|
||||
focusApp: () => calls.push('focus'),
|
||||
focusApp: () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
yieldToRunLoop: async () => {
|
||||
calls.push('yield');
|
||||
},
|
||||
showMessageBox,
|
||||
});
|
||||
|
||||
await presenter.showNoUpdateDialog('0.14.0');
|
||||
|
||||
assert.deepEqual(calls, ['focus', 'yield', 'dialog:SubMiner is up to date (v0.14.0)']);
|
||||
});
|
||||
|
||||
test('update dialog presenter awaits async focusApp before yielding and showing the dialog', async () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
calls.push(`dialog:${options.message}`);
|
||||
return { response: 0 };
|
||||
};
|
||||
const presenter = createUpdateDialogPresenter({
|
||||
platform: 'darwin',
|
||||
focusApp: async () => {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
calls.push('focus');
|
||||
},
|
||||
yieldToRunLoop: async () => {
|
||||
calls.push('yield');
|
||||
},
|
||||
showMessageBox,
|
||||
});
|
||||
|
||||
await presenter.showNoUpdateDialog('0.14.0');
|
||||
|
||||
assert.deepEqual(calls, ['focus', 'yield', 'dialog:SubMiner is up to date (v0.14.0)']);
|
||||
});
|
||||
|
||||
test('update dialog presenter does not focus app or yield before showing non-macOS dialogs', async () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
calls.push(`dialog:${options.message}`);
|
||||
return { response: 0 };
|
||||
};
|
||||
const presenter = createUpdateDialogPresenter({
|
||||
platform: 'linux',
|
||||
focusApp: () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
yieldToRunLoop: async () => {
|
||||
calls.push('yield');
|
||||
},
|
||||
showMessageBox,
|
||||
});
|
||||
|
||||
await presenter.showNoUpdateDialog('0.14.0');
|
||||
|
||||
assert.deepEqual(calls, ['dialog:SubMiner is up to date (v0.14.0)']);
|
||||
});
|
||||
|
||||
test('update dialog presenter still shows macOS dialog when focus fails', async () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
calls.push(`dialog:${options.message}`);
|
||||
return { response: 0 };
|
||||
};
|
||||
const presenter = createUpdateDialogPresenter({
|
||||
platform: 'darwin',
|
||||
focusApp: () => {
|
||||
calls.push('focus');
|
||||
throw new Error('focus failed');
|
||||
},
|
||||
yieldToRunLoop: async () => {
|
||||
calls.push('yield');
|
||||
},
|
||||
showMessageBox,
|
||||
});
|
||||
|
||||
@@ -23,21 +96,27 @@ test('update dialog presenter focuses app before showing macOS dialogs', async (
|
||||
assert.deepEqual(calls, ['focus', 'dialog:SubMiner is up to date (v0.14.0)']);
|
||||
});
|
||||
|
||||
test('update dialog presenter does not focus app before showing non-macOS dialogs', async () => {
|
||||
test('update dialog presenter still shows macOS dialog when yielding fails', async () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
calls.push(`dialog:${options.message}`);
|
||||
return { response: 0 };
|
||||
};
|
||||
const presenter = createUpdateDialogPresenter({
|
||||
platform: 'linux',
|
||||
focusApp: () => calls.push('focus'),
|
||||
platform: 'darwin',
|
||||
focusApp: () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
yieldToRunLoop: async () => {
|
||||
calls.push('yield');
|
||||
throw new Error('yield failed');
|
||||
},
|
||||
showMessageBox,
|
||||
});
|
||||
|
||||
await presenter.showNoUpdateDialog('0.14.0');
|
||||
|
||||
assert.deepEqual(calls, ['dialog:SubMiner is up to date (v0.14.0)']);
|
||||
assert.deepEqual(calls, ['focus', 'yield', 'dialog:SubMiner is up to date (v0.14.0)']);
|
||||
});
|
||||
|
||||
test('manual update required dialog explains that automatic install is unavailable', async () => {
|
||||
|
||||
@@ -17,7 +17,8 @@ export type ShowMessageBox = (options: {
|
||||
|
||||
export interface UpdateDialogPresenterDeps {
|
||||
showMessageBox: ShowMessageBox;
|
||||
focusApp?: () => void;
|
||||
focusApp?: () => void | Promise<void>;
|
||||
yieldToRunLoop?: () => Promise<void>;
|
||||
platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
@@ -33,14 +34,23 @@ export async function showNoUpdateDialog(
|
||||
});
|
||||
}
|
||||
|
||||
function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): void {
|
||||
async function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): Promise<void> {
|
||||
if ((deps.platform ?? process.platform) !== 'darwin') return;
|
||||
deps.focusApp?.();
|
||||
await deps.focusApp?.();
|
||||
// Yield to the macOS run loop so the activation request is processed before the
|
||||
// modal alert blocks JS execution; without this, the alert often appears behind
|
||||
// other apps when SubMiner is not the active app at dialog-show time.
|
||||
const yieldToRunLoop = deps.yieldToRunLoop ?? (() => new Promise((r) => setTimeout(r, 0)));
|
||||
await yieldToRunLoop();
|
||||
}
|
||||
|
||||
export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
|
||||
const showFocusedMessageBox: ShowMessageBox = async (options) => {
|
||||
maybeFocusAppForDialog(deps);
|
||||
try {
|
||||
await maybeFocusAppForDialog(deps);
|
||||
} catch {
|
||||
// Best-effort focus only; never block the dialog itself.
|
||||
}
|
||||
return deps.showMessageBox(options);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user