ODIAN Launcher: hub, installer, local stack and remembered sign-in (M2)

- Tauri 2 + Svelte 5 hub in ODIAN dark: patch notes and roadmap, install and
  update progress, local stack status, settings with DHP and MCP addresses.
- crates/odian-packs: Rust installer for pack format v1 (signed manifests,
  resumable verified downloads from a DHP or an ODIAN drive), byte-compatible
  with ODIAN_MCP_Server's Python odian_packs.
- Local stack on 127.0.0.1: Postgres -> Engine API -> tile server -> Odian.exe,
  one Job Object, clean shutdown; database snapshot restore on a fresh PC only.
- Keycloak sign-in through the DHP's MCP, remembered until Sign out with a
  launcher session that renews the Engine token silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kelorastus
2026-09-15 15:23:17 +02:00
commit b7f3061be3
102 changed files with 47611 additions and 0 deletions
+495
View File
@@ -0,0 +1,495 @@
<script lang="ts">
import Icon, { type IconName } from "./Icon.svelte";
import ProgressBar from "./ProgressBar.svelte";
import StackList from "./StackList.svelte";
import StateDot, { type DotTone } from "./StateDot.svelte";
import { launcher } from "../lib/launcher.svelte";
import { STAGE_LABEL, formatBytes, formatCount, formatDuration, formatSpeed } from "../lib/format";
const s = $derived(launcher.status);
const p = $derived(launcher.progress);
const action = $derived(launcher.action);
const busy = $derived(launcher.busy);
const checking = $derived(s?.phase === "checking");
const blocked = $derived(busy !== null || checking);
const site = $derived(launcher.siteName);
const fraction = $derived.by(() => {
if (!p) return null;
if (p.total_bytes > 0) return p.done_bytes / p.total_bytes;
if (p.total_items > 0) return p.done_items / p.total_items;
return null;
});
const transferred = $derived.by(() => {
if (!p) return "—";
if (p.total_bytes > 0) return `${formatBytes(p.done_bytes)} / ${formatBytes(p.total_bytes)}`;
if (p.total_items > 0) return `${formatCount(p.done_items)} / ${formatCount(p.total_items)}`;
return "—";
});
interface Head {
label: string;
tone: DotTone;
pulse: boolean;
meta: string | null;
}
const head: Head = $derived.by(() => {
const stage = p ? `Stage ${p.stage_index}/${p.stage_count}` : null;
const version = (v: string | null | undefined) => (v ? `v${v}` : null);
switch (action) {
case "loading":
return { label: "Connecting", tone: "idle", pulse: true, meta: null };
case "sign_in":
if (s?.installed && s.update_required) {
return { label: "Update required", tone: "warn", pulse: false, meta: `${s.installed} → ${s.available}` };
}
return s?.installed
? { label: "Signed out", tone: "idle", pulse: false, meta: version(s.installed) }
: { label: "Not installed", tone: "idle", pulse: false, meta: version(s?.available) };
case "signed_out_offline":
return { label: "Signed out", tone: "warn", pulse: false, meta: "offline" };
case "signing_in":
return { label: "Signing in", tone: "accent", pulse: true, meta: null };
case "install":
return { label: "Ready to install", tone: "accent", pulse: false, meta: version(s?.available) };
case "offline_unavailable":
return { label: "Not installed", tone: "warn", pulse: false, meta: "offline" };
case "installing":
return { label: s?.installed ? "Updating" : "Installing", tone: "accent", pulse: true, meta: stage };
case "restoring":
return { label: "Restoring database", tone: "accent", pulse: true, meta: stage };
case "update":
return { label: "Update required", tone: "warn", pulse: false, meta: `${s?.installed} → ${s?.available}` };
case "launch":
return { label: s?.online ? "Ready" : "Ready offline", tone: "ok", pulse: false, meta: version(s?.installed) };
case "stack":
if (launcher.stopping) return { label: "Stopping", tone: "warn", pulse: true, meta: version(s?.installed) };
return s?.phase === "running"
? { label: "Running", tone: "ok", pulse: false, meta: version(s?.installed) }
: { label: "Starting ODIAN", tone: "accent", pulse: true, meta: version(s?.installed) };
case "error":
return { label: "Needs attention", tone: "danger", pulse: false, meta: null };
}
});
const showNotice = $derived(launcher.notice !== null && !(action === "error" && launcher.notice === s?.error));
</script>
{#snippet primary(label: string, icon: IconName, onclick: () => void, disabled: boolean, pending = false)}
<div class="primary-wrap">
<button class="primary" {onclick} {disabled} aria-busy={pending}>
<span class="primary-icon">
{#if pending}
<span class="spinner" aria-hidden="true"></span>
{:else}
<Icon name={icon} size={18} strokeWidth={1.75} />
{/if}
</span>
<span class="primary-label">{label}</span>
</button>
<span class="corners" aria-hidden="true"></span>
</div>
{/snippet}
{#snippet driveLink(lead: string)}
<p class="secondary">
{lead}
<button class="btn-link" disabled={blocked} onclick={() => launcher.installFromDrive()}>Install from a drive…</button>
</p>
{/snippet}
<section class="action glass" aria-labelledby="action-title">
<header class="head">
<StateDot tone={head.tone} pulse={head.pulse} />
<h2 id="action-title" class="eyebrow title" aria-live="polite">{head.label}</h2>
{#if head.meta}<span class="meta mono">{head.meta}</span>{/if}
</header>
{#if action === "loading"}
{@render primary("Loading", "play", () => {}, true, true)}
{:else if action === "sign_in"}
{@render primary(
s?.update_required ? "Sign in to update" : s?.installed ? "Sign in" : "Sign in to install",
"key",
() => launcher.signIn(),
blocked,
busy === "sign_in",
)}
<p class="secondary">
{#if s?.update_required}
ODIAN <strong>{s.available}</strong> is required before launch while connected. Sign in with your {site}
account to download it.
{:else if s?.installed}
Sign in with your {site} account. This PC then remembers you, online and offline, until you sign out.
{:else}
Sign in with your {site} account to download ODIAN{#if s?.available}&nbsp;<strong>{s.available}</strong>{/if}.
{/if}
</p>
{#if !s?.installed}{@render driveLink("No access to the DHP?")}{/if}
{:else if action === "signing_in"}
{@render primary("Waiting for sign-in", "key", () => {}, true, true)}
<p class="secondary">
Complete the Keycloak sign-in in your browser, then come back here.
<button class="btn-link" onclick={() => launcher.cancelSignIn()}>Cancel sign-in</button>
</p>
{:else if action === "install"}
{@render primary("Install", "download", () => launcher.install(), blocked, busy === "install")}
<p class="secondary">
ODIAN <strong>{s?.available}</strong> from {site}, into <span class="mono path">{s?.install_dir}</span>.
</p>
{@render driveLink("Have an ODIAN drive?")}
{:else if action === "offline_unavailable"}
{@render primary("Connect to your DHP to install", "globe", () => {}, true)}
{@render driveLink("Installing needs your DHP, or an ODIAN drive.")}
{:else if action === "signed_out_offline"}
{@render primary("Connect to your DHP to sign in", "globe", () => {}, true)}
<p class="secondary">
This PC doesn't remember an operator anymore. Sign in once while connected to {site}, and ODIAN launches offline
again after that.
</p>
{:else if action === "update"}
{@render primary(`Update to ${s?.available}`, "update", () => launcher.install(), blocked, busy === "install")}
<p class="secondary">
Updating is required before launch while connected to {site}. Everything already verified is reused.
</p>
{:else if action === "launch"}
{@render primary("Launch", "play", () => launcher.launch(), blocked || !s?.can_launch, busy === "launch")}
<p class="secondary">
{#if checking}
Checking {site}…
{:else if s?.online}
ODIAN <strong>{s.installed}</strong> is up to date.
{:else}
Offline: ODIAN <strong>{s?.installed}</strong> runs on local data and map tiles.
{/if}
</p>
{:else if action === "installing"}
<div class="progress">
<div class="stage-line">
<span class="stage-name">{p ? STAGE_LABEL[p.stage] : "Preparing"}</span>
{#if fraction !== null}<span class="pct mono tabular">{Math.floor(fraction * 100)}%</span>{/if}
</div>
<ProgressBar
value={fraction}
label={p ? `${STAGE_LABEL[p.stage]}, stage ${p.stage_index} of ${p.stage_count}` : "Preparing install"}
/>
{#if p}
<div class="segments" style:--count={p.stage_count} aria-hidden="true">
{#each { length: p.stage_count } as _, i (i)}
<span class="seg" class:done={i < p.stage_index - 1} class:current={i === p.stage_index - 1}></span>
{/each}
</div>
<dl class="metrics">
<div>
<dt>Transferred</dt>
<dd class="mono tabular" title={transferred}>{transferred}</dd>
</div>
<div>
<dt>Speed</dt>
<dd class="mono tabular">{launcher.speed > 0 ? formatSpeed(launcher.speed) : "—"}</dd>
</div>
<div>
<dt>Stage ETA</dt>
<dd class="mono tabular">{launcher.eta !== null ? formatDuration(launcher.eta) : "—"}</dd>
</div>
</dl>
{#if p.message}<p class="message mono" title={p.message}>{p.message}</p>{/if}
{/if}
</div>
<button class="btn btn-danger wide" disabled={busy !== null} onclick={() => launcher.cancelInstall()}>
{busy === "cancel" ? "Cancelling…" : "Cancel"}
</button>
<p class="secondary small">Cancelling keeps every file already verified; the next install resumes from there.</p>
{:else if action === "restoring"}
<div class="progress">
<div class="stage-line">
<span class="stage-name">Loading the database snapshot</span>
</div>
<ProgressBar value={null} label="Restoring the database" />
{#if p?.stage === "restore" && p.message}<p class="message mono" title={p.message}>{p.message}</p>{/if}
</div>
<p class="secondary">This can take a few minutes. Keep the launcher open until it finishes.</p>
{:else if action === "stack"}
<StackList stack={launcher.stack} phase={s?.phase ?? "launching"} />
<button class="btn btn-danger wide" disabled={launcher.stopping || busy !== null} onclick={() => launcher.stop()}>
<Icon name="stop" size={12} />
{launcher.stopping ? "Stopping…" : s?.phase === "running" ? "Stop ODIAN" : "Stop"}
</button>
{:else if action === "error"}
<div class="error-box" role="alert">
<Icon name="alert" size={16} />
<p class="selectable">{s?.error ?? "Something went wrong."}</p>
</div>
{@render primary("Retry", "refresh", () => launcher.retry(), blocked, busy !== null)}
<p class="secondary">
<button class="btn-link" onclick={() => launcher.setActivityOpen(true)}>View activity</button> for the full log.
</p>
{/if}
{#if showNotice}
<div class="notice" role="alert">
<Icon name="alert" size={14} />
<p class="selectable">{launcher.notice}</p>
<button class="icon-btn dismiss" aria-label="Dismiss message" onclick={() => launcher.dismissNotice()}>
<Icon name="close" size={12} />
</button>
</div>
{/if}
</section>
<style>
.action {
display: grid;
gap: 14px;
width: 100%;
padding: 18px 22px 22px;
}
.head {
display: flex;
align-items: center;
gap: 10px;
min-height: 20px;
}
.title {
flex: 1;
color: var(--ink-2);
}
.meta {
color: var(--muted);
font-size: var(--text-xs);
}
/* Primary button */
.primary-wrap {
position: relative;
margin: 4px 0 2px;
}
.primary {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
height: 64px;
padding: 0 24px;
border: 1px solid var(--accent);
border-radius: var(--radius);
background: var(--accent);
color: var(--accent-ink);
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 600;
letter-spacing: 0.02em;
box-shadow: 0 14px 40px -16px rgba(91, 140, 255, 0.7);
transition:
background-color var(--dur-fast) var(--ease),
border-color var(--dur-fast) var(--ease),
box-shadow var(--dur) var(--ease),
transform var(--dur-fast) var(--ease);
}
.primary:hover:not(:disabled) {
border-color: var(--accent-hover);
background: var(--accent-hover);
box-shadow: 0 18px 48px -14px rgba(91, 140, 255, 0.85);
}
.primary:active:not(:disabled) {
background: var(--accent-press);
transform: translateY(1px);
}
.primary:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
}
.primary:disabled {
border-color: var(--line-strong);
background: var(--surface-2);
color: var(--muted);
box-shadow: none;
font-size: var(--text-lg);
}
.primary-icon {
display: grid;
place-items: center;
width: 20px;
}
.primary-label {
flex: 1;
overflow: hidden;
text-align: left;
white-space: nowrap;
text-overflow: ellipsis;
}
.corners {
--c: var(--accent-line);
position: absolute;
inset: -6px;
background:
linear-gradient(var(--c), var(--c)) 0 0 / 10px 1px,
linear-gradient(var(--c), var(--c)) 0 0 / 1px 10px,
linear-gradient(var(--c), var(--c)) 100% 0 / 10px 1px,
linear-gradient(var(--c), var(--c)) 100% 0 / 1px 10px,
linear-gradient(var(--c), var(--c)) 0 100% / 10px 1px,
linear-gradient(var(--c), var(--c)) 0 100% / 1px 10px,
linear-gradient(var(--c), var(--c)) 100% 100% / 10px 1px,
linear-gradient(var(--c), var(--c)) 100% 100% / 1px 10px;
background-repeat: no-repeat;
pointer-events: none;
transition:
inset var(--dur) var(--ease),
opacity var(--dur) var(--ease);
}
.primary-wrap:hover .corners {
inset: -8px;
}
.primary:disabled + .corners {
opacity: 0;
}
.spinner {
width: 16px;
height: 16px;
border: 1.5px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* Text */
.secondary {
color: var(--muted);
font-size: var(--text-sm);
line-height: 1.55;
}
.secondary.small {
font-size: var(--text-xs);
}
.secondary strong {
color: var(--ink-2);
font-weight: 500;
}
.path {
color: var(--ink-2);
font-size: var(--text-xs);
}
.wide {
width: 100%;
height: 44px;
}
/* Progress */
.progress {
display: grid;
gap: 10px;
}
.stage-line {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.stage-name {
color: var(--ink);
font-family: var(--font-display);
font-size: var(--text-lg);
font-weight: 500;
}
.pct {
color: var(--ink-2);
font-size: var(--text-sm);
}
.segments {
display: grid;
grid-template-columns: repeat(var(--count), 1fr);
gap: 3px;
}
.seg {
height: 2px;
background: var(--line-strong);
transition: background-color var(--dur) var(--ease);
}
.seg.done {
background: var(--accent);
}
.seg.current {
background: var(--accent-line);
}
.metrics {
display: grid;
grid-template-columns: 1.45fr 1fr 1fr;
gap: 1px;
margin: 2px 0 0;
border: 1px solid var(--line);
background: var(--line);
}
.metrics > div {
min-width: 0;
padding: 7px 10px;
background: var(--surface);
}
.metrics dt {
color: var(--muted);
font-size: var(--text-xs);
}
.metrics dd {
margin: 0;
overflow: hidden;
color: var(--ink);
font-size: var(--text-xs);
white-space: nowrap;
text-overflow: ellipsis;
}
.message {
overflow: hidden;
color: var(--muted);
font-size: var(--text-xs);
white-space: nowrap;
text-overflow: ellipsis;
}
/* Errors */
.error-box,
.notice {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border: 1px solid rgba(248, 113, 113, 0.32);
border-radius: var(--radius);
background: var(--danger-soft);
color: var(--danger);
}
.error-box p,
.notice p {
flex: 1;
color: var(--ink-2);
font-size: var(--text-sm);
line-height: 1.5;
}
.error-box :global(.icon),
.notice :global(.icon) {
margin-top: 2px;
}
.notice {
align-items: center;
padding: 6px 6px 6px 12px;
}
.dismiss {
width: 24px;
height: 24px;
}
</style>