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:
@@ -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} <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>
|
||||
Reference in New Issue
Block a user