update
This commit is contained in:
+3
-1
@@ -4,6 +4,7 @@
|
||||
import ActivityPanel from "./components/ActivityPanel.svelte";
|
||||
import Hero from "./components/Hero.svelte";
|
||||
import KeyArt from "./components/KeyArt.svelte";
|
||||
import MarketplaceDrawer from "./components/MarketplaceDrawer.svelte";
|
||||
import NewsPanel from "./components/NewsPanel.svelte";
|
||||
import SettingsDrawer from "./components/SettingsDrawer.svelte";
|
||||
import SyncPanel from "./components/SyncPanel.svelte";
|
||||
@@ -18,7 +19,7 @@
|
||||
|
||||
<div class="shell">
|
||||
<KeyArt />
|
||||
<TitleBar onOpenSettings={() => (settingsOpen = true)} />
|
||||
<TitleBar onOpenSettings={() => (settingsOpen = true)} onOpenMarket={() => launcher.openMarket()} />
|
||||
|
||||
<main class="hub">
|
||||
<div class="news-slot"><NewsPanel /></div>
|
||||
@@ -37,6 +38,7 @@
|
||||
{/if}
|
||||
|
||||
<SettingsDrawer bind:open={settingsOpen} />
|
||||
<MarketplaceDrawer />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
{#snippet driveLink(lead: string)}
|
||||
<p class="secondary">
|
||||
{lead}
|
||||
<button class="btn-link" disabled={blocked} onclick={() => launcher.installFromDrive()}>Install from a drive…</button>
|
||||
<button class="btn-link" disabled={blocked} onclick={() => launcher.chooseDrive()}>Install from a drive…</button>
|
||||
</p>
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
| "check"
|
||||
| "signout"
|
||||
| "key"
|
||||
| "globe";
|
||||
| "globe"
|
||||
| "market";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -92,6 +93,11 @@
|
||||
<circle cx="8" cy="8" r="5.5" />
|
||||
<ellipse cx="8" cy="8" rx="2.4" ry="5.5" />
|
||||
<path d="M2.5 8h11" />
|
||||
{:else if name === "market"}
|
||||
<rect x="2.5" y="2.5" width="4.5" height="4.5" />
|
||||
<rect x="9" y="2.5" width="4.5" height="4.5" />
|
||||
<rect x="2.5" y="9" width="4.5" height="4.5" />
|
||||
<path d="M11.25 9v4.5M9 11.25h4.5" />
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { launcher } from "../lib/launcher.svelte";
|
||||
import { errorText, formatBytes } from "../lib/format";
|
||||
import type { Market, MarketItem } from "../lib/types";
|
||||
|
||||
let dialog: HTMLDialogElement | undefined = $state();
|
||||
let market = $state<Market | null>(null);
|
||||
let loading = $state(false);
|
||||
let loadError = $state<string | null>(null);
|
||||
/** The optional items ticked here, by key: the draft the operator applies. */
|
||||
let chosen = $state<string[]>([]);
|
||||
let saved = $state(false);
|
||||
let applyError = $state<string | null>(null);
|
||||
|
||||
const s = $derived(launcher.status);
|
||||
const locked = $derived(launcher.locked);
|
||||
/** Set while the panel describes a drive to install from, instead of this PC and its DHP. */
|
||||
const drive = $derived(launcher.marketDrive);
|
||||
const applying = $derived(launcher.busy === "market");
|
||||
const busy = $derived(launcher.busy !== null);
|
||||
|
||||
const required = $derived(market?.items.filter((item) => item.required) ?? []);
|
||||
const optional = $derived(market?.items.filter((item) => !item.required) ?? []);
|
||||
const picked = (item: MarketItem) => chosen.includes(item.key);
|
||||
const dirty = $derived(optional.some((item) => picked(item) !== item.selected));
|
||||
const adds = $derived(optional.filter((item) => picked(item) && item.presence !== "installed"));
|
||||
const removes = $derived(optional.filter((item) => !picked(item) && item.presence !== "absent"));
|
||||
const installed = $derived((market?.installed ?? false) && drive === null);
|
||||
const addBytes = $derived(adds.reduce((sum, item) => sum + item.bytes, 0));
|
||||
const removeBytes = $derived(removes.reduce((sum, item) => sum + item.bytes, 0));
|
||||
/** Before the first install: everything Install will download with this choice. */
|
||||
const installBytes = $derived(
|
||||
(market?.core_bytes ?? 0) +
|
||||
required.reduce((sum, item) => sum + item.bytes, 0) +
|
||||
optional.filter(picked).reduce((sum, item) => sum + item.bytes, 0),
|
||||
);
|
||||
/** A copy skips what the target already holds, byte for byte: only the rest crosses. */
|
||||
const copyBytes = $derived(
|
||||
(market?.core_bytes ?? 0) +
|
||||
(market?.items
|
||||
.filter((item) => (item.required || picked(item)) && item.presence !== "installed")
|
||||
.reduce((sum, item) => sum + item.bytes, 0) ?? 0),
|
||||
);
|
||||
const needBytes = $derived(drive !== null ? copyBytes : installed ? addBytes : installBytes);
|
||||
const tooBig = $derived(market?.free_bytes != null && needBytes > market.free_bytes);
|
||||
const offlineAdds = $derived(drive === null && installed && adds.length > 0 && !s?.online);
|
||||
const updateFirst = $derived(drive === null && installed && (s?.update_required ?? false));
|
||||
/** A stopped download is work to do even when the choice did not change. */
|
||||
const work = $derived(installed ? adds.length + removes.length > 0 : dirty);
|
||||
// A copy from a drive starts here, so a disk that cannot hold it stops the button too.
|
||||
const blocked = $derived(locked || busy || offlineAdds || updateFirst || ((installed || drive !== null) && tooBig));
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
loadError = null;
|
||||
try {
|
||||
const next = await launcher.loadMarket(drive ?? undefined);
|
||||
market = next;
|
||||
chosen = next.items.filter((item) => !item.required && item.selected).map((item) => item.key);
|
||||
} catch (e) {
|
||||
loadError = errorText(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function revert() {
|
||||
chosen = optional.filter((item) => item.selected).map((item) => item.key);
|
||||
saved = false;
|
||||
applyError = null;
|
||||
}
|
||||
|
||||
function toggle(item: MarketItem) {
|
||||
chosen = picked(item) ? chosen.filter((key) => key !== item.key) : [...chosen, item.key];
|
||||
saved = false;
|
||||
applyError = null;
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
if (blocked) return;
|
||||
// From a drive: save the choice, then copy. The progress shows in the main panel.
|
||||
if (drive !== null) {
|
||||
if (!(await launcher.setMarket([...chosen], drive))) {
|
||||
applyError = launcher.notice;
|
||||
return;
|
||||
}
|
||||
launcher.marketOpen = false;
|
||||
void launcher.installFromDrive(drive);
|
||||
return;
|
||||
}
|
||||
if (!work) return;
|
||||
const startsDownload = installed && work;
|
||||
const ok = await launcher.applyMarket([...chosen]);
|
||||
if (!ok) {
|
||||
applyError = launcher.notice;
|
||||
return;
|
||||
}
|
||||
if (startsDownload) {
|
||||
launcher.marketOpen = false;
|
||||
} else {
|
||||
saved = true;
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
function presenceLabel(item: MarketItem): string {
|
||||
if (item.presence === "installed") return "Installed";
|
||||
if (item.presence === "partial") return "Partly downloaded";
|
||||
return "Not installed";
|
||||
}
|
||||
|
||||
function changeNote(item: MarketItem): string | null {
|
||||
if (drive !== null) {
|
||||
if (!picked(item)) return item.presence === "absent" ? null : "Left as it is";
|
||||
if (item.presence === "installed") return "Already here";
|
||||
return item.presence === "partial" ? "Copy resumes" : `Copies ${formatBytes(item.bytes)}`;
|
||||
}
|
||||
if (!installed) return picked(item) ? "Installed with ODIAN" : null;
|
||||
if (picked(item) && item.presence === "partial") return "Download resumes";
|
||||
if (picked(item) && item.presence === "absent") return `Downloads ${formatBytes(item.bytes)}`;
|
||||
if (!picked(item) && item.presence !== "absent") return `Removed from this PC, frees ${formatBytes(item.bytes)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Keep the native modal in sync with the store, returning focus to whatever opened it.
|
||||
const open = $derived(launcher.marketOpen);
|
||||
let opener: HTMLElement | null = null;
|
||||
$effect(() => {
|
||||
const el = dialog;
|
||||
void drive;
|
||||
if (!el) return;
|
||||
if (open && !el.open) {
|
||||
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
saved = false;
|
||||
applyError = null;
|
||||
untrack(() => void load());
|
||||
el.showModal();
|
||||
} else if (!open) {
|
||||
if (el.open) el.close();
|
||||
if (opener?.isConnected) opener.focus();
|
||||
opener = null;
|
||||
}
|
||||
});
|
||||
|
||||
// A marketplace download that finishes while the drawer is open changes what is installed.
|
||||
let lastPhase: string | undefined;
|
||||
$effect(() => {
|
||||
const phase = s?.phase;
|
||||
if (open && lastPhase && lastPhase !== phase && phase === "idle") untrack(() => void load());
|
||||
lastPhase = phase;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const el = dialog;
|
||||
if (!el) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (e.target === el) launcher.marketOpen = false;
|
||||
};
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
launcher.marketOpen = false;
|
||||
}
|
||||
};
|
||||
el.addEventListener("click", onClick);
|
||||
el.addEventListener("keydown", onKeydown);
|
||||
return () => {
|
||||
el.removeEventListener("click", onClick);
|
||||
el.removeEventListener("keydown", onKeydown);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} class="drawer" aria-labelledby="market-title" onclose={() => (launcher.marketOpen = false)}>
|
||||
<div class="panel">
|
||||
<header class="head">
|
||||
<div class="title">
|
||||
<h2 id="market-title">{drive === null ? "Marketplace" : "Install from drive"}</h2>
|
||||
{#if market}<span class="version">ODIAN {market.version}</span>{/if}
|
||||
</div>
|
||||
<button type="button" class="icon-btn" aria-label="Close marketplace" onclick={() => (launcher.marketOpen = false)}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
{#if locked}
|
||||
<p class="note warn" role="note">
|
||||
<Icon name="alert" size={14} />
|
||||
The marketplace is locked while ODIAN is signing in, installing or running.
|
||||
</p>
|
||||
{:else if updateFirst}
|
||||
<p class="note warn" role="note">
|
||||
<Icon name="alert" size={14} />
|
||||
Update ODIAN first: the marketplace adds to the installed version.
|
||||
</p>
|
||||
{:else if offlineAdds}
|
||||
<p class="note warn" role="note">
|
||||
<Icon name="alert" size={14} />
|
||||
Your DHP is unreachable: connect to download. Removing items works offline.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if loading && !market}
|
||||
<p class="empty">Reading what your DHP offers…</p>
|
||||
{:else if loadError}
|
||||
<div class="empty">
|
||||
<p class="err-text">{loadError}</p>
|
||||
<button type="button" class="btn" onclick={() => void load()}>
|
||||
<Icon name="refresh" size={14} /> Try again
|
||||
</button>
|
||||
</div>
|
||||
{:else if market}
|
||||
{#if drive !== null}
|
||||
<p class="intro">
|
||||
Copying from <strong>{drive}</strong> into <strong>{s?.install_dir}</strong>. Only what the drive
|
||||
carries is listed, and every file is checked as it lands.
|
||||
</p>
|
||||
{:else if !installed}
|
||||
<p class="intro">
|
||||
ODIAN is not installed yet. Choose what to add: <strong>Install</strong> downloads it with the rest.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<section class="group" aria-labelledby="market-optional">
|
||||
<h3 id="market-optional" class="eyebrow">Optional</h3>
|
||||
{#if optional.length === 0}
|
||||
<p class="text">This version offers nothing optional: everything comes with the install.</p>
|
||||
{/if}
|
||||
{#each optional as item (item.key)}
|
||||
{@const note = changeNote(item)}
|
||||
<label class="card" class:picked={picked(item)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="check"
|
||||
checked={picked(item)}
|
||||
disabled={locked || applying}
|
||||
onchange={() => toggle(item)}
|
||||
/>
|
||||
<span class="body">
|
||||
<span class="line">
|
||||
<span class="name">{item.title}</span>
|
||||
<span class="size">{formatBytes(item.bytes)}</span>
|
||||
</span>
|
||||
{#if item.description}<span class="desc">{item.description}</span>{/if}
|
||||
<span class="meta">
|
||||
<span class="pill {item.presence}">{presenceLabel(item)}</span>
|
||||
{#if note}<span class="change" class:loss={!picked(item)}>{note}</span>{/if}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="group" aria-labelledby="market-included">
|
||||
<h3 id="market-included" class="eyebrow">Included with every install</h3>
|
||||
<ul class="included">
|
||||
<li>
|
||||
<span class="name">ODIAN app and database</span>
|
||||
<span class="size">{formatBytes(market.core_bytes)}</span>
|
||||
</li>
|
||||
{#each required as item (item.key)}
|
||||
<li>
|
||||
<span class="name" title={item.description}>{item.title}</span>
|
||||
<span class="size">{formatBytes(item.bytes)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer class="foot">
|
||||
<span class="summary" role="status">
|
||||
{#if applyError}
|
||||
<span class="err">{applyError}</span>
|
||||
{:else if saved}
|
||||
<span class="ok"><Icon name="check" size={14} /> Saved: Install brings your choice.</span>
|
||||
{:else if market}
|
||||
{#if drive !== null}
|
||||
Copies {formatBytes(copyBytes)}
|
||||
{:else if installed}
|
||||
{#if adds.length || removes.length}
|
||||
{#if adds.length}Downloads {formatBytes(addBytes)}{/if}{#if adds.length && removes.length} · {/if}{#if removes.length}Frees {formatBytes(removeBytes)}{/if}
|
||||
{:else}
|
||||
Nothing to change
|
||||
{/if}
|
||||
{:else}
|
||||
Install downloads {formatBytes(installBytes)}
|
||||
{/if}
|
||||
{#if market.free_bytes != null}
|
||||
<span class:err={tooBig}> · {formatBytes(market.free_bytes)} free{tooBig ? ": not enough" : ""}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
<button type="button" class="btn" disabled={!dirty || applying} onclick={revert}>Revert</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-accent"
|
||||
disabled={blocked || (drive === null && !work) || (drive !== null && market === null)}
|
||||
onclick={apply}
|
||||
>
|
||||
{#if applying}Applying…{:else if drive !== null}Install from drive{:else if installed}Apply changes{:else}Save choice{/if}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.drawer {
|
||||
position: fixed;
|
||||
inset: 0 0 0 auto;
|
||||
width: min(560px, 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);
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.head h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.version {
|
||||
color: var(--muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin: 16px 24px 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.note.warn {
|
||||
border: 1px solid rgba(245, 180, 84, 0.3);
|
||||
background: var(--warn-soft);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.intro,
|
||||
.empty {
|
||||
margin: 20px 24px 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.intro strong {
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
.empty {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.err-text {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.group {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 22px 24px 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.group .eyebrow {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.text {
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(9, 9, 11, 0.45);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease),
|
||||
background-color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--line-strong);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.card.picked {
|
||||
border-color: var(--accent-line);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.card:has(.check:disabled) {
|
||||
cursor: default;
|
||||
}
|
||||
.card:has(.check:focus-visible) {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.check {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 2px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.name {
|
||||
color: var(--ink);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 500;
|
||||
}
|
||||
.size {
|
||||
flex: none;
|
||||
color: var(--ink-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.desc {
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.pill {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
color: var(--muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.pill.installed {
|
||||
border-color: rgba(134, 239, 172, 0.28);
|
||||
background: var(--ok-soft);
|
||||
color: var(--ok);
|
||||
}
|
||||
.pill.partial {
|
||||
border-color: rgba(245, 180, 84, 0.3);
|
||||
background: var(--warn-soft);
|
||||
color: var(--warn);
|
||||
}
|
||||
.change {
|
||||
color: var(--accent-hover);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.change.loss {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.included {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.included li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.included .name {
|
||||
color: var(--ink-2);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
.summary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ok {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--ok);
|
||||
}
|
||||
.err {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -23,6 +23,9 @@
|
||||
const current = $derived(launcher.settings);
|
||||
const locked = $derived(launcher.locked);
|
||||
const saving = $derived(launcher.busy === "settings");
|
||||
/** A launcher that ships on an ODIAN drive installs on that drive to run ODIAN from it,
|
||||
* or on a disk of the PC it is plugged into to hand ODIAN over. */
|
||||
const portable = $derived(s?.portable ?? false);
|
||||
const busy = $derived(launcher.busy !== null);
|
||||
|
||||
const trimmed = $derived<Settings>({
|
||||
@@ -36,10 +39,12 @@
|
||||
trimmed.mcp_url !== current.mcp_url ||
|
||||
trimmed.install_dir !== current.install_dir),
|
||||
);
|
||||
/** A drive's launcher needs a folder on a disk: a root or a share holds no install. */
|
||||
const offDrive = $derived(portable && trimmed.install_dir !== "" && !/^[A-Za-z]:\\.+/.test(trimmed.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);
|
||||
const invalid = $derived(siteError !== null || mcpError !== null || dirError !== null || offDrive);
|
||||
|
||||
function urlProblem(value: string): string | null {
|
||||
if (!value) return "Required.";
|
||||
@@ -134,7 +139,7 @@
|
||||
|
||||
function installFromDrive() {
|
||||
open = false;
|
||||
void launcher.installFromDrive();
|
||||
void launcher.chooseDrive();
|
||||
}
|
||||
|
||||
function signIn() {
|
||||
@@ -216,14 +221,24 @@
|
||||
</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."}
|
||||
{#if offDrive}
|
||||
Give a folder on a disk, like D:\ODIAN — not a drive's root, and not a network share.
|
||||
{:else if portable}
|
||||
On the ODIAN drive this launcher runs from, ODIAN runs from the drive and follows it to the
|
||||
next PC. A folder on this PC's own disk installs ODIAN onto the PC instead.
|
||||
{:else}
|
||||
{dirty && dirError ? dirError : "Application, database snapshot and map tiles live here."}
|
||||
{/if}
|
||||
</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>
|
||||
<p class="text">
|
||||
Copy ODIAN from an ODIAN SSD into your install folder, without reaching the DHP. Pick the drive,
|
||||
then choose what to copy.
|
||||
</p>
|
||||
<div>
|
||||
<button type="button" class="btn" disabled={locked || busy} onclick={installFromDrive}>
|
||||
<Icon name="drive" size={14} /> Install from drive…
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
|
||||
interface Props {
|
||||
onOpenSettings: () => void;
|
||||
onOpenMarket: () => void;
|
||||
}
|
||||
|
||||
let { onOpenSettings }: Props = $props();
|
||||
let { onOpenSettings, onOpenMarket }: Props = $props();
|
||||
|
||||
let maximized = $state(false);
|
||||
|
||||
@@ -81,6 +82,10 @@
|
||||
|
||||
<div class="drag" data-tauri-drag-region></div>
|
||||
|
||||
<button class="icon-btn market" aria-label="Marketplace" title="Marketplace" aria-haspopup="dialog" onclick={onOpenMarket}>
|
||||
<Icon name="market" />
|
||||
</button>
|
||||
|
||||
<button class="icon-btn settings" aria-label="Settings" title="Settings" aria-haspopup="dialog" onclick={onOpenSettings}>
|
||||
<Icon name="settings" />
|
||||
</button>
|
||||
@@ -223,6 +228,10 @@
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.market {
|
||||
margin-right: var(--space-1);
|
||||
}
|
||||
|
||||
.settings {
|
||||
margin-right: var(--space-2);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
Backend,
|
||||
InstallProgress,
|
||||
LogEvent,
|
||||
Market,
|
||||
Settings,
|
||||
SignInResult,
|
||||
StackEvent,
|
||||
@@ -47,6 +48,9 @@ function createTauriBackend(): Backend {
|
||||
getStack: () => invoke<StackEvent[]>("get_stack"),
|
||||
startInstall: () => invoke<void>("start_install"),
|
||||
startInstallFromDrive: (path) => invoke<void>("start_install_from_drive", { path }),
|
||||
getMarket: (drive) => invoke<Market>("get_market", drive === undefined ? {} : { drive }),
|
||||
applyMarket: (selected) => invoke<void>("apply_market", { selected }),
|
||||
setMarket: (selected, drive) => invoke<void>("set_market", drive === undefined ? { selected } : { selected, drive }),
|
||||
cancelInstall: () => invoke<void>("cancel_install"),
|
||||
launch: () => invoke<void>("launch"),
|
||||
stop: () => invoke<void>("stop"),
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
InstallProgress,
|
||||
LogEvent,
|
||||
LogLevel,
|
||||
Market,
|
||||
Phase,
|
||||
Settings,
|
||||
StackComponent,
|
||||
@@ -112,6 +113,9 @@ class Launcher {
|
||||
stopping = $state(false);
|
||||
/** The sync review panel. */
|
||||
syncOpen = $state(false);
|
||||
/** The marketplace panel, and the drive it describes (null: this PC and its DHP). */
|
||||
marketOpen = $state(false);
|
||||
marketDrive = $state<string | null>(null);
|
||||
/** Decisions waiting in this PC's database; null until the panel loads them. */
|
||||
conflicts = $state<SyncConflict[] | null>(null);
|
||||
notes = $state<NotesState>({
|
||||
@@ -289,6 +293,38 @@ class Launcher {
|
||||
}
|
||||
}
|
||||
|
||||
/** The marketplace listing, of this PC's DHP or of a drive. Throws, so the drawer says why. */
|
||||
loadMarket(drive?: string): Promise<Market> {
|
||||
return backend.getMarket(drive);
|
||||
}
|
||||
|
||||
openMarket() {
|
||||
this.marketDrive = null;
|
||||
this.marketOpen = true;
|
||||
}
|
||||
|
||||
/** Installing from a drive starts by looking at what the drive carries. */
|
||||
async chooseDrive() {
|
||||
const path = await this.pickFolder();
|
||||
if (!path) return;
|
||||
this.marketDrive = path;
|
||||
this.marketOpen = true;
|
||||
}
|
||||
|
||||
/** Choose what an install about to run will bring; nothing installed is touched. */
|
||||
setMarket(selected: string[], drive?: string) {
|
||||
return this.run("market", () => backend.setMarket(selected, drive));
|
||||
}
|
||||
|
||||
/** Save the choice of optional items and apply it (downloads show as an install). */
|
||||
applyMarket(selected: string[]) {
|
||||
return this.run("market", async () => {
|
||||
this.lastOp = null;
|
||||
await backend.applyMarket(selected);
|
||||
this.applyStatus(await backend.getStatus());
|
||||
});
|
||||
}
|
||||
|
||||
install() {
|
||||
return this.run("install", async () => {
|
||||
this.lastOp = { kind: "install" };
|
||||
@@ -296,9 +332,11 @@ class Launcher {
|
||||
});
|
||||
}
|
||||
|
||||
async installFromDrive() {
|
||||
const path = await this.pickFolder();
|
||||
if (!path) return false;
|
||||
/**
|
||||
* Installing from a drive is a copy the operator chooses first: the marketplace opens on what
|
||||
* the drive carries, and starts this once they have picked.
|
||||
*/
|
||||
async installFromDrive(path: string) {
|
||||
return this.run("install", async () => {
|
||||
this.lastOp = { kind: "drive", path };
|
||||
await backend.startInstallFromDrive(path);
|
||||
|
||||
+100
@@ -18,6 +18,8 @@ import {
|
||||
type InstallStage,
|
||||
type LogEvent,
|
||||
type LogLevel,
|
||||
type Market,
|
||||
type MarketItem,
|
||||
type MockControls,
|
||||
type MockScenario,
|
||||
type Settings,
|
||||
@@ -182,6 +184,25 @@ type Listeners = { [K in Channel]: Set<(payload: Payloads[K]) => void> };
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
const busy = "Busy: wait for the current task to finish.";
|
||||
|
||||
/** The 0.61.0 catalogue with real sizes: what the marketplace lists in the browser mock. */
|
||||
const MOCK_MARKET: Omit<MarketItem, "presence" | "selected">[] = [
|
||||
{ key: "tiles:dark/protomaps_20260423", kind: "tiles", title: "Dark map", bytes: 2_437_523_419, required: true,
|
||||
description: "The standard ODIAN basemap: roads, water and borders in the dark style, as pre-drawn at the office. Every install carries it." },
|
||||
{ key: "tiles:dark_v2/protomaps_20260423", kind: "tiles", title: "Dark V2 map", bytes: 1_649_512_829, required: false,
|
||||
description: "The newer dark basemap, with buildings and forests. It is the Theatre of Operations' default ground: without it (or the Map source) the Theatre shows relief shading until Dark is picked." },
|
||||
{ key: "tiles:satellite_hd/protomaps_20260423", kind: "tiles", title: "World satellite imagery", bytes: 5_660_999_147, required: false,
|
||||
description: "Photographic basemap of the whole world, for the map and the Theatre of Operations. It covers what the office pre-cached: this PC cannot fetch more imagery offline." },
|
||||
{ key: "tiles:orthophotos/protomaps_20260423", kind: "tiles", title: "France orthophotos", bytes: 6_713_284_879, required: false,
|
||||
description: "High-resolution aerial photographs of France, much sharper than the world imagery over French territory." },
|
||||
{ key: "tiles:heightmap/planet_heightmap", kind: "tiles", title: "Terrain elevation", bytes: 74_367_050_377, required: false,
|
||||
description: "The world height map: real 3D relief for the globe and the Theatre of Operations, and ground elevation wherever ODIAN reads it. Without it the ground is flat." },
|
||||
{ key: "src:osm_source/world_aeroway_v1.sqlite", kind: "src", title: "Aeroway source data (OSM)", bytes: 884_244_480, required: false,
|
||||
description: "Raw OpenStreetMap airports and runways. ODIAN reads aeroways from its own database, so this is only needed to rebuild that layer." },
|
||||
{ key: "src:protomaps_20260423.pmtiles", kind: "src", title: "Map source (world, vector)", bytes: 135_176_708_563, required: false,
|
||||
description: "The vector map data the Dark and Dark V2 basemaps are drawn from. With it this PC draws any tile it lacks, anywhere and at any zoom: the detailed ground the Theatre of Operations needs offline. Without it only the pre-drawn tiles show." },
|
||||
];
|
||||
const MOCK_CORE_BYTES = 2_275_409_041 + 15_356_491_648;
|
||||
|
||||
function pick<T extends string>(value: string | null, allowed: readonly T[]): T | null {
|
||||
return value !== null && (allowed as readonly string[]).includes(value) ? (value as T) : null;
|
||||
}
|
||||
@@ -212,6 +233,7 @@ function initialStatus(scenario: MockScenario): Status {
|
||||
phase: "idle",
|
||||
error: null,
|
||||
install_dir: DEFAULT_DIR,
|
||||
portable: false,
|
||||
sync: null,
|
||||
live: null,
|
||||
};
|
||||
@@ -276,6 +298,9 @@ class MockBackend implements Backend {
|
||||
private signInCancelled = false;
|
||||
/** What waits for a decision in the mock "local database". */
|
||||
private conflicts: SyncConflict[];
|
||||
/** Marketplace: the optional items chosen, and what is on the mock PC. */
|
||||
private marketChosen = new Set<string>();
|
||||
private readonly marketPresent = new Map<string, MarketItem["presence"]>();
|
||||
/** Latest event per component, as `get_stack` reports it. */
|
||||
private readonly stackStates = new Map<StackComponent, StackEvent>();
|
||||
private readonly listeners: Listeners = {
|
||||
@@ -295,6 +320,13 @@ class MockBackend implements Backend {
|
||||
this.conflicts = scenario === "sync-conflicts" ? structuredClone(MOCK_CONFLICTS) : [];
|
||||
this.network = this.status.online;
|
||||
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, install_dir: DEFAULT_DIR };
|
||||
if (this.status.installed) {
|
||||
// An installed mock PC has the basemap, Dark V2 and a stopped satellite download.
|
||||
for (const item of MOCK_MARKET) if (item.required) this.marketPresent.set(item.key, "installed");
|
||||
this.marketPresent.set("tiles:dark_v2/protomaps_20260423", "installed");
|
||||
this.marketPresent.set("tiles:satellite_hd/protomaps_20260423", "partial");
|
||||
this.marketChosen = new Set(["tiles:dark_v2/protomaps_20260423", "tiles:satellite_hd/protomaps_20260423"]);
|
||||
}
|
||||
|
||||
const self = this;
|
||||
this.mock = {
|
||||
@@ -476,6 +508,74 @@ class MockBackend implements Backend {
|
||||
void this.runInstall(kind, "drive", path);
|
||||
}
|
||||
|
||||
async getMarket(drive?: string): Promise<Market> {
|
||||
await this.wait(250);
|
||||
if (drive !== undefined && !drive.trim()) throw "Pick the root folder of an ODIAN drive.";
|
||||
if (drive === undefined && !this.status.installed && !this.status.online) {
|
||||
throw "Connect to your DHP to see what it offers.";
|
||||
}
|
||||
return {
|
||||
version: this.status.installed ?? this.status.available ?? CURRENT,
|
||||
installed: this.status.installed !== null,
|
||||
core_bytes: MOCK_CORE_BYTES,
|
||||
items: MOCK_MARKET.map((item) => ({
|
||||
...item,
|
||||
presence: this.marketPresent.get(item.key) ?? "absent",
|
||||
selected: item.required || this.marketChosen.has(item.key),
|
||||
})),
|
||||
free_bytes: 297_100_000_000,
|
||||
};
|
||||
}
|
||||
|
||||
async setMarket(selected: string[], _drive?: string): Promise<void> {
|
||||
const optional = new Set(MOCK_MARKET.filter((item) => !item.required).map((item) => item.key));
|
||||
this.marketChosen = new Set(selected.filter((key) => optional.has(key)));
|
||||
}
|
||||
|
||||
async applyMarket(selected: string[]): Promise<void> {
|
||||
const s = this.status;
|
||||
if (s.phase !== "idle" && s.phase !== "error") throw busy;
|
||||
const optional = new Set(MOCK_MARKET.filter((item) => !item.required).map((item) => item.key));
|
||||
this.marketChosen = new Set(selected.filter((key) => optional.has(key)));
|
||||
if (!s.installed) return;
|
||||
for (const item of MOCK_MARKET) {
|
||||
if (!item.required && !this.marketChosen.has(item.key) && this.marketPresent.has(item.key)) {
|
||||
this.marketPresent.delete(item.key);
|
||||
this.log("info", `Removed ${item.title}.`);
|
||||
}
|
||||
}
|
||||
const adds = MOCK_MARKET.filter((item) => this.marketChosen.has(item.key) && this.marketPresent.get(item.key) !== "installed");
|
||||
if (adds.length === 0) return;
|
||||
if (!s.online) throw "Your DHP is unreachable: connect to download marketplace items.";
|
||||
void this.runMarketDownload(adds);
|
||||
}
|
||||
|
||||
private async runMarketDownload(adds: typeof MOCK_MARKET) {
|
||||
const job = ++this.job;
|
||||
this.commit({ phase: "installing", error: null });
|
||||
this.log("info", `Downloading ${adds.map((item) => item.title).join(", ")}`);
|
||||
const total = adds.reduce((sum, item) => sum + item.bytes, 0);
|
||||
const ticks = 30;
|
||||
for (let t = 0; t <= ticks; t++) {
|
||||
if (t > 0) await this.wait(100);
|
||||
if (job !== this.job) return;
|
||||
this.emit("install-progress", {
|
||||
stage: "tiles",
|
||||
stage_index: 2,
|
||||
stage_count: 2,
|
||||
done_bytes: Math.round((total * t) / ticks),
|
||||
total_bytes: total,
|
||||
done_items: t,
|
||||
total_items: ticks,
|
||||
bytes_per_sec: t < ticks ? 180_000_000 : 0,
|
||||
message: `tiles ${adds[0].title}: ${t}/${ticks} blocks`,
|
||||
});
|
||||
}
|
||||
for (const item of adds) this.marketPresent.set(item.key, "installed");
|
||||
this.commit({ phase: "idle" });
|
||||
this.log("info", "The marketplace items are installed.");
|
||||
}
|
||||
|
||||
async cancelInstall(): Promise<void> {
|
||||
if (this.status.phase === "restoring") throw "The database restore cannot be cancelled once it has started.";
|
||||
if (this.status.phase !== "installing") throw "Nothing is installing.";
|
||||
|
||||
@@ -38,6 +38,11 @@ export interface Status {
|
||||
error: string | null;
|
||||
/** e.g. "D:\\ODIAN". */
|
||||
install_dir: string;
|
||||
/**
|
||||
* This launcher ships on an ODIAN drive: it runs the install on the drive it was started
|
||||
* from (whatever letter it gets), the folder cannot be changed, and it never self-updates.
|
||||
*/
|
||||
portable: boolean;
|
||||
/** Operator-data sync counts from the last sync this session; null before one. */
|
||||
sync: SyncStatus | null;
|
||||
/**
|
||||
@@ -47,6 +52,36 @@ export interface Status {
|
||||
live: boolean | null;
|
||||
}
|
||||
|
||||
/** A marketplace item: one tile set or source file of the version. */
|
||||
export interface MarketItem {
|
||||
/** Stable across versions: `tiles:<style>/<dataset>` or `src:<path>`. */
|
||||
key: string;
|
||||
kind: "tiles" | "src";
|
||||
title: string;
|
||||
description: string;
|
||||
/** What installing it downloads, in full. */
|
||||
bytes: number;
|
||||
/** Part of every install: shown, never offered for removal. */
|
||||
required: boolean;
|
||||
/** installed: all of it is on this PC; partial: a download that stopped (installing resumes). */
|
||||
presence: "installed" | "partial" | "absent";
|
||||
/** Chosen: installed with the version and kept by updates (required items always are). */
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export interface Market {
|
||||
/** The version the items belong to: the installed one, else the DHP's current one. */
|
||||
version: string;
|
||||
/** False before the first install: a choice then waits for Install. */
|
||||
installed: boolean;
|
||||
/** The app and database: what every install downloads besides the items. */
|
||||
core_bytes: number;
|
||||
/** Required items first. */
|
||||
items: MarketItem[];
|
||||
/** Free space where ODIAN is (or will be) installed; null when unknown. */
|
||||
free_bytes: number | null;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
/** Local edits the DHP has not taken yet. */
|
||||
pending: number;
|
||||
@@ -184,6 +219,15 @@ export interface Backend {
|
||||
openExternal(url: string): Promise<void>;
|
||||
startInstall(): Promise<void>;
|
||||
startInstallFromDrive(path: string): Promise<void>;
|
||||
/** The marketplace: every tile set and source file of the version, or of a drive. */
|
||||
getMarket(drive?: string): Promise<Market>;
|
||||
/**
|
||||
* Choose the optional items (keys) and apply it: unchosen ones leave this PC, chosen missing
|
||||
* ones download (install-progress follows). Before the first install it only saves the choice.
|
||||
*/
|
||||
applyMarket(selected: string[]): Promise<void>;
|
||||
/** Choose the optional items an install about to run will bring; changes nothing installed. */
|
||||
setMarket(selected: string[], drive?: string): Promise<void>;
|
||||
cancelInstall(): Promise<void>;
|
||||
launch(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user