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
+443
View File
@@ -0,0 +1,443 @@
<script lang="ts">
import { untrack } from "svelte";
import Icon from "./Icon.svelte";
import { launcher } from "../lib/launcher.svelte";
import type { Settings } from "../lib/types";
interface Props {
open: boolean;
}
let { open = $bindable(false) }: Props = $props();
const EMPTY: Settings = { site_url: "", mcp_url: "", install_dir: "" };
let dialog: HTMLDialogElement | undefined = $state();
let draft = $state<Settings>({ ...EMPTY });
let saved = $state(false);
let saveError = $state<string | null>(null);
/** Sign out forgets the operator on this PC, so it takes a second, explicit click. */
let confirmSignOut = $state(false);
const s = $derived(launcher.status);
const current = $derived(launcher.settings);
const locked = $derived(launcher.locked);
const saving = $derived(launcher.busy === "settings");
const busy = $derived(launcher.busy !== null);
const trimmed = $derived<Settings>({
site_url: draft.site_url.trim(),
mcp_url: draft.mcp_url.trim(),
install_dir: draft.install_dir.trim(),
});
const dirty = $derived(
current !== null &&
(trimmed.site_url !== current.site_url ||
trimmed.mcp_url !== current.mcp_url ||
trimmed.install_dir !== current.install_dir),
);
const siteError = $derived(urlProblem(trimmed.site_url));
const mcpError = $derived(urlProblem(trimmed.mcp_url));
const dirError = $derived(trimmed.install_dir ? null : "Choose where ODIAN is installed.");
const invalid = $derived(siteError !== null || mcpError !== null || dirError !== null);
function urlProblem(value: string): string | null {
if (!value) return "Required.";
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? null : "Use an http:// or https:// address.";
} catch {
return "Enter a full address, including http:// or https://.";
}
}
function resetDraft() {
draft = current ? { ...current } : { ...EMPTY };
saved = false;
saveError = null;
confirmSignOut = false;
}
// Keep the native modal in sync with `open`, returning focus to whatever opened it.
let opener: HTMLElement | null = null;
$effect(() => {
const el = dialog;
if (!el) return;
if (open && !el.open) {
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
untrack(resetDraft);
el.showModal();
} else if (!open) {
if (el.open) el.close();
if (opener?.isConnected) opener.focus();
opener = null;
}
});
// Settings may arrive after the drawer was opened.
$effect(() => {
if (open && current && untrack(() => !draft.site_url && !draft.mcp_url && !draft.install_dir)) {
untrack(resetDraft);
}
});
$effect(() => {
const el = dialog;
if (!el) return;
// Clicks on the ::backdrop target the dialog element itself.
const onClick = (e: MouseEvent) => {
if (e.target === el) open = false;
};
// The native cancel-on-Escape is skipped for some synthetic key events; be explicit.
const onKeydown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
open = false;
}
};
el.addEventListener("click", onClick);
el.addEventListener("keydown", onKeydown);
return () => {
el.removeEventListener("click", onClick);
el.removeEventListener("keydown", onKeydown);
};
});
async function save(event: SubmitEvent) {
event.preventDefault();
if (!dirty || invalid || locked) return;
const ok = await launcher.saveSettings({ ...trimmed });
if (ok) {
resetDraft();
saved = true;
} else {
saveError = launcher.notice;
}
}
/** Keyboard focus for a control that replaces the one the operator just used. */
function focusOnMount(node: HTMLElement) {
node.focus();
}
async function signOutAndForget() {
confirmSignOut = false;
await launcher.signOut();
// The button that had focus is gone; put focus back inside the drawer so Escape still closes it.
dialog?.querySelector<HTMLButtonElement>("button:not([disabled])")?.focus();
}
async function browse() {
const path = await launcher.pickFolder();
if (path) draft.install_dir = path;
}
function installFromDrive() {
open = false;
void launcher.installFromDrive();
}
function signIn() {
open = false;
void launcher.signIn();
}
</script>
<dialog bind:this={dialog} class="drawer" aria-labelledby="settings-title" onclose={() => (open = false)}>
<form class="panel" onsubmit={save} novalidate>
<header class="head">
<h2 id="settings-title">Settings</h2>
<button type="button" class="icon-btn" aria-label="Close settings" onclick={() => (open = false)}>
<Icon name="close" />
</button>
</header>
<div class="content">
{#if locked}
<p class="lock" role="note">
<Icon name="alert" size={14} />
Settings are locked while ODIAN is signing in, installing or running.
</p>
{/if}
<fieldset class="group" disabled={locked || saving}>
<legend class="eyebrow">DHP</legend>
<div class="field">
<label class="field-label" for="set-site-url">DHP URL</label>
<input
id="set-site-url"
class="input"
type="url"
spellcheck="false"
autocomplete="off"
bind:value={draft.site_url}
aria-invalid={dirty && siteError !== null}
aria-describedby="set-site-url-help"
/>
<span id="set-site-url-help" class={dirty && siteError ? "field-error" : "field-help"}>
{dirty && siteError ? siteError : "Update service of your DHP."}
</span>
</div>
<div class="field">
<label class="field-label" for="set-mcp-url">MCP URL</label>
<input
id="set-mcp-url"
class="input"
type="url"
spellcheck="false"
autocomplete="off"
bind:value={draft.mcp_url}
aria-invalid={dirty && mcpError !== null}
aria-describedby="set-mcp-url-help"
/>
<span id="set-mcp-url-help" class={dirty && mcpError ? "field-error" : "field-help"}>
{dirty && mcpError ? mcpError : "The DHP's MCP handles Keycloak sign-in."}
</span>
</div>
</fieldset>
<fieldset class="group" disabled={locked || saving}>
<legend class="eyebrow">Installation</legend>
<div class="field">
<label class="field-label" for="set-install-dir">Install folder</label>
<div class="row">
<input
id="set-install-dir"
class="input"
type="text"
spellcheck="false"
autocomplete="off"
bind:value={draft.install_dir}
aria-invalid={dirty && dirError !== null}
aria-describedby="set-install-dir-help"
/>
<button type="button" class="btn" onclick={browse}>
<Icon name="folder" size={14} /> Browse
</button>
</div>
<span id="set-install-dir-help" class={dirty && dirError ? "field-error" : "field-help"}>
{dirty && dirError ? dirError : "Application, database snapshot and map tiles live here."}
</span>
</div>
</fieldset>
<section class="group" aria-labelledby="set-drive-title">
<h3 id="set-drive-title" class="eyebrow">Install from drive</h3>
<p class="text">Install or update from an ODIAN SSD without reaching the DHP. Pick the drive's root folder.</p>
<div>
<button type="button" class="btn" disabled={locked || busy} onclick={installFromDrive}>
<Icon name="drive" size={14} /> Install from drive…
</button>
</div>
</section>
<section class="group" aria-labelledby="set-account-title">
<h3 id="set-account-title" class="eyebrow">Account</h3>
{#if s?.signed_in && s.operator}
<p class="text">
Signed in as <strong>{s.operator}</strong> on {launcher.siteName}. This PC remembers you until you sign out.
</p>
{#if confirmSignOut}
<p class="text" role="alert" style="color: var(--warn)">
Signing out makes this PC forget <strong>{s.operator}</strong>: ODIAN won't launch offline until someone
signs in again while connected to the DHP.
</p>
<div style="display: flex; flex-wrap: wrap; gap: 8px">
<button type="button" class="btn btn-danger" disabled={locked || busy} onclick={signOutAndForget}>
<Icon name="signout" size={14} /> Sign out and forget me
</button>
<!-- The safe choice takes focus, so Enter right after "Sign out" keeps the operator signed in. -->
<button type="button" class="btn" use:focusOnMount onclick={() => (confirmSignOut = false)}>
Keep me signed in
</button>
</div>
{:else}
<div>
<button type="button" class="btn btn-danger" disabled={locked || busy} onclick={() => (confirmSignOut = true)}>
<Icon name="signout" size={14} /> Sign out
</button>
</div>
{/if}
{:else}
<p class="text">
{#if s?.operator}
Not signed in. Last operator: <strong>{s.operator}</strong>{s.online ? "" : " (offline)"}.
{:else}
Not signed in.
{/if}
{#if s && !s.online}Signing in needs a connection to your DHP.{/if}
</p>
{#if s?.online}
<div>
<button type="button" class="btn" disabled={locked || busy} onclick={signIn}>
<Icon name="key" size={14} /> Sign in
</button>
</div>
{/if}
{/if}
</section>
</div>
<footer class="foot">
<span class="result" role="status">
{#if saveError}
<span class="err">{saveError}</span>
{:else if saved}
<Icon name="check" size={14} /> Saved
{/if}
</span>
<button type="button" class="btn" disabled={!dirty || saving} onclick={resetDraft}>Revert</button>
<button type="submit" class="btn btn-accent" disabled={!dirty || invalid || locked || saving}>
{saving ? "Saving…" : "Save changes"}
</button>
</footer>
</form>
</dialog>
<style>
.drawer {
position: fixed;
inset: 0 0 0 auto;
width: min(440px, 100vw);
max-width: 100%;
height: 100%;
max-height: 100%;
margin: 0;
padding: 0;
border: 0;
border-left: 1px solid var(--line-strong);
background: var(--glass-strong);
color: var(--ink);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
opacity: 0;
transform: translateX(24px);
transition:
opacity var(--dur-slow) var(--ease),
transform var(--dur-slow) var(--ease),
overlay var(--dur-slow) allow-discrete,
display var(--dur-slow) allow-discrete;
}
.drawer[open] {
opacity: 1;
transform: none;
}
.drawer::backdrop {
background: rgba(5, 5, 7, 0);
transition:
background-color var(--dur-slow) var(--ease),
overlay var(--dur-slow) allow-discrete,
display var(--dur-slow) allow-discrete;
}
.drawer[open]::backdrop {
background: rgba(5, 5, 7, 0.6);
}
@starting-style {
.drawer[open] {
opacity: 0;
transform: translateX(24px);
}
.drawer[open]::backdrop {
background: rgba(5, 5, 7, 0);
}
}
.panel {
display: flex;
flex-direction: column;
height: 100%;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
padding: 0 12px 0 24px;
border-bottom: 1px solid var(--line);
}
.head h2 {
font-family: var(--font-display);
font-size: 18px;
font-weight: 600;
letter-spacing: 0.02em;
}
.content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.lock {
display: flex;
align-items: center;
gap: var(--space-2);
margin: 16px 24px 0;
padding: 8px 12px;
border: 1px solid rgba(245, 180, 84, 0.3);
border-radius: var(--radius);
background: var(--warn-soft);
color: var(--warn);
font-size: var(--text-xs);
}
.group {
display: grid;
gap: 14px;
min-width: 0;
margin: 0;
padding: 22px 24px 24px;
border: 0;
border-bottom: 1px solid var(--line);
}
.group legend {
float: left;
width: 100%;
margin: 0 0 14px;
padding: 0;
}
.group legend + * {
clear: both;
}
.row {
display: flex;
gap: var(--space-2);
}
.text {
color: var(--muted);
font-size: var(--text-sm);
line-height: 1.55;
}
.text strong {
color: var(--ink);
font-weight: 500;
}
.foot {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 12px 24px;
border-top: 1px solid var(--line);
background: rgba(9, 9, 11, 0.6);
}
.result {
display: flex;
flex: 1;
align-items: center;
gap: 6px;
min-width: 0;
color: var(--ok);
font-size: var(--text-xs);
}
.err {
overflow: hidden;
color: var(--danger);
white-space: nowrap;
text-overflow: ellipsis;
}
</style>