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,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>
|
||||
Reference in New Issue
Block a user