Files
opensquilla--opensquilla/desktop/electron/src/boot.html
T
2026-07-13 13:12:33 +08:00

934 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Starting OpenSquilla</title>
<style>
:root {
color-scheme: light dark;
--bg: #f8f7f3;
--surface: #fffefa;
--ink: #1d1f1d;
--muted: #686e66;
--dim: #8b9088;
--line: rgba(34, 31, 27, 0.12);
--accent: #F26A1B;
--danger: #a23a22;
}
/* The app theme defaults to "system", so match the OS here — a dark-OS user
gets a dark splash that hands off to the dark app with no luminance flash.
These mirror the app's canonical "Instrument" dark tokens. */
@media (prefers-color-scheme: dark) {
:root {
--bg: #0E0F11;
--surface: #161719;
--ink: #e8e8ea;
--muted: #9a9aa0;
--dim: #6b6b72;
--line: rgba(255, 255, 255, 0.12);
--accent: #F26A1B;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
overflow: hidden;
background: var(--bg);
color: var(--ink);
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 40px;
}
.boot {
width: min(520px, calc(100vw - 48px));
display: grid;
gap: 34px;
padding: 4px;
}
.brand {
display: grid;
gap: 12px;
}
.mark-row {
align-items: center;
display: flex;
gap: 11px;
}
.mark {
width: 22px;
height: 22px;
border: 2px solid var(--ink);
position: relative;
}
.mark::after {
content: "";
position: absolute;
bottom: -2px;
right: -7px;
width: 9px;
height: 2px;
background: var(--accent);
}
h1 {
margin: 0;
font-size: 28px;
font-weight: 760;
letter-spacing: 0;
line-height: 1;
}
.subtitle {
color: var(--muted);
font-size: 14px;
line-height: 1.5;
margin: 0;
max-width: 42ch;
}
.status {
display: grid;
gap: 13px;
padding-top: 2px;
}
.status-line {
height: 2px;
overflow: hidden;
background: rgba(29, 31, 29, 0.1);
}
.status-line::before {
animation: progress 1.6s ease-in-out infinite;
background: var(--accent);
content: "";
display: block;
height: 100%;
width: 38%;
}
/* On a startup failure the splash is paused, not working — freeze the
indeterminate sweep so it doesn't contradict the "Startup paused" state. */
body.errored .status-line::before {
animation: none;
opacity: 0.2;
}
.status-copy {
align-items: baseline;
display: flex;
justify-content: space-between;
gap: 18px;
}
.phase {
color: var(--ink);
font-size: 14px;
font-weight: 680;
min-width: 0;
}
.timer {
color: var(--dim);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.steps {
display: grid;
gap: 8px;
grid-template-columns: repeat(3, 1fr);
}
.step {
border-top: 1px solid var(--line);
color: var(--dim);
font-size: 11px;
font-weight: 720;
letter-spacing: 0.06em;
padding-top: 10px;
text-transform: uppercase;
transition: color 160ms ease, border-color 160ms ease;
}
.step.active {
border-color: var(--accent);
color: var(--ink);
}
.step.done {
border-color: rgba(29, 31, 29, 0.32);
color: var(--muted);
}
.error {
display: none;
gap: 13px;
padding: 16px;
border: 1px solid rgba(162, 58, 34, 0.28);
background: #fff8f5;
color: var(--danger);
}
.error.visible { display: grid; }
.recovery {
display: none;
gap: 18px;
padding: 20px;
border: 1px solid rgba(242, 106, 27, 0.28);
background: var(--surface);
}
.recovery.visible { display: grid; }
body.recovering .status,
body.recovering .error { display: none; }
.recovery h2 {
margin: 0;
font-size: 18px;
line-height: 1.2;
}
.recovery p {
color: var(--muted);
font-size: 13px;
line-height: 1.5;
margin: 0;
}
.recovery-code {
color: var(--dim);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
overflow-wrap: anywhere;
}
.recovery-group {
display: grid;
gap: 8px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
.recovery-group[hidden] {
display: none;
}
.recovery-group label,
.recovery-group legend {
color: var(--ink);
font-size: 12px;
font-weight: 720;
}
.recovery-group fieldset {
border: 0;
display: grid;
gap: 8px;
margin: 0;
padding: 0;
}
.recovery-row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.recovery-row select {
min-height: 34px;
min-width: min(300px, 100%);
flex: 1;
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink);
font: inherit;
font-size: 12px;
padding: 0 9px;
}
.check-row {
align-items: flex-start;
color: var(--muted);
display: flex;
font-size: 12px;
font-weight: 500;
gap: 8px;
line-height: 1.4;
}
.check-row input { margin-top: 2px; }
.recovery-status {
min-height: 1.5em;
color: var(--danger);
font-size: 12px;
}
.recovery button:focus-visible,
.recovery select:focus-visible,
.recovery input:focus-visible,
.recovery [tabindex="-1"]:focus-visible {
outline: 3px solid color-mix(in srgb, var(--accent) 35%, transparent);
outline-offset: 2px;
}
.recovery button:disabled { cursor: wait; opacity: 0.55; }
.error strong {
font-size: 13px;
}
.error p {
color: #75584f;
font-size: 13px;
line-height: 1.45;
margin: 0;
white-space: pre-line;
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
button {
min-height: 34px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 720;
padding: 0 12px;
}
button.primary {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
@keyframes progress {
0% { transform: translateX(-110%); opacity: 0.35; }
24% { opacity: 1; }
100% { transform: translateX(270%); opacity: 0.45; }
}
@media (max-width: 560px) {
.shell { padding: 28px; }
.status-copy {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
.steps {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
}
</style>
</head>
<body>
<div class="shell">
<main class="boot" aria-label="OpenSquilla startup" data-i18n-aria="bootAria">
<section class="brand">
<div class="mark-row">
<span class="mark" aria-hidden="true"></span>
<h1>OpenSquilla</h1>
</div>
<p class="subtitle" data-i18n="subtitle">Starting the desktop control surface.</p>
</section>
<section class="status" aria-live="polite">
<div class="status-line" aria-hidden="true"></div>
<div class="status-copy">
<span class="phase" id="phase" data-i18n="phaseDefault">Preparing desktop profile</span>
<span class="timer" id="timer">00.0s</span>
</div>
<div class="steps" aria-hidden="true">
<span class="step active" data-step="profile" data-i18n="stepProfile">Profile</span>
<span class="step" data-step="gateway" data-i18n="stepGateway">Gateway</span>
<span class="step" data-step="control" data-i18n="stepControl">Control</span>
</div>
</section>
<section class="error" id="errorPanel" aria-live="polite">
<strong data-i18n="errorTitle">Startup needs attention</strong>
<p id="errorMessage" data-i18n="errorDefault">The gateway did not finish starting.</p>
<div class="actions">
<button id="revealLog" type="button" data-i18n="revealLog">Reveal log</button>
<button id="resetSetup" type="button" data-i18n="resetSetup">Reset setup</button>
<button id="quit" type="button" data-i18n="quit">Quit</button>
<button id="retry" class="primary" type="button" data-i18n="retry">Retry</button>
</div>
</section>
<section class="recovery" id="recoveryPanel" role="region" aria-labelledby="recoveryTitle">
<div>
<h2 id="recoveryTitle" tabindex="-1" data-i18n="recoveryTitle">Your primary profile needs recovery</h2>
<p id="recoveryIntro" data-i18n="recoveryIntro">OpenSquilla paused the primary gateway because it could not prove that writing is safe. Your data has not been merged or deleted.</p>
</div>
<div class="recovery-code" id="recoveryCode"></div>
<div class="recovery-group" id="workspaceGroup">
<label for="workspaceCandidates" data-i18n="workspaceLabel">Choose a verified workspace</label>
<div class="recovery-row">
<select id="workspaceCandidates"></select>
<button id="chooseWorkspace" type="button" data-i18n="chooseWorkspace">Use selected workspace</button>
<button id="browseWorkspace" type="button" data-i18n="browseWorkspace">Browse…</button>
</div>
</div>
<div class="recovery-group" id="existingRecoveryGroup">
<label for="recoveryProfiles" data-i18n="existingRecoveryLabel">Existing recovery profile</label>
<div class="recovery-row">
<select id="recoveryProfiles"></select>
<button id="continueRecovery" type="button" data-i18n="continueRecovery">Continue recovery profile</button>
</div>
</div>
<div class="recovery-group">
<fieldset>
<legend data-i18n="newRecoveryLabel">New recovery profile</legend>
<label class="check-row" for="copyCredential">
<input id="copyCredential" type="checkbox">
<span data-i18n="copyCredential">Copy the decryptable provider credential from the primary profile</span>
</label>
</fieldset>
<div class="recovery-row">
<button id="createRecovery" class="primary" type="button" data-i18n="createRecovery">Create recovery profile</button>
<button id="retryPrimary" type="button" data-i18n="retryPrimary">Retry primary profile</button>
<button id="returnPrimary" type="button" data-i18n="returnPrimary">Return to primary profile</button>
</div>
</div>
<div class="recovery-group" id="transactionRecoveryGroup" hidden>
<div class="recovery-row">
<button id="recoverTransaction" class="primary" type="button" data-i18n="recoverTransaction">Safely recover interrupted operation</button>
</div>
</div>
<div class="recovery-group" id="cleanupAbandonGroup" hidden>
<p data-i18n="abandonCleanupHelp">A cleanup stopped partway through. Preserve every remaining file and leave the interrupted transaction before choosing what to use next.</p>
<div class="recovery-row">
<button id="abandonCleanup" class="primary" type="button" data-i18n="abandonCleanup">Preserve remaining data and continue</button>
</div>
</div>
<p class="recovery-status" id="recoveryStatus" role="status" aria-live="polite"></p>
<div class="actions">
<button id="revealProfile" type="button" data-i18n="revealProfile">Show profile location</button>
<button id="revealBackups" type="button" data-i18n="revealBackups">Show backup location</button>
<button id="copyDiagnostics" type="button" data-i18n="copyDiagnostics">Copy diagnostics</button>
<button id="recoveryQuit" type="button" data-i18n="quit">Quit</button>
</div>
</section>
</main>
</div>
<script>
// Static-string catalog for the splash screen. The main process owns the
// live phase labels (already localized when sent over IPC), so this only
// covers the pre-IPC chrome: subtitle, default phase, step chips, error
// panel, and buttons. The first sub-second paint shows the English markup;
// we swap to the OS locale as soon as getOsLocale() resolves.
const MESSAGES = {
en: {
title: 'Starting OpenSquilla', bootAria: 'OpenSquilla startup',
subtitle: 'Starting the desktop control surface.',
phaseDefault: 'Preparing desktop profile',
stepProfile: 'Profile', stepGateway: 'Gateway', stepControl: 'Control',
errorTitle: 'Startup needs attention',
errorDefault: 'The gateway did not finish starting.',
revealLog: 'Reveal log', resetSetup: 'Reset setup', quit: 'Quit', retry: 'Retry',
startupPaused: 'Startup paused',
resetConfirm: 'Reset desktop setup? This clears the saved provider credential and reopens onboarding. Your workspace path, identity, memory, and chat history are kept.',
resetPhase: 'Resetting desktop setup',
resetProgress: 'Clearing saved setup and restarting onboarding.',
resetFailed: 'Reset failed',
recoveryTitle: 'Your primary profile needs recovery',
recoveryIntro: 'OpenSquilla paused the primary gateway because it could not prove that writing is safe. Your data has not been merged or deleted.',
recoveryConfirmationTitle: 'Confirm recovery profile',
recoveryConfirmationIntro: 'OpenSquilla is waiting for you to confirm the selected isolated recovery profile before it starts. This does not mean your primary profile is unsafe.',
recoveryProfileUnsafeTitle: 'Recovery profile needs attention',
recoveryProfileUnsafeIntro: 'OpenSquilla could not prove that the selected recovery profile is safe to write. The primary profile has not been selected or changed.',
cleanupRecoveryTitle: 'A data cleanup stopped partway through',
cleanupRecoveryIntro: 'Some confirmed items may already have been deleted. OpenSquilla stopped before touching anything it could not verify and preserved every remaining file.',
workspaceLabel: 'Choose a verified workspace', chooseWorkspace: 'Use selected workspace', browseWorkspace: 'Browse…',
existingRecoveryLabel: 'Existing recovery profile', continueRecovery: 'Continue recovery profile', noRecoveryProfiles: 'No recovery profiles yet',
newRecoveryLabel: 'New recovery profile', copyCredential: 'Copy the decryptable provider credential from the primary profile', createRecovery: 'Create recovery profile',
retryPrimary: 'Retry primary profile', returnPrimary: 'Return to primary profile', recoverTransaction: 'Safely recover interrupted operation', abandonCleanup: 'Preserve remaining data and continue', abandonCleanupHelp: 'A cleanup stopped partway through. Preserve every remaining file and leave the interrupted transaction before choosing what to use next.', revealProfile: 'Show profile location', revealBackups: 'Show backup location',
copyDiagnostics: 'Copy diagnostics', diagnosticsCopied: 'Sanitized diagnostics copied.', recoveryWorking: 'Working…', noWorkspaceCandidates: 'No verified candidates; browse for a workspace.',
},
'zh-Hans': {
title: '正在启动 OpenSquilla', bootAria: 'OpenSquilla 启动',
subtitle: '正在启动桌面控制界面。',
phaseDefault: '正在准备桌面配置',
stepProfile: '配置', stepGateway: '网关', stepControl: '控制',
errorTitle: '启动需要处理',
errorDefault: '网关未能完成启动。',
revealLog: '显示日志', resetSetup: '重新配置', quit: '退出', retry: '重试',
startupPaused: '启动已暂停',
resetConfirm: '要重置桌面设置吗?这会清除已保存的提供商凭据并重新进入引导。工作区路径、身份、记忆和聊天记录都会保留。',
resetPhase: '正在重置桌面设置',
resetProgress: '正在清除已保存的设置并重新启动引导。',
resetFailed: '重置失败',
recoveryTitle: '主配置需要恢复',
recoveryIntro: 'OpenSquilla 无法证明写入安全,因此已暂停主网关。你的数据没有被合并或删除。',
recoveryConfirmationTitle: '确认继续恢复配置',
recoveryConfirmationIntro: 'OpenSquilla 正在等待你确认启动已选中的独立恢复配置。这不表示主配置不安全。',
recoveryProfileUnsafeTitle: '恢复配置需要处理',
recoveryProfileUnsafeIntro: 'OpenSquilla 无法证明已选恢复配置可以安全写入。主配置未被选中或修改。',
cleanupRecoveryTitle: '数据清理在中途停止',
cleanupRecoveryIntro: '部分已确认条目可能已经删除。OpenSquilla 在遇到无法验证的内容前已停止,并保留了所有剩余文件。',
workspaceLabel: '选择已验证的工作区', chooseWorkspace: '使用所选工作区', browseWorkspace: '浏览…',
existingRecoveryLabel: '现有恢复配置', continueRecovery: '继续恢复配置', noRecoveryProfiles: '尚无恢复配置',
newRecoveryLabel: '新建恢复配置', copyCredential: '从主配置复制可解密的提供商凭据', createRecovery: '创建恢复配置',
retryPrimary: '重试主配置', returnPrimary: '返回主配置', recoverTransaction: '安全恢复中断的操作', abandonCleanup: '保留剩余数据并继续', abandonCleanupHelp: '清理在中途停止。请先保留所有剩余文件并结束中断的事务,再选择接下来要使用的配置。', revealProfile: '显示配置位置', revealBackups: '显示备份位置',
copyDiagnostics: '复制诊断信息', diagnosticsCopied: '已复制脱敏诊断信息。', recoveryWorking: '正在处理…', noWorkspaceCandidates: '没有已验证候选;请浏览选择工作区。',
},
ja: {
title: 'OpenSquilla を起動しています', bootAria: 'OpenSquilla の起動',
subtitle: 'デスクトップのコントロール画面を起動しています。',
phaseDefault: 'デスクトッププロファイルを準備しています',
stepProfile: 'プロファイル', stepGateway: 'ゲートウェイ', stepControl: 'コントロール',
errorTitle: '起動の確認が必要です',
errorDefault: 'ゲートウェイの起動が完了しませんでした。',
revealLog: 'ログを表示', resetSetup: '設定をリセット', quit: '終了', retry: '再試行',
startupPaused: '起動を一時停止しました',
resetConfirm: 'デスクトップ設定をリセットしますか?保存済みのプロバイダー認証情報を消去してオンボーディングを再開します。ワークスペースのパス、ID、メモリ、チャット履歴は保持されます。',
resetPhase: 'デスクトップ設定をリセットしています',
resetProgress: '保存済み設定を消去し、オンボーディングを再開しています。',
resetFailed: 'リセットに失敗しました',
recoveryTitle: 'プライマリプロファイルの復旧が必要です',
recoveryIntro: '安全な書き込みを確認できないため、プライマリゲートウェイを一時停止しました。データの結合や削除は行っていません。',
recoveryConfirmationTitle: '復旧プロファイルを確認',
recoveryConfirmationIntro: '選択済みの独立した復旧プロファイルを起動する前に確認が必要です。プライマリプロファイルが危険という意味ではありません。',
recoveryProfileUnsafeTitle: '復旧プロファイルの確認が必要です',
recoveryProfileUnsafeIntro: '選択済みの復旧プロファイルに安全に書き込めることを確認できません。プライマリプロファイルは選択も変更もされていません。',
cleanupRecoveryTitle: 'データのクリーンアップが途中で停止しました',
cleanupRecoveryIntro: '確認済みの項目の一部は削除済みの可能性があります。検証できない項目に触れる前に停止し、残りのファイルをすべて保持しました。',
workspaceLabel: '検証済みワークスペースを選択', chooseWorkspace: '選択したワークスペースを使用', browseWorkspace: '参照…',
existingRecoveryLabel: '既存の復旧プロファイル', continueRecovery: '復旧プロファイルを続行', noRecoveryProfiles: '復旧プロファイルはまだありません',
newRecoveryLabel: '新しい復旧プロファイル', copyCredential: 'プライマリから復号可能なプロバイダー認証情報をコピー', createRecovery: '復旧プロファイルを作成',
retryPrimary: 'プライマリを再試行', returnPrimary: 'プライマリに戻る', recoverTransaction: '中断した操作を安全に復旧', abandonCleanup: '残りのデータを保持して続行', abandonCleanupHelp: 'クリーンアップが途中で停止しました。残っているすべてのファイルを保持し、中断したトランザクションを終了してから次に使うプロファイルを選択してください。', revealProfile: 'プロファイルの場所を表示', revealBackups: 'バックアップの場所を表示',
copyDiagnostics: '診断情報をコピー', diagnosticsCopied: 'サニタイズ済み診断情報をコピーしました。', recoveryWorking: '処理中…', noWorkspaceCandidates: '検証済み候補がありません。ワークスペースを参照してください。',
},
fr: {
title: 'Démarrage d\'OpenSquilla', bootAria: 'Démarrage d\'OpenSquilla',
subtitle: 'Démarrage de l\'interface de contrôle du bureau.',
phaseDefault: 'Préparation du profil de bureau',
stepProfile: 'Profil', stepGateway: 'Passerelle', stepControl: 'Contrôle',
errorTitle: 'Le démarrage nécessite votre attention',
errorDefault: 'La passerelle n\'a pas terminé son démarrage.',
revealLog: 'Afficher le journal', resetSetup: 'Réinitialiser', quit: 'Quitter', retry: 'Réessayer',
startupPaused: 'Démarrage en pause',
resetConfirm: 'Réinitialiser la configuration du bureau ? Cela efface les identifiants du fournisseur et relance lonboarding. Le chemin de lespace de travail, lidentité, la mémoire et lhistorique des discussions sont conservés.',
resetPhase: 'Réinitialisation de la configuration du bureau',
resetProgress: 'Suppression de la configuration enregistrée et relance de lonboarding.',
resetFailed: 'Échec de la réinitialisation',
recoveryTitle: 'Le profil principal doit être récupéré',
recoveryIntro: 'OpenSquilla a suspendu la passerelle principale car une écriture sûre ne pouvait pas être garantie. Vos données nont été ni fusionnées ni supprimées.',
recoveryConfirmationTitle: 'Confirmer le profil de récupération',
recoveryConfirmationIntro: 'OpenSquilla attend votre confirmation avant de démarrer le profil de récupération isolé sélectionné. Cela ne signifie pas que le profil principal est dangereux.',
recoveryProfileUnsafeTitle: 'Le profil de récupération nécessite votre attention',
recoveryProfileUnsafeIntro: 'OpenSquilla ne peut pas garantir une écriture sûre dans le profil de récupération sélectionné. Le profil principal na été ni sélectionné ni modifié.',
cleanupRecoveryTitle: 'Un nettoyage des données sest arrêté en cours de route',
cleanupRecoveryIntro: 'Certains éléments confirmés peuvent déjà avoir été supprimés. OpenSquilla sest arrêté avant tout élément non vérifiable et a conservé tous les fichiers restants.',
workspaceLabel: 'Choisir un espace de travail vérifié', chooseWorkspace: 'Utiliser lespace sélectionné', browseWorkspace: 'Parcourir…',
existingRecoveryLabel: 'Profil de récupération existant', continueRecovery: 'Continuer ce profil', noRecoveryProfiles: 'Aucun profil de récupération',
newRecoveryLabel: 'Nouveau profil de récupération', copyCredential: 'Copier lidentifiant fournisseur déchiffrable du profil principal', createRecovery: 'Créer un profil de récupération',
retryPrimary: 'Réessayer le profil principal', returnPrimary: 'Revenir au profil principal', recoverTransaction: 'Récupérer lopération interrompue en toute sécurité', abandonCleanup: 'Conserver les données restantes et continuer', abandonCleanupHelp: 'Un nettoyage sest arrêté en cours de route. Conservez tous les fichiers restants et quittez la transaction interrompue avant de choisir le profil à utiliser.', revealProfile: 'Afficher lemplacement du profil', revealBackups: 'Afficher les sauvegardes',
copyDiagnostics: 'Copier le diagnostic', diagnosticsCopied: 'Diagnostic expurgé copié.', recoveryWorking: 'Traitement…', noWorkspaceCandidates: 'Aucun candidat vérifié ; parcourez les dossiers.',
},
de: {
title: 'OpenSquilla wird gestartet', bootAria: 'OpenSquilla-Start',
subtitle: 'Die Desktop-Steueroberfläche wird gestartet.',
phaseDefault: 'Desktop-Profil wird vorbereitet',
stepProfile: 'Profil', stepGateway: 'Gateway', stepControl: 'Control',
errorTitle: 'Der Start erfordert Ihre Aufmerksamkeit',
errorDefault: 'Das Gateway hat den Start nicht abgeschlossen.',
revealLog: 'Protokoll anzeigen', resetSetup: 'Zurücksetzen', quit: 'Beenden', retry: 'Wiederholen',
startupPaused: 'Start angehalten',
resetConfirm: 'Desktop-Einrichtung zurücksetzen? Die gespeicherten Anbieter-Zugangsdaten werden gelöscht und das Onboarding wird neu geöffnet. Arbeitsbereichspfad, Identität, Speicher und Chatverlauf bleiben erhalten.',
resetPhase: 'Desktop-Einrichtung wird zurückgesetzt',
resetProgress: 'Gespeicherte Einrichtung wird gelöscht und Onboarding neu gestartet.',
resetFailed: 'Zurücksetzen fehlgeschlagen',
recoveryTitle: 'Das Hauptprofil muss wiederhergestellt werden',
recoveryIntro: 'OpenSquilla hat das Haupt-Gateway angehalten, weil sicheres Schreiben nicht bestätigt werden konnte. Daten wurden weder zusammengeführt noch gelöscht.',
recoveryConfirmationTitle: 'Wiederherstellungsprofil bestätigen',
recoveryConfirmationIntro: 'OpenSquilla wartet auf Ihre Bestätigung, bevor das ausgewählte isolierte Wiederherstellungsprofil gestartet wird. Dies bedeutet nicht, dass das Hauptprofil unsicher ist.',
recoveryProfileUnsafeTitle: 'Wiederherstellungsprofil erfordert Aufmerksamkeit',
recoveryProfileUnsafeIntro: 'OpenSquilla konnte sicheres Schreiben im ausgewählten Wiederherstellungsprofil nicht bestätigen. Das Hauptprofil wurde weder ausgewählt noch geändert.',
cleanupRecoveryTitle: 'Eine Datenbereinigung wurde unterbrochen',
cleanupRecoveryIntro: 'Einige bestätigte Elemente wurden möglicherweise bereits gelöscht. OpenSquilla stoppte vor nicht überprüfbaren Elementen und behielt alle verbleibenden Dateien.',
workspaceLabel: 'Geprüften Arbeitsbereich wählen', chooseWorkspace: 'Auswahl verwenden', browseWorkspace: 'Durchsuchen…',
existingRecoveryLabel: 'Vorhandenes Wiederherstellungsprofil', continueRecovery: 'Profil fortsetzen', noRecoveryProfiles: 'Noch keine Wiederherstellungsprofile',
newRecoveryLabel: 'Neues Wiederherstellungsprofil', copyCredential: 'Entschlüsselbare Anbieter-Anmeldedaten aus dem Hauptprofil kopieren', createRecovery: 'Wiederherstellungsprofil erstellen',
retryPrimary: 'Hauptprofil erneut prüfen', returnPrimary: 'Zum Hauptprofil zurückkehren', recoverTransaction: 'Unterbrochenen Vorgang sicher wiederherstellen', abandonCleanup: 'Verbleibende Daten behalten und fortfahren', abandonCleanupHelp: 'Eine Bereinigung wurde unterbrochen. Behalten Sie alle verbliebenen Dateien und verlassen Sie die unterbrochene Transaktion, bevor Sie das nächste Profil auswählen.', revealProfile: 'Profilordner anzeigen', revealBackups: 'Sicherungsordner anzeigen',
copyDiagnostics: 'Diagnose kopieren', diagnosticsCopied: 'Bereinigte Diagnose kopiert.', recoveryWorking: 'Wird ausgeführt…', noWorkspaceCandidates: 'Keine geprüften Kandidaten; bitte Arbeitsbereich auswählen.',
},
es: {
title: 'Iniciando OpenSquilla', bootAria: 'Inicio de OpenSquilla',
subtitle: 'Iniciando la superficie de control del escritorio.',
phaseDefault: 'Preparando el perfil de escritorio',
stepProfile: 'Perfil', stepGateway: 'Pasarela', stepControl: 'Control',
errorTitle: 'El inicio necesita atención',
errorDefault: 'La pasarela no terminó de iniciarse.',
revealLog: 'Mostrar registro', resetSetup: 'Restablecer', quit: 'Salir', retry: 'Reintentar',
startupPaused: 'Inicio en pausa',
resetConfirm: '¿Restablecer la configuración de escritorio? Esto borra la credencial del proveedor y vuelve a abrir la configuración inicial. Se conservan la ruta del espacio de trabajo, la identidad, la memoria y el historial de chat.',
resetPhase: 'Restableciendo configuración de escritorio',
resetProgress: 'Borrando la configuración guardada y reiniciando la incorporación.',
resetFailed: 'Error al restablecer',
recoveryTitle: 'El perfil principal necesita recuperación',
recoveryIntro: 'OpenSquilla pausó la pasarela principal porque no pudo demostrar que fuera seguro escribir. No se combinaron ni eliminaron datos.',
recoveryConfirmationTitle: 'Confirmar perfil de recuperación',
recoveryConfirmationIntro: 'OpenSquilla espera tu confirmación antes de iniciar el perfil de recuperación aislado seleccionado. Esto no significa que el perfil principal sea inseguro.',
recoveryProfileUnsafeTitle: 'El perfil de recuperación necesita atención',
recoveryProfileUnsafeIntro: 'OpenSquilla no pudo demostrar que fuera seguro escribir en el perfil de recuperación seleccionado. El perfil principal no se seleccionó ni modificó.',
cleanupRecoveryTitle: 'Una limpieza de datos se detuvo a mitad de camino',
cleanupRecoveryIntro: 'Es posible que algunos elementos confirmados ya se hayan eliminado. OpenSquilla se detuvo antes de tocar algo no verificable y conservó todos los archivos restantes.',
workspaceLabel: 'Elegir un espacio verificado', chooseWorkspace: 'Usar el espacio seleccionado', browseWorkspace: 'Examinar…',
existingRecoveryLabel: 'Perfil de recuperación existente', continueRecovery: 'Continuar perfil de recuperación', noRecoveryProfiles: 'Aún no hay perfiles de recuperación',
newRecoveryLabel: 'Nuevo perfil de recuperación', copyCredential: 'Copiar la credencial descifrable del proveedor desde el perfil principal', createRecovery: 'Crear perfil de recuperación',
retryPrimary: 'Reintentar perfil principal', returnPrimary: 'Volver al perfil principal', recoverTransaction: 'Recuperar de forma segura la operación interrumpida', abandonCleanup: 'Conservar los datos restantes y continuar', abandonCleanupHelp: 'Una limpieza se detuvo a mitad de camino. Conserva todos los archivos restantes y sal de la transacción interrumpida antes de elegir qué perfil usar.', revealProfile: 'Mostrar ubicación del perfil', revealBackups: 'Mostrar copias de seguridad',
copyDiagnostics: 'Copiar diagnóstico', diagnosticsCopied: 'Diagnóstico depurado copiado.', recoveryWorking: 'Procesando…', noWorkspaceCandidates: 'No hay candidatos verificados; busca un espacio de trabajo.',
},
};
let msg = MESSAGES.en;
const started = performance.now();
const phase = document.getElementById('phase');
const timer = document.getElementById('timer');
const errorPanel = document.getElementById('errorPanel');
const errorMessage = document.getElementById('errorMessage');
const recoveryPanel = document.getElementById('recoveryPanel');
const recoveryTitle = document.getElementById('recoveryTitle');
const recoveryIntro = document.getElementById('recoveryIntro');
const recoveryCode = document.getElementById('recoveryCode');
const recoveryStatus = document.getElementById('recoveryStatus');
const workspaceCandidates = document.getElementById('workspaceCandidates');
const recoveryProfiles = document.getElementById('recoveryProfiles');
let currentRecoveryState = null;
const steps = {
profile: document.querySelector('[data-step="profile"]'),
gateway: document.querySelector('[data-step="gateway"]'),
control: document.querySelector('[data-step="control"]'),
};
// Map the stable phaseId from main to one of the three visible step chips.
const stepMap = {
profile: 'profile',
'gateway-start': 'gateway',
'gateway-health': 'gateway',
control: 'control',
ready: 'control',
};
function applyLocale(locale) {
msg = MESSAGES[locale] || MESSAGES.en;
document.documentElement.lang = MESSAGES[locale] ? locale : 'en';
document.title = msg.title;
document.querySelectorAll('[data-i18n]').forEach((el) => {
const key = el.getAttribute('data-i18n');
if (msg[key]) el.textContent = msg[key];
});
document.querySelectorAll('[data-i18n-aria]').forEach((el) => {
const key = el.getAttribute('data-i18n-aria');
if (msg[key]) el.setAttribute('aria-label', msg[key]);
});
if (currentRecoveryState) renderRecoveryState(currentRecoveryState, false);
}
function setStepState(activeKey, ready) {
let passedActive = false;
for (const [key, step] of Object.entries(steps)) {
step.classList.remove('active', 'done');
if (key === activeKey) {
step.classList.add(ready ? 'done' : 'active');
passedActive = true;
} else if (!passedActive) {
step.classList.add('done');
}
}
}
function applyStatus(payload) {
const phaseId = payload && payload.phaseId ? String(payload.phaseId) : 'profile';
// The label is already localized by the main process; fall back to the
// local default phase string only when no label was provided.
phase.textContent = payload && payload.label ? String(payload.label) : msg.phaseDefault;
errorPanel.classList.remove('visible');
recoveryPanel.classList.remove('visible');
document.body.classList.remove('recovering');
// Leaving the error state: resume the sweep and the elapsed timer.
document.body.classList.remove('errored');
if (!timerInterval) timerInterval = setInterval(updateTimer, 100);
setStepState(stepMap[phaseId] || 'profile', phaseId === 'ready');
}
function applyError(payload) {
const message = payload && payload.message ? String(payload.message) : msg.errorDefault;
phase.textContent = msg.startupPaused;
errorMessage.textContent = message;
errorPanel.classList.add('visible');
recoveryPanel.classList.remove('visible');
document.body.classList.remove('recovering');
// Stop signalling progress: freeze the elapsed timer, pause the sweep (via
// the .errored class), and drop the active step highlight so a failed phase
// is not shown as still running.
document.body.classList.add('errored');
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
for (const step of Object.values(steps)) {
if (step) step.classList.remove('active');
}
}
function renderRecoveryState(state, moveFocus = true) {
currentRecoveryState = state || null;
const inspection = state && state.inspection;
const blocked = Boolean(state && state.blocked && inspection);
if (!blocked) {
recoveryPanel.classList.remove('visible');
document.body.classList.remove('recovering');
return;
}
const wasVisible = recoveryPanel.classList.contains('visible');
document.body.classList.add('recovering', 'errored');
errorPanel.classList.remove('visible');
recoveryPanel.classList.add('visible');
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
recoveryCode.textContent = String(inspection.stable_code || 'recovery_required');
const confirmationRequired = (
inspection.stable_code === 'desktop_recovery_profile_confirmation_required'
);
const cleanupInterrupted = inspection.stable_code === 'cleanup_transaction_incomplete';
const recoveryProfileUnsafe = (
!confirmationRequired && !cleanupInterrupted && state.activeProfile?.kind === 'recovery'
);
recoveryTitle.textContent = cleanupInterrupted
? msg.cleanupRecoveryTitle
: confirmationRequired
? msg.recoveryConfirmationTitle
: (recoveryProfileUnsafe ? msg.recoveryProfileUnsafeTitle : msg.recoveryTitle);
recoveryIntro.textContent = cleanupInterrupted
? msg.cleanupRecoveryIntro
: confirmationRequired
? msg.recoveryConfirmationIntro
: (recoveryProfileUnsafe ? msg.recoveryProfileUnsafeIntro : msg.recoveryIntro);
const candidates = Array.isArray(inspection.candidates)
? inspection.candidates.filter((item) => (
item
&& ['canonical', 'legacy', 'external'].includes(String(item.kind || ''))
&& item.exists
&& item.valid
))
: [];
const allowedActions = new Set(Array.isArray(inspection.allowed_actions)
? inspection.allowed_actions.map((item) => String(item))
: []);
const mayChooseWorkspace = allowedActions.has('choose-workspace');
workspaceCandidates.replaceChildren();
if (candidates.length) {
for (const candidate of candidates) {
const option = document.createElement('option');
option.value = String(candidate.path || '');
option.textContent = `${String(candidate.kind || 'workspace')}${String(candidate.path || '')}`;
workspaceCandidates.appendChild(option);
}
} else {
const option = document.createElement('option');
option.value = '';
option.textContent = msg.noWorkspaceCandidates;
workspaceCandidates.appendChild(option);
}
const profiles = Array.isArray(state.recoveryProfiles) ? state.recoveryProfiles : [];
recoveryProfiles.replaceChildren();
if (profiles.length) {
for (const profile of profiles) {
const option = document.createElement('option');
option.value = String(profile.id || '');
option.textContent = String(profile.home || profile.id || '');
recoveryProfiles.appendChild(option);
}
} else {
const option = document.createElement('option');
option.value = '';
option.textContent = msg.noRecoveryProfiles;
recoveryProfiles.appendChild(option);
}
document.getElementById('workspaceGroup').hidden = !mayChooseWorkspace;
document.getElementById('existingRecoveryGroup').hidden = (
profiles.length === 0 || !allowedActions.has('continue-recovery-profile')
);
document.getElementById('returnPrimary').hidden = state.activeProfile?.kind !== 'recovery';
document.getElementById('createRecovery').hidden = !allowedActions.has('create-recovery-profile');
document.getElementById('retryPrimary').hidden = !allowedActions.has('retry-primary-profile');
document.getElementById('transactionRecoveryGroup').hidden = !allowedActions.has('recover-transaction');
const mayAbandonCleanup = allowedActions.has('abandon-cleanup')
&& typeof api?.abandonCleanupTransaction === 'function';
document.getElementById('cleanupAbandonGroup').hidden = !mayAbandonCleanup;
document.getElementById('revealBackups').hidden = !allowedActions.has('show-backups');
document.getElementById('copyDiagnostics').hidden = !allowedActions.has('copy-diagnostics');
recoveryStatus.textContent = state.error ? String(state.error) : (state.busy ? msg.recoveryWorking : '');
for (const button of recoveryPanel.querySelectorAll('button')) button.disabled = Boolean(state.busy);
document.getElementById('chooseWorkspace').disabled = (
Boolean(state.busy) || !mayChooseWorkspace || candidates.length === 0
);
document.getElementById('browseWorkspace').disabled = Boolean(state.busy) || !mayChooseWorkspace;
document.getElementById('continueRecovery').disabled = (
Boolean(state.busy)
|| !allowedActions.has('continue-recovery-profile')
|| profiles.length === 0
);
document.getElementById('recoverTransaction').disabled = (
Boolean(state.busy) || !allowedActions.has('recover-transaction')
);
document.getElementById('abandonCleanup').disabled = (
Boolean(state.busy) || !mayAbandonCleanup
);
if (moveFocus && !wasVisible) recoveryTitle.focus();
}
async function runRecoveryAction(action) {
recoveryStatus.textContent = msg.recoveryWorking;
try {
const result = await action();
if (result && result.state) renderRecoveryState(result.state, false);
if (result && result.ok === false) {
recoveryStatus.textContent = String(result.error || msg.errorDefault);
}
return result;
} catch (error) {
recoveryStatus.textContent = error && error.message ? error.message : String(error);
return null;
}
}
async function resetSetup() {
if (!api || typeof api.resetDesktopSettings !== 'function') return;
const ok = window.confirm(msg.resetConfirm);
if (!ok) return;
phase.textContent = msg.resetPhase;
errorMessage.textContent = msg.resetProgress;
try {
const result = await api.resetDesktopSettings();
if (result && result.ok === false) {
throw new Error(String(result.detail || result.error || msg.errorDefault));
}
await api.retryStartup();
} catch (err) {
const message = err && err.message ? err.message : String(err);
phase.textContent = msg.startupPaused;
errorMessage.textContent = `${msg.resetFailed}: ${message}`;
errorPanel.classList.add('visible');
}
}
function updateTimer() {
timer.textContent = `${((performance.now() - started) / 1000).toFixed(1).padStart(4, '0')}s`;
}
let timerInterval = setInterval(updateTimer, 100);
const api = window.opensquillaDesktop;
if (api) {
if (typeof api.getOsLocale === 'function') {
api.getOsLocale().then((locale) => applyLocale(String(locale || 'en'))).catch(() => {});
}
api.onBootStatus(applyStatus);
api.onBootError(applyError);
if (typeof api.onRecoveryState === 'function') {
api.onRecoveryState((state) => renderRecoveryState(state));
}
api.getBootState().then((state) => {
if (state && state.recovery && state.recovery.blocked) renderRecoveryState(state.recovery);
else if (state && state.error) applyError(state.error);
else applyStatus(state && state.status ? state.status : null);
}).catch(() => applyStatus(null));
document.getElementById('retry').addEventListener('click', () => api.retryStartup());
document.getElementById('revealLog').addEventListener('click', () => api.revealGatewayLog());
document.getElementById('quit').addEventListener('click', () => api.quitApp());
const resetButton = document.getElementById('resetSetup');
if (typeof api.resetDesktopSettings === 'function') {
resetButton.addEventListener('click', () => resetSetup());
} else {
resetButton.style.display = 'none';
}
document.getElementById('chooseWorkspace').addEventListener('click', () => runRecoveryAction(() => (
api.chooseRecoveryWorkspace({ workspace: workspaceCandidates.value })
)));
document.getElementById('browseWorkspace').addEventListener('click', () => runRecoveryAction(() => (
api.chooseRecoveryWorkspace({})
)));
document.getElementById('continueRecovery').addEventListener('click', () => runRecoveryAction(() => (
api.launchSafeProfile({ mode: 'continue', recoveryId: recoveryProfiles.value })
)));
document.getElementById('createRecovery').addEventListener('click', () => runRecoveryAction(() => (
api.launchSafeProfile({
mode: 'create',
copyPrimaryCredential: document.getElementById('copyCredential').checked,
})
)));
document.getElementById('retryPrimary').addEventListener('click', () => runRecoveryAction(() => (
api.retryPrimaryProfile()
)));
document.getElementById('recoverTransaction').addEventListener('click', () => runRecoveryAction(() => (
api.recoverProfileTransaction()
)));
document.getElementById('abandonCleanup').addEventListener('click', () => runRecoveryAction(() => (
api.abandonCleanupTransaction()
)));
document.getElementById('returnPrimary').addEventListener('click', () => runRecoveryAction(() => (
api.returnPrimaryProfile()
)));
document.getElementById('revealProfile').addEventListener('click', () => (
api.revealRecoveryPath({ target: 'primary' })
));
document.getElementById('revealBackups').addEventListener('click', () => (
api.revealRecoveryPath({ target: 'backups' })
));
document.getElementById('copyDiagnostics').addEventListener('click', async () => {
if (await api.copyRecoveryDiagnostics()) recoveryStatus.textContent = msg.diagnosticsCopied;
});
document.getElementById('recoveryQuit').addEventListener('click', () => api.quitApp());
}
</script>
</body>
</html>