Files
SubMiner/src/main/runtime/first-run-setup-window.ts
T
sudacode d9fdc7ef6d feat(dictionary): add Hachidori backend support
- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
2026-09-22 00:21:19 -07:00

731 lines
26 KiB
TypeScript

import type { HachidoriHostStatus } from '../../shared/hachidori-sharing';
import type { DictionaryBackend } from '../../types/config';
import { getFirstRunSetupCompletionMessage } from './first-run-setup-service';
import type { CommandLineLauncherSnapshot, LauncherSnapshot } from './command-line-launcher';
type FocusableWindowLike = {
focus: () => void;
show?: () => void;
};
type FirstRunSetupWebContentsLike = {
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
};
type FirstRunSetupWindowLike = FocusableWindowLike & {
webContents: FirstRunSetupWebContentsLike;
loadURL: (url: string) => unknown;
on: (event: 'closed', handler: () => void) => void;
isDestroyed: () => boolean;
close: () => void;
};
export type FirstRunSetupSubmission =
| { action: 'configure-mpv-executable-path'; mpvExecutablePath: string }
| {
action: 'configure-windows-mpv-shortcuts';
startMenuEnabled: boolean;
desktopEnabled: boolean;
}
| { action: 'link-hachidori-host'; address: string }
| {
action:
| 'unlink-hachidori-host'
| 'remove-legacy-plugin'
| 'install-bun'
| 'install-command-line-launcher'
| 'open-yomitan-settings'
| 'open-config-settings'
| 'refresh'
| 'finish';
};
export type FirstRunSetupAction = FirstRunSetupSubmission['action'];
export interface FirstRunSetupHtmlModel {
dictionaryBackend?: DictionaryBackend;
hachidoriHost?: HachidoriHostStatus;
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
externalYomitanConfigured: boolean;
pluginStatus: 'installed' | 'required' | 'failed';
pluginInstallPathSummary: string | null;
legacyMpvPluginPaths?: string[];
mpvExecutablePath: string;
mpvExecutablePathStatus: 'blank' | 'configured' | 'invalid';
windowsMpvShortcuts: {
supported: boolean;
startMenuEnabled: boolean;
desktopEnabled: boolean;
startMenuInstalled: boolean;
desktopInstalled: boolean;
status: 'installed' | 'optional' | 'skipped' | 'failed';
};
commandLineLauncher: CommandLineLauncherSnapshot;
message: string | null;
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
function renderStatusBadge(value: string, tone: 'ready' | 'warn' | 'muted' | 'danger'): string {
return `<span class="badge ${tone}">${escapeHtml(value)}</span>`;
}
function getLauncherStatusLabel(status: LauncherSnapshot['status']): string {
switch (status) {
case 'ready':
return 'Ready';
case 'installed_bun_missing':
return 'Unavailable';
case 'not_installed':
return 'Not installed';
case 'not_on_path':
return 'Not on PATH';
case 'shadowed':
return 'Shadowed';
case 'not_installable':
return 'Not installable';
case 'failed':
return 'Failed';
}
}
function getLauncherTone(
status: LauncherSnapshot['status'],
): 'ready' | 'warn' | 'muted' | 'danger' {
if (status === 'ready') return 'ready';
if (status === 'failed') return 'danger';
if (status === 'installed_bun_missing' || status === 'not_installed') return 'warn';
return 'muted';
}
function renderCommandLineLauncherSection(
commandLineLauncher: CommandLineLauncherSnapshot,
): string {
if (!commandLineLauncher.supported) {
return '';
}
const bun = commandLineLauncher.bun;
const launcher = commandLineLauncher.launcher;
const runtimeUnavailable = bun.status !== 'ready';
const launcherStatus = runtimeUnavailable ? 'failed' : launcher.status;
const runtimeError = bun.installCommand
? "The launcher runtime is unavailable. Install the project's Bun dependency, then refresh."
: 'The launcher runtime is unavailable. Reinstall SubMiner to repair it.';
const launcherMeta = [
launcher.commandPath ? `Command: ${launcher.commandPath}` : null,
launcher.installPath ? `Install target: ${launcher.installPath}` : null,
launcher.pathDir ? `PATH dir: ${launcher.pathDir}` : null,
launcher.shadowedBy ? `Shadowed by: ${launcher.shadowedBy}` : null,
runtimeUnavailable ? runtimeError : launcher.message,
].filter(Boolean);
const launcherButtonDisabled =
launcher.status === 'not_installable' || bun.status !== 'ready' ? 'disabled' : '';
return `
<section class="setup-section">
<div class="section-head">
<h2>Command line launcher</h2>
<div class="meta">Optional. Install the launcher to use SubMiner from your terminal.</div>
</div>
<div class="card block">
<div class="card-head">
<div>
<strong>SubMiner launcher</strong>
${launcherMeta.map((line) => `<div class="meta">${escapeHtml(String(line))}</div>`).join('')}
</div>
${renderStatusBadge(getLauncherStatusLabel(launcherStatus), getLauncherTone(launcherStatus))}
</div>
<div class="inline-actions">
<button ${launcherButtonDisabled} onclick="window.location.href='subminer://first-run-setup?action=install-command-line-launcher'">Install command-line launcher</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh</button>
</div>
</div>
</section>`;
}
export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
const legacyMpvPluginPaths = model.legacyMpvPluginPaths ?? [];
const finishButtonLabel =
legacyMpvPluginPaths.length > 0 && model.canFinish
? 'Continue without removing'
: 'Finish setup';
const windowsShortcutLabel =
model.windowsMpvShortcuts.status === 'installed'
? 'Installed'
: model.windowsMpvShortcuts.status === 'skipped'
? 'Skipped'
: model.windowsMpvShortcuts.status === 'failed'
? 'Failed'
: 'Optional';
const windowsShortcutTone =
model.windowsMpvShortcuts.status === 'installed'
? 'ready'
: model.windowsMpvShortcuts.status === 'failed'
? 'danger'
: model.windowsMpvShortcuts.status === 'skipped'
? 'muted'
: 'warn';
const mpvExecutablePathLabel =
model.mpvExecutablePathStatus === 'configured'
? 'Configured'
: model.mpvExecutablePathStatus === 'invalid'
? 'Invalid'
: 'Blank';
const mpvExecutablePathTone =
model.mpvExecutablePathStatus === 'configured'
? 'ready'
: model.mpvExecutablePathStatus === 'invalid'
? 'danger'
: 'muted';
const mpvExecutablePathCurrent =
model.mpvExecutablePathStatus === 'blank'
? 'blank (PATH discovery)'
: model.mpvExecutablePathStatus === 'invalid'
? `${model.mpvExecutablePath} (invalid; file not found)`
: model.mpvExecutablePath;
const mpvExecutablePathCard = model.windowsMpvShortcuts.supported
? `
<div class="card block">
<div class="card-head">
<div>
<strong>mpv executable path</strong>
<div class="meta">Leave blank to auto-discover mpv.exe from PATH.</div>
<div class="meta">Current: ${escapeHtml(mpvExecutablePathCurrent)}</div>
</div>
${renderStatusBadge(mpvExecutablePathLabel, mpvExecutablePathTone)}
</div>
<form
class="path-form"
onsubmit="event.preventDefault(); const params = new URLSearchParams({ action: 'configure-mpv-executable-path', mpvExecutablePath: document.getElementById('mpv-executable-path').value }); window.location.href = 'subminer://first-run-setup?' + params.toString();"
>
<input
id="mpv-executable-path"
type="text"
aria-label="Path to mpv.exe"
value="${escapeHtml(model.mpvExecutablePath)}"
placeholder="C:\\Program Files\\mpv\\mpv.exe"
/>
<button type="submit">Save mpv executable path</button>
</form>
</div>`
: '';
const windowsShortcutCard = model.windowsMpvShortcuts.supported
? `
<div class="card block">
<div class="card-head">
<div>
<strong>Windows mpv launcher</strong>
<div class="meta">Create standalone \`SubMiner mpv\` shortcuts that run \`SubMiner.exe --launch-mpv\`.</div>
<div class="meta">Installed: Start Menu ${model.windowsMpvShortcuts.startMenuInstalled ? 'yes' : 'no'}, Desktop ${model.windowsMpvShortcuts.desktopInstalled ? 'yes' : 'no'}</div>
</div>
${renderStatusBadge(windowsShortcutLabel, windowsShortcutTone)}
</div>
<form
class="shortcut-form"
onsubmit="event.preventDefault(); const params = new URLSearchParams({ action: 'configure-windows-mpv-shortcuts', startMenu: document.getElementById('shortcut-start-menu').checked ? '1' : '0', desktop: document.getElementById('shortcut-desktop').checked ? '1' : '0' }); window.location.href = 'subminer://first-run-setup?' + params.toString();"
>
<label><input id="shortcut-start-menu" type="checkbox" ${model.windowsMpvShortcuts.startMenuEnabled ? 'checked' : ''} /> Create Start Menu shortcut</label>
<label><input id="shortcut-desktop" type="checkbox" ${model.windowsMpvShortcuts.desktopEnabled ? 'checked' : ''} /> Create Desktop shortcut</label>
<button type="submit">Apply mpv launcher shortcuts</button>
</form>
</div>`
: '';
const legacyPluginCard =
legacyMpvPluginPaths.length > 0
? `
<div class="card block">
<div class="card-head">
<div>
<strong>Legacy mpv plugin</strong>
<div class="meta">Regular mpv still loads SubMiner from these mpv scripts paths.</div>
</div>
${renderStatusBadge('Found', 'warn')}
</div>
<ul class="legacy-paths">
${legacyMpvPluginPaths.map((pluginPath) => `<li>${escapeHtml(pluginPath)}</li>`).join('')}
</ul>
<button class="legacy-remove" onclick="if (confirm(&quot;Remove these SubMiner mpv plugin files from mpv's scripts directory? This stops regular mpv from loading SubMiner. SubMiner-managed playback will keep working with the bundled runtime plugin.&quot;)) window.location.href='subminer://first-run-setup?action=remove-legacy-plugin'">Remove legacy mpv plugin</button>
</div>`
: '';
const dictionaryName = model.dictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan';
const host = model.hachidoriHost;
const linked = host?.kind === 'connected' || host?.kind === 'disconnected';
const hostAddress = linked ? host.address : '';
const hostCard =
model.dictionaryBackend === 'hachidori'
? `
<div class="card block">
<strong>Dictionary source</strong>
<p class="meta">Import dictionaries through Hachidori Settings to keep them in SubMiner, or connect to an existing library below.</p>
<details class="external-host"${linked ? ' open' : ''}>
<summary>Use an external dictionary host</summary>
<p class="meta">Connect to Hachidori in another app, browser, or Docker container to use the dictionaries already installed there. You won't need to import a second copy into SubMiner.</p>
<p class="meta">Dictionaries and their settings are shared. You still mine cards in SubMiner, using its own Anki settings, audio, and screenshots.</p>
<ul class="meta host-requirements">
<li>Browser: keep the browser and its sharing relay running. If the relay runs through Anki, keep Anki open too.</li>
<li>Desktop app: keep the app sharing your dictionaries and any required relay running.</li>
<li>Docker: keep the dictionary container running. You can close its browser management page.</li>
</ul>
<p class="meta">If that app or container stops or loses its connection, dictionary lookups will be unavailable until it reconnects.</p>
<form class="path-form" onsubmit="event.preventDefault(); const address = document.getElementById('hachidori-host-address').value; window.location.href='subminer://first-run-setup?action=link-hachidori-host&amp;address='+encodeURIComponent(address)">
<label for="hachidori-host-address">Host address</label>
<input id="hachidori-host-address" type="text" value="${escapeHtml(hostAddress)}" placeholder="127.0.0.1:8771 or ws://host:8771/link" required />
<div class="meta">Use the address from Hachidori's Sharing settings or your container's WebSocket sharing address, rather than its management page URL.</div>
<div class="inline-actions">
<button type="submit">${linked ? 'Change host' : 'Link host'}</button>
${linked ? `<button type="button" class="ghost" onclick="window.location.href='subminer://first-run-setup?action=unlink-hachidori-host'">Unlink and use local dictionaries</button>` : ''}
</div>
</form>
${host?.kind === 'disconnected' || host?.kind === 'unavailable' ? `<p class="meta">${escapeHtml(host.message)}</p>` : ''}
</details>
</div>`
: '';
const yomitanMeta =
host?.kind === 'connected'
? `${host.dictionaryCount} ${host.dictionaryCount === 1 ? 'dictionary' : 'dictionaries'} from ${host.name} at ${host.address}`
: host?.kind === 'disconnected'
? `Host unavailable: ${host.address}`
: model.externalYomitanConfigured
? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.'
: `${model.dictionaryCount} installed`;
const yomitanBadgeLabel = model.externalYomitanConfigured
? 'External'
: model.dictionaryCount >= 1
? 'Ready'
: 'Missing';
const yomitanBadgeTone = model.externalYomitanConfigured
? 'ready'
: model.dictionaryCount >= 1
? 'ready'
: 'warn';
const blockerMessage = getFirstRunSetupCompletionMessage(model);
const footerMessage = blockerMessage
? blockerMessage
: model.canFinish
? model.externalYomitanConfigured
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
: `Finish stays unlocked once ${dictionaryName} reports at least one installed dictionary.`
: `Finish stays locked until ${dictionaryName} reports at least one installed dictionary.`;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>SubMiner First-Run Setup</title>
<style>
:root {
color-scheme: dark;
--base: #24273a;
--mantle: #1e2030;
--surface: #363a4f;
--surface-strong: #494d64;
--text: #cad3f5;
--muted: #b8c0e0;
--blue: #8aadf4;
--green: #a6da95;
--yellow: #eed49f;
--red: #ed8796;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
min-height: 100vh;
background: linear-gradient(180deg, var(--mantle), var(--base));
color: var(--text);
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
box-sizing: border-box;
min-height: 100vh;
padding: 18px;
}
h1 {
margin: 0 0 6px;
font-size: 18px;
}
p {
margin: 0 0 14px;
color: var(--muted);
}
.card {
background: rgba(54, 58, 79, 0.92);
border: 1px solid rgba(202, 211, 245, 0.08);
border-radius: 12px;
padding: 12px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.card.block {
display: block;
}
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.meta {
color: var(--muted);
font-size: 12px;
}
.external-host summary {
cursor: pointer;
color: var(--blue);
font-weight: 700;
}
.external-host[open] summary {
margin-bottom: 10px;
}
.host-requirements {
padding-left: 20px;
margin: 0 0 12px;
}
.host-requirements li + li {
margin-top: 4px;
}
.shortcut-form {
display: grid;
gap: 8px;
margin-top: 12px;
}
.setup-section {
margin-top: 10px;
}
.section-head {
margin: 14px 0 8px;
}
.section-head h2 {
margin: 0;
font-size: 14px;
}
label {
color: var(--muted);
display: flex;
align-items: center;
gap: 8px;
}
.badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 9px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
}
.badge.ready { background: rgba(166, 218, 149, 0.16); color: var(--green); }
.badge.warn { background: rgba(238, 212, 159, 0.18); color: var(--yellow); }
.badge.muted { background: rgba(184, 192, 224, 0.12); color: var(--muted); }
.badge.danger { background: rgba(237, 135, 150, 0.16); color: var(--red); }
.path-form {
display: grid;
gap: 8px;
margin-top: 12px;
}
.path-form input[type='text'] {
width: 100%;
box-sizing: border-box;
border: 1px solid rgba(202, 211, 245, 0.12);
border-radius: 10px;
padding: 9px 10px;
color: var(--text);
background: rgba(30, 32, 48, 0.72);
font: inherit;
}
.path-form input[type='text']::placeholder {
color: rgba(184, 192, 224, 0.65);
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 14px;
}
.inline-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
button {
border: 0;
border-radius: 10px;
padding: 10px 12px;
cursor: pointer;
font-weight: 700;
color: var(--text);
background: var(--surface);
}
button.primary {
background: var(--blue);
color: #1e2030;
}
button.ghost {
background: transparent;
border: 1px solid rgba(202, 211, 245, 0.12);
}
button.legacy-remove {
display: inline-flex;
justify-content: center;
align-items: center;
min-width: 220px;
border: 1px solid rgba(237, 135, 150, 0.38);
background: rgba(237, 135, 150, 0.14);
color: #f5b1ba;
}
button.legacy-remove:hover {
background: rgba(237, 135, 150, 0.22);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.message {
min-height: 18px;
margin-top: 12px;
color: var(--muted);
}
.footer {
margin-top: 10px;
color: var(--muted);
font-size: 12px;
}
.legacy-paths {
margin: 10px 0 12px;
padding-left: 18px;
color: var(--muted);
font-size: 12px;
overflow-wrap: anywhere;
}
</style>
</head>
<body>
<main>
<h1>SubMiner setup</h1>
<div class="card">
<div>
<strong>Config file</strong>
<div class="meta">Default config directory seeded automatically.</div>
</div>
${renderStatusBadge(model.configReady ? 'Ready' : 'Missing', model.configReady ? 'ready' : 'danger')}
</div>
<div class="card">
<div>
<strong>${dictionaryName} dictionaries</strong>
<div class="meta">${escapeHtml(yomitanMeta)}</div>
</div>
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
</div>
${hostCard}
${mpvExecutablePathCard}
${windowsShortcutCard}
${renderCommandLineLauncherSection(model.commandLineLauncher)}
${legacyPluginCard}
<div class="actions">
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open ${dictionaryName} Settings</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
<button onclick="window.location.href='subminer://first-run-setup?action=open-config-settings'">Open SubMiner Settings</button>
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">${finishButtonLabel}</button>
</div>
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
<div class="footer">${escapeHtml(footerMessage)}</div>
</main>
</body>
</html>`;
}
export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSubmission | null {
if (!rawUrl.startsWith('subminer://first-run-setup')) {
return null;
}
const parsed = new URL(rawUrl);
const action = parsed.searchParams.get('action');
if (
action !== 'link-hachidori-host' &&
action !== 'unlink-hachidori-host' &&
action !== 'configure-mpv-executable-path' &&
action !== 'remove-legacy-plugin' &&
action !== 'configure-windows-mpv-shortcuts' &&
action !== 'install-bun' &&
action !== 'install-command-line-launcher' &&
action !== 'open-yomitan-settings' &&
action !== 'open-config-settings' &&
action !== 'refresh' &&
action !== 'finish'
) {
return null;
}
if (action === 'link-hachidori-host') {
return { action, address: parsed.searchParams.get('address')?.trim() ?? '' };
}
if (action === 'configure-mpv-executable-path') {
return {
action,
mpvExecutablePath: parsed.searchParams.get('mpvExecutablePath') ?? '',
};
}
if (action === 'configure-windows-mpv-shortcuts') {
return {
action,
startMenuEnabled: parsed.searchParams.get('startMenu') === '1',
desktopEnabled: parsed.searchParams.get('desktop') === '1',
};
}
return { action };
}
export function createMaybeFocusExistingFirstRunSetupWindowHandler(deps: {
getSetupWindow: () => FocusableWindowLike | null;
}) {
return (): boolean => {
const window = deps.getSetupWindow();
if (!window) return false;
window.show?.();
window.focus();
return true;
};
}
export function createHandleFirstRunSetupNavigationHandler(deps: {
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
handleAction: (submission: FirstRunSetupSubmission) => Promise<unknown>;
logError: (message: string, error: unknown) => void;
}) {
return (params: { url: string; preventDefault: () => void }): boolean => {
if (!params.url.startsWith('subminer://first-run-setup')) {
params.preventDefault();
return true;
}
params.preventDefault();
let submission: FirstRunSetupSubmission | null;
try {
submission = deps.parseSubmissionUrl(params.url);
} catch {
return true;
}
if (!submission) return true;
void deps.handleAction(submission).catch((error) => {
deps.logError('Failed handling first-run setup action', error);
});
return true;
};
}
export function createOpenFirstRunSetupWindowHandler<
TWindow extends FirstRunSetupWindowLike,
>(deps: {
maybeFocusExistingSetupWindow: () => boolean;
createSetupWindow: () => TWindow;
getSetupSnapshot: () => Promise<FirstRunSetupHtmlModel>;
buildSetupHtml: (model: FirstRunSetupHtmlModel) => string;
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
handleAction: (
submission: FirstRunSetupSubmission,
) => Promise<{ closeWindow?: boolean; skipRender?: boolean } | void>;
markSetupInProgress: () => Promise<unknown>;
markSetupCancelled: () => Promise<unknown>;
isSetupCompleted: () => boolean;
shouldQuitWhenClosedIncomplete: () => boolean;
shouldQuitWhenClosedCompleted?: () => boolean;
quitApp: () => void;
clearSetupWindow: () => void;
setSetupWindow: (window: TWindow) => void;
encodeURIComponent: (value: string) => string;
logError: (message: string, error: unknown) => void;
}) {
return (): void => {
if (deps.maybeFocusExistingSetupWindow()) {
return;
}
const setupWindow = deps.createSetupWindow();
deps.setSetupWindow(setupWindow);
setupWindow.show?.();
setupWindow.focus();
const render = async (): Promise<void> => {
const model = await deps.getSetupSnapshot();
if (setupWindow.isDestroyed()) {
return;
}
const html = deps.buildSetupHtml(model);
if (setupWindow.isDestroyed()) {
return;
}
await setupWindow.loadURL(`data:text/html;charset=utf-8,${deps.encodeURIComponent(html)}`);
if (!setupWindow.isDestroyed()) {
setupWindow.show?.();
setupWindow.focus();
}
};
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
parseSubmissionUrl: deps.parseSubmissionUrl,
handleAction: async (submission) => {
const result = await deps.handleAction(submission);
if (result?.closeWindow) {
if (!setupWindow.isDestroyed()) {
setupWindow.close();
}
return;
}
if (result?.skipRender) {
return;
}
if (!setupWindow.isDestroyed()) {
await render();
}
},
logError: deps.logError,
});
setupWindow.webContents.on('will-navigate', (event, url) => {
handleNavigation({
url,
preventDefault: () => {
if (event && typeof event === 'object' && 'preventDefault' in event) {
(event as { preventDefault?: () => void }).preventDefault?.();
}
},
});
});
setupWindow.on('closed', () => {
const setupCompleted = deps.isSetupCompleted();
if (!setupCompleted) {
void deps.markSetupCancelled().catch((error) => {
deps.logError('Failed marking first-run setup cancelled', error);
});
}
deps.clearSetupWindow();
if (
(setupCompleted && deps.shouldQuitWhenClosedCompleted?.()) ||
(!setupCompleted && deps.shouldQuitWhenClosedIncomplete())
) {
deps.quitApp();
}
});
void deps
.markSetupInProgress()
.then(() => render())
.catch((error) => deps.logError('Failed opening first-run setup window', error));
};
}