This commit is contained in:
Kelorastus
2026-09-28 11:19:02 +02:00
parent 21d6e6bffb
commit 73a8f2c30c
35 changed files with 1615 additions and 86 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 29.5.1, SVG Export Plug-In . SVG Version: 9.03 Build 0) -->
<svg version="1.1" id="Calque_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="256px" height="256px" viewBox="0 0 256 256" enable-background="new 0 0 256 256" xml:space="preserve">
<g>
<g>
<g>
<path id="SVGID_00000111182432350397502420000010888337569888139697_" fill="#DFDFDF" d="M114.311,61.703l-0.025,7.156
c-0.004,1.106-0.745,2.058-1.804,2.376c-11.176,3.361-20.925,10.066-28.086,18.94c-0.424,0.52-0.835,1.052-1.234,1.597
c-0.112,0.136-0.224,0.285-0.324,0.433c-0.549,0.731-1.073,1.474-1.572,2.241c-3.14,4.714-5.594,9.924-7.227,15.477
c-0.315,1.073-1.278,1.822-2.396,1.818l-7.516-0.026c-1.378-0.005-2.479-1.126-2.474-2.491l0.05-14.61
c0.005-1.371,1.12-2.479,2.491-2.474l8.788,0.03c2.818,0.01,5.354-1.124,7.21-2.954c1.732-1.732,2.844-4.074,2.99-6.681
l0.072-20.941c0.005-1.371,1.12-2.479,2.491-2.474l26.092,0.089C113.208,59.216,114.315,60.331,114.311,61.703z"/>
</g>
</g>
<path fill="#DFDFDF" d="M114.328,180.693v7.515c0,1.371-1.112,2.483-2.483,2.483H85.741c-1.365,0-2.483-1.105-2.483-2.483v-18.396
c0-2.805-1.142-5.363-2.979-7.2c-1.85-1.837-4.394-2.979-7.212-2.979h-20.42c-1.371,0-2.483-1.112-2.483-2.483v-24.293
c-0.05-1.167-0.546-2.222-1.328-3.004c-0.695-0.708-1.626-1.179-2.669-1.303H40.32c-1.371,0-2.483-1.112-2.483-2.483v-7.349
c0-1.365,1.105-2.483,2.483-2.483h7.349c1.365,0,2.483,1.117,2.483,2.483l0.012,5.213c0,1.279,0.509,2.433,1.353,3.277
c0.782,0.794,1.837,1.303,3.029,1.353h15.31c1.236,0,2.303,0.907,2.457,2.133c1.107,8.833,4.231,17.044,8.909,24.158
c1.092,1.651,2.259,3.252,3.513,4.779c7.114,8.728,16.754,15.321,27.795,18.677C113.59,178.631,114.328,179.586,114.328,180.693z"
/>
<g>
<g>
<path id="SVGID_00000094603966042395717880000002472666982323682744_" fill="#2474E8" d="M207.917,124.705
c0,43.256-34.364,78.65-77.226,80.288c-1.69,0.065-3.106-1.265-3.106-2.956v-19.229c0-1.562,1.217-2.864,2.777-2.942
c29.166-1.452,52.438-25.64,52.438-55.161c0-29.52-23.272-53.695-52.438-55.146c-1.56-0.078-2.777-1.38-2.777-2.942V47.388
c0-1.691,1.417-3.021,3.106-2.956C173.553,46.069,207.917,81.449,207.917,124.705z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+70
View File
@@ -62,6 +62,10 @@
return { label: "Restoring database", tone: "accent", pulse: true, meta: stage };
case "syncing":
return { label: "Syncing with the DHP", tone: "accent", pulse: true, meta: version(s?.installed) };
case "pulling_tiles":
return { label: "Taking map tiles", tone: "accent", pulse: true, meta: tileMeta };
case "removing":
return { label: "Removing", tone: "accent", pulse: true, meta: rp ? `${rp.index}/${rp.count}` : null };
case "update":
return { label: "Update required", tone: "warn", pulse: false, meta: `${s?.installed} → ${s?.available}` };
case "launch":
@@ -76,6 +80,29 @@
}
});
// What the DHP has baked, for the sets this PC holds, since it last looked. Zero means
// level with the site - which is worth saying nothing at all about.
// Chosen, and not all here. ODIAN runs without it, so this is said beside Launch rather
// than in place of it - but it IS said: the marketplace was the only place that knew.
const unfinished = $derived(s?.unfinished ?? null);
const unfinishedNames = $derived.by(() => {
const titles = unfinished?.titles ?? [];
if (titles.length === 0) return "";
const shown = titles.slice(0, 2).join(", ");
return (unfinished?.items ?? 0) > titles.length || titles.length > 2 ? `${shown}…` : shown;
});
const tiles = $derived(s?.tiles_waiting ?? null);
const tilesOffered = $derived(tiles !== null && tiles.tiles > 0);
const tilesCount = $derived(tiles === null ? "" : `${formatCount(tiles.tiles)}${tiles.more ? "+" : ""}`);
const rp = $derived(launcher.removeProgress);
const removeFraction = $derived(rp === null || rp.total === 0 ? null : Math.min(rp.done / rp.total, 1));
const tp = $derived(launcher.tileProgress);
const tileMeta = $derived(tp === null ? null : `${formatCount(tp.tiles)} tiles`);
const tileFraction = $derived(tp === null || tp.page_tiles === 0 ? null : tp.page_done / tp.page_tiles);
const showNotice = $derived(launcher.notice !== null && !(action === "error" && launcher.notice === s?.error));
</script>
@@ -182,6 +209,29 @@
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
</p>
{/if}
{#if unfinished}
<p class="secondary attention">
<Icon name="alert" size={14} />
<span>
<strong>{unfinished.items}</strong>
{unfinished.items === 1 ? "chosen map is" : "chosen maps are"} only partly downloaded
{#if unfinishedNames}<span class="dim">({unfinishedNames})</span>{/if}.
<button class="btn-link" disabled={blocked} onclick={() => launcher.finishMarket()}>Finish</button>
</span>
</p>
{/if}
{#if tilesOffered}
<!-- Ground another operator looked at while this PC was away. Online it is already
on screen, drawn from {site}; nothing keeps it, so unplugging loses a week of
other people's map. -->
<p class="secondary attention">
<Icon name="download" size={14} />
<span>
<strong>{tilesCount}</strong> new map tiles on {site} ({formatBytes(tiles?.bytes ?? 0)}).
<button class="btn-link" disabled={blocked} onclick={() => launcher.syncTiles()}>Take them</button>
</span>
</p>
{/if}
{:else if action === "installing"}
<div class="progress">
<div class="stage-line">
@@ -236,6 +286,26 @@
<ProgressBar value={null} label="Syncing with the DHP" />
</div>
<p class="secondary">Sending this PC's changes to {site} and taking in the other operators' work.</p>
{:else if action === "removing"}
<div class="progress">
<div class="stage-line">
<span class="stage-name">Removing {rp?.title ?? "what you unticked"}</span>
{#if removeFraction !== null}<span class="pct mono tabular">{Math.floor(removeFraction * 100)}%</span>{/if}
</div>
<ProgressBar value={removeFraction} label="Removing" />
{#if rp}<p class="message mono">{formatCount(rp.done)} / {formatCount(rp.total)} files</p>{/if}
</div>
<p class="secondary">Deleting a map is hundreds of thousands of files. Nothing is downloaded meanwhile.</p>
{:else if action === "pulling_tiles"}
<div class="progress">
<div class="stage-line">
<span class="stage-name">Taking map tiles from {site}</span>
{#if tp}<span class="pct mono tabular">{formatBytes(tp.bytes)}</span>{/if}
</div>
<ProgressBar value={tileFraction} label="Taking map tiles" />
</div>
<p class="secondary">Tiles the other operators baked while this PC was away. It can be stopped and resumed.</p>
<button class="btn wide" onclick={() => launcher.cancelInstall()}>Stop</button>
{:else if action === "stack"}
<StackList stack={launcher.stack} phase={s?.phase ?? "launching"} />
{#if s?.map_from_site === false}
+214 -19
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { untrack } from "svelte";
import Icon from "./Icon.svelte";
import { backend } from "../lib/backend";
import { launcher } from "../lib/launcher.svelte";
import { errorText, formatBytes } from "../lib/format";
import type { Market, MarketItem } from "../lib/types";
import { errorText, formatBytes, formatCount } from "../lib/format";
import type { Market, MarketCheck, MarketItem } from "../lib/types";
let dialog: HTMLDialogElement | undefined = $state();
let market = $state<Market | null>(null);
@@ -11,20 +12,55 @@
let loadError = $state<string | null>(null);
/** The optional items ticked here, by key: the draft the operator applies. */
let chosen = $state<string[]>([]);
/** What a count of the files on disk found, once it has been taken. */
let checks = $state<Record<string, MarketCheck> | null>(null);
let saved = $state(false);
let applyError = $state<string | null>(null);
/** Apply is waiting on a yes: it would delete something. */
let confirming = $state(false);
let cancelButton: HTMLButtonElement | undefined = $state();
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 siteName = $derived(launcher.siteName);
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));
/**
* What is REALLY there, once counted.
*
* An item's badge comes from the installer's own marker - its note that it finished. Any
* emptying of the folder afterwards leaves that note behind, and the marketplace goes on
* calling it installed. The count is a walk of hundreds of thousands of files, far too
* slow to open on, so the drawer opens on the marker and corrects itself when the count
* comes back.
*/
const shortfall = (item: MarketItem): MarketCheck | null => {
const check = checks?.[item.key];
return check && !check.whole ? check : null;
};
const counting = $derived(checks === null && market !== null && drive === null);
/** What the badge should say, marker and disk together. */
const presenceOf = (item: MarketItem) =>
item.presence === "installed" && shortfall(item) !== null ? "partial" : item.presence;
/**
* What this PC has settled on: what the operator chose, PLUS whatever is actually here.
*
* `selected` answers only "did the operator ask for this", and an item can be installed
* without ever having been asked for - required by the version that installed it (Dark,
* until Dark V2 took its place), taken off a drive, or never downloadable at all. Opening
* on `selected` alone shows those unticked though installed, which this panel states as
* "removed from this PC, frees N GB" and acts on at the next Apply. Nobody asked for that.
*
* It is the OPENING position, not a veto: unticking works exactly as before.
*/
const held = (item: MarketItem) => item.selected || item.presence === "installed";
const dirty = $derived(optional.some((item) => picked(item) !== held(item)));
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);
@@ -49,6 +85,8 @@
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);
/** Removed items no download can bring back: only an ODIAN drive carries them. */
const gone = $derived(removes.filter((item) => !item.on_dhp));
// 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));
@@ -58,7 +96,9 @@
try {
const next = await launcher.loadMarket(drive ?? undefined);
market = next;
chosen = next.items.filter((item) => !item.required && item.selected).map((item) => item.key);
chosen = next.items.filter((item) => !item.required && held(item)).map((item) => item.key);
// Not awaited: the list is usable at once, and the badges settle when the count lands.
if (drive === null) void count();
} catch (e) {
loadError = errorText(e);
} finally {
@@ -66,20 +106,69 @@
}
}
/** Weigh what the markers claim. Slow, so it runs behind the open list. */
async function count() {
checks = null;
try {
const found = await backend.checkMarket();
checks = Object.fromEntries(found.map((check) => [check.key, check]));
} catch {
// A count that cannot be taken leaves the markers' word standing, which is what the
// marketplace showed before there was a count at all.
checks = {};
}
}
function revert() {
chosen = optional.filter((item) => item.selected).map((item) => item.key);
chosen = optional.filter((item) => held(item)).map((item) => item.key);
saved = false;
applyError = null;
confirming = false;
}
function toggle(item: MarketItem) {
chosen = picked(item) ? chosen.filter((key) => key !== item.key) : [...chosen, item.key];
saved = false;
applyError = null;
// The question was asked about a list that no longer holds.
confirming = false;
}
// The question replaces the button that asked it, so focus would fall to the document
// and Enter would do nothing at all. It lands on Cancel: the safe answer, and the one a
// keyboard reaches first.
$effect(() => {
if (confirming) cancelButton?.focus();
});
// Escape while the question is up answers "no", and nothing more. Left alone it closes
// the whole drawer - which looks exactly like the removal having been accepted, on the
// one screen where that guess costs 705 GB. Taken on the KEYDOWN rather than the dialog's
// `cancel` event: closing on Escape is that key's default action, and `cancel` does not
// bubble, so a handler on it is the less certain of the two.
$effect(() => {
const el = dialog;
if (!el) return;
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape" || !confirming) return;
event.preventDefault();
event.stopPropagation();
confirming = false;
};
el.addEventListener("keydown", onKey, true);
return () => el.removeEventListener("keydown", onKey, true);
});
async function apply() {
if (blocked) return;
// Deleting is the one thing here that cannot be taken back by pressing the button
// again: a re-download is hours, and for an item the DHP does not carry there is no
// re-download at all. Adding needs no ceremony, so only a removal is asked about.
if (drive === null && installed && removes.length > 0 && !confirming) {
confirming = true;
return;
}
confirming = false;
// From a drive: save the choice, then copy. The progress shows in the main panel.
if (drive !== null) {
if (!(await launcher.setMarket([...chosen], drive))) {
@@ -107,17 +196,29 @@
/** True while the DHP would draw this item itself, so the PC lacking it shows nothing. */
const drawnBySite = (item: MarketItem) =>
item.presence === "absent" && drive === null && (s?.online ?? false);
item.presence === "absent" && drive === null && (s?.online ?? false) && item.on_dhp;
// Listed, but the DHP has not got it: the terrain source is 705 GB and comes on a drive.
// Only true in the DHP's own marketplace - a drive that carries it is offering it - and
// only while this PC does NOT hold it. Once it is here, where it came from is history:
// saying "not on the DHP" beside "Installed" reads as a fault instead of a fact.
const offDhp = (item: MarketItem) => !item.on_dhp && drive === null && item.presence !== "installed";
function presenceLabel(item: MarketItem): string {
if (item.presence === "installed") return "Installed";
if (item.presence === "partial") return "Partly downloaded";
const presence = presenceOf(item);
if (presence === "installed") return counting ? "Installed, checking…" : "Installed";
if (presence === "partial") return "Partly downloaded";
// "Not installed" would read as missing when nothing is missing: while the DHP
// answers it draws this itself. What this PC lacks is the copy that works offline.
return drawnBySite(item) ? "On the DHP" : "Not installed";
}
function presenceHelp(item: MarketItem): string | undefined {
const short = shortfall(item);
if (short) {
return `Its marker says it is installed, but only ${formatCount(short.found)} of ${formatCount(short.expected)} files are on this PC. Re-applying downloads the rest.`;
}
if (offDhp(item)) return "Your DHP does not carry this one. It is installed from an ODIAN drive.";
if (item.presence !== "absent" || drive !== null) return undefined;
return drawnBySite(item)
? "Your DHP draws this while you are connected. Download it to keep it when you are not."
@@ -249,7 +350,7 @@
type="checkbox"
class="check"
checked={picked(item)}
disabled={locked || applying}
disabled={locked || applying || offDhp(item)}
onchange={() => toggle(item)}
/>
<span class="body">
@@ -259,9 +360,12 @@
</span>
{#if item.description}<span class="desc">{item.description}</span>{/if}
<span class="meta">
<span class="pill {item.presence}" class:from-site={drawnBySite(item)} title={presenceHelp(item)}>
<span class="pill {presenceOf(item)}" class:from-site={drawnBySite(item)} title={presenceHelp(item)}>
{presenceLabel(item)}
</span>
{#if offDhp(item)}
<span class="pill off-dhp" title={presenceHelp(item)}>Not on the DHP</span>
{/if}
{#if note}<span class="change" class:loss={!picked(item)}>{note}</span>{/if}
</span>
</span>
@@ -287,6 +391,39 @@
{/if}
</div>
{#if confirming}
<section class="confirm" role="alert">
<h3 class="confirm-head">
<Icon name="alert" size={15} />
Remove {removes.length === 1 ? "this" : `these ${removes.length}`} from this PC?
</h3>
<ul class="confirm-list">
{#each removes as item (item.key)}
<li>
<span class="name">{item.title}</span>
<span class="size">{formatBytes(item.bytes)}</span>
{#if !item.on_dhp}<span class="pill off-dhp">Only from a drive</span>{/if}
</li>
{/each}
</ul>
<p class="confirm-note">
Their files are deleted from this PC, freeing {formatBytes(removeBytes)}.
{#if gone.length}
<strong>
{gone.length === 1 ? `${gone[0].title} is not on ${siteName}` : `${gone.length} of them are not on ${siteName}`}:
once removed, only an ODIAN drive can bring {gone.length === 1 ? "it" : "them"} back.
</strong>
{:else}
Taking {removes.length === 1 ? "it" : "them"} again later means downloading
{formatBytes(removeBytes)} from {siteName}.
{/if}
{#if adds.length}
&ensp;·&ensp;{adds.length === 1 ? "One item" : `${adds.length} items`} will be downloaded in the same pass.
{/if}
</p>
</section>
{/if}
<footer class="foot">
<span class="summary" role="status">
{#if applyError}
@@ -310,15 +447,28 @@
{/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>
{#if confirming}
<button
type="button"
class="btn"
bind:this={cancelButton}
disabled={applying}
onclick={() => (confirming = false)}>Cancel</button
>
<button type="button" class="btn btn-danger" disabled={blocked} onclick={apply}>
{applying ? "Removing…" : `Remove ${removes.length === 1 ? "it" : "them"}`}
</button>
{:else}
<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>
{/if}
</footer>
</div>
</dialog>
@@ -552,6 +702,11 @@
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
color: var(--accent);
}
/* The DHP has not got this one. Not a fault either - it comes on a drive - so it is
stated flatly rather than coloured like something that went wrong. */
.pill.off-dhp {
border-style: dashed;
}
.change {
color: var(--accent-hover);
font-size: var(--text-xs);
@@ -579,6 +734,46 @@
font-weight: 400;
}
/* The question, shown where the answer is given: directly above the two buttons. */
.confirm {
padding: 14px 24px;
border-top: 1px solid color-mix(in srgb, var(--danger) 35%, transparent);
background: color-mix(in srgb, var(--danger) 8%, transparent);
}
.confirm-head {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px;
color: var(--danger);
font-size: var(--text-sm);
}
.confirm-list {
margin: 0 0 8px;
padding: 0;
list-style: none;
display: grid;
gap: 4px;
}
.confirm-list li {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--text-sm);
}
.confirm-list .size {
color: var(--muted);
font-family: var(--font-mono);
}
.confirm-note {
margin: 0;
color: var(--muted);
font-size: var(--text-xs);
line-height: 1.5;
}
.confirm-note strong {
color: var(--danger);
}
.foot {
display: flex;
align-items: center;
+8
View File
@@ -10,11 +10,14 @@ import type {
InstallProgress,
LogEvent,
Market,
MarketCheck,
Settings,
SignInResult,
StackEvent,
Status,
RemoveProgress,
SyncConflict,
TileSyncProgress,
Unlisten,
} from "./types";
@@ -50,6 +53,8 @@ function createTauriBackend(): Backend {
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 }),
checkMarket: () => invoke<MarketCheck[]>("check_market"),
finishMarket: () => invoke<void>("finish_market"),
setMarket: (selected, drive) => invoke<void>("set_market", drive === undefined ? { selected } : { selected, drive }),
cancelInstall: () => invoke<void>("cancel_install"),
launch: () => invoke<void>("launch"),
@@ -58,12 +63,15 @@ function createTauriBackend(): Backend {
setSettings: (settings) => invoke<Status>("set_settings", { settings }),
pickFolder: () => invoke<string | null>("pick_folder"),
syncNow: () => invoke<void>("sync_now"),
syncTiles: () => invoke<void>("sync_tiles"),
getSyncConflicts: () => invoke<SyncConflict[]>("get_sync_conflicts"),
resolveSyncConflict: (tbl, key, choice, value) =>
invoke<SyncConflict[]>("resolve_sync_conflict", { tbl, key, choice, value: value === undefined ? null : value }),
onStatus: (handler) => subscribe<Status>("status", handler),
onInstallProgress: (handler) => subscribe<InstallProgress>("install-progress", handler),
onTileSyncProgress: (handler) => subscribe<TileSyncProgress>("tile-sync-progress", handler),
onRemoveProgress: (handler) => subscribe<RemoveProgress>("remove-progress", handler),
onStack: (handler) => subscribe<StackEvent>("stack", handler),
onLog: (handler) => subscribe<LogEvent>("log", handler),
};
+34
View File
@@ -14,7 +14,9 @@ import type {
StackComponent,
StackEvent,
Status,
RemoveProgress,
SyncConflict,
TileSyncProgress,
Unlisten,
} from "./types";
@@ -28,6 +30,8 @@ export type ActionKind =
| "installing"
| "restoring"
| "syncing"
| "pulling_tiles"
| "removing"
| "update"
| "launch"
| "stack"
@@ -51,6 +55,10 @@ export function deriveAction(s: Status | null): ActionKind {
return "restoring";
case "syncing":
return "syncing";
case "pulling_tiles":
return "pulling_tiles";
case "removing":
return "removing";
case "launching":
case "running":
return "stack";
@@ -98,6 +106,10 @@ class Launcher {
status = $state<Status | null>(null);
settings = $state<Settings | null>(null);
progress = $state<InstallProgress | null>(null);
/** While tiles are being taken from the DHP; cleared when the pull ends. */
tileProgress = $state<TileSyncProgress | null>(null);
/** While unticked items are being deleted; cleared when the phase moves on. */
removeProgress = $state<RemoveProgress | null>(null);
/** Smoothed bytes/sec for a steadier ETA. */
speed = $state(0);
stack = $state<StackMap>(emptyStack());
@@ -161,6 +173,8 @@ class Launcher {
await Promise.all([
keep(backend.onStatus((s) => this.applyStatus(s))),
keep(backend.onInstallProgress((p) => this.applyProgress(p))),
keep(backend.onTileSyncProgress((p) => (this.tileProgress = p))),
keep(backend.onRemoveProgress((p) => (this.removeProgress = p))),
keep(backend.onStack((e) => (this.stack[e.component] = e))),
keep(backend.onLog((l) => this.pushLog(l))),
]);
@@ -389,6 +403,26 @@ class Launcher {
});
}
/** Take the tiles other operators baked while this PC was away. */
/** Download what was chosen and never fully arrived. */
finishMarket() {
return this.run("market", async () => {
await backend.finishMarket();
});
}
syncTiles() {
this.tileProgress = null;
return this.run("tiles", async () => {
try {
await backend.syncTiles();
} finally {
// The bar belongs to one pull: left behind, it would describe the last one.
this.tileProgress = null;
}
});
}
syncNow() {
return this.run("sync", async () => {
await backend.syncNow();
+123 -21
View File
@@ -19,6 +19,7 @@ import {
type LogEvent,
type LogLevel,
type Market,
type MarketCheck,
type MarketItem,
type MockControls,
type MockScenario,
@@ -28,7 +29,9 @@ import {
type StackEvent,
type Status,
type SyncConflict,
type RemoveProgress,
type SyncStatus,
type TileSyncProgress,
type Unlisten,
} from "./types";
@@ -175,6 +178,8 @@ const MOCK_CONFLICTS: SyncConflict[] = [
type Payloads = {
status: Status;
"install-progress": InstallProgress;
"tile-sync-progress": TileSyncProgress;
"remove-progress": RemoveProgress;
stack: StackEvent;
log: LogEvent;
};
@@ -186,20 +191,22 @@ 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,
{ key: "tiles:dark/protomaps_20260423", kind: "tiles", title: "Dark map", bytes: 2_437_523_419, required: false, on_dhp: true, files: 375_004,
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,
{ key: "tiles:dark_v2/protomaps_20260423", kind: "tiles", title: "Dark V2 map", bytes: 1_649_512_829, required: true, on_dhp: true, files: 253_771,
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,
{ key: "tiles:satellite_hd/protomaps_20260423", kind: "tiles", title: "World satellite imagery", bytes: 5_660_999_147, required: false, on_dhp: true, files: 870_923,
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,
{ key: "tiles:orthophotos/protomaps_20260423", kind: "tiles", title: "France orthophotos", bytes: 6_713_284_879, required: false, on_dhp: true, files: 1_032_813,
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,
{ key: "tiles:heightmap/planet_heightmap", kind: "tiles", title: "Terrain elevation", bytes: 74_367_050_377, required: false, on_dhp: true, files: 11_441_085,
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,
{ key: "src:osm_source/world_aeroway_v1.sqlite", kind: "src", title: "Aeroway source data (OSM)", bytes: 884_244_480, required: false, on_dhp: true, files: 1,
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,
{ key: "src:protomaps_20260423.pmtiles", kind: "src", title: "Map source (world, vector)", bytes: 135_176_708_563, required: false, on_dhp: true, files: 1,
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." },
{ key: "src:planet_heightmap.pmtiles", kind: "src", title: "Terrain source (world, 705 GB)", bytes: 705_591_064_874, required: false, on_dhp: false, files: 1,
description: "The elevation archive the terrain tiles are baked from. With it this PC makes relief for any ground at any zoom, instead of only where the office pre-baked. It is 705 GB and the DHP does not carry it: it arrives on an ODIAN drive, and the Terrain elevation tiles above are what an install gets over the network." },
];
const MOCK_CORE_BYTES = 2_275_409_041 + 15_356_491_648;
@@ -238,6 +245,8 @@ function initialStatus(scenario: MockScenario): Status {
live: null,
map_from_site: null,
map_problem: null,
tiles_waiting: null,
unfinished: null,
};
switch (scenario) {
case "first-run":
@@ -308,6 +317,8 @@ class MockBackend implements Backend {
private readonly listeners: Listeners = {
status: new Set(),
"install-progress": new Set(),
"tile-sync-progress": new Set(),
"remove-progress": new Set(),
stack: new Set(),
log: new Set(),
};
@@ -327,7 +338,21 @@ class MockBackend implements Backend {
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"]);
this.marketPresent.set("src:planet_heightmap.pmtiles", "installed");
// Every PC installed before Dark V2 took Dark's place holds Dark without ever having
// chosen it. It must open ticked, or the marketplace offers to delete it.
this.marketPresent.set("tiles:dark/protomaps_20260423", "installed");
// Its marker says installed; the count finds under half the files. This is the state
// the badge used to report as "Installed" and say no more about.
this.marketPresent.set("tiles:orthophotos/protomaps_20260423", "installed");
this.marketChosen = new Set([
"tiles:dark_v2/protomaps_20260423",
"tiles:satellite_hd/protomaps_20260423",
"tiles:orthophotos/protomaps_20260423",
]);
// Satellite stopped half-way and the orthophotos' marker is a lie: two chosen maps
// the hub has to own up to, which is what an operator reported seeing as "Launch".
this.status = derive({ ...this.status, unfinished: this.unfinishedNow() });
}
const self = this;
@@ -361,6 +386,23 @@ class MockBackend implements Backend {
return Promise.resolve(() => void this.listeners[channel].delete(handler));
}
/** What was chosen and is not all here, as the launcher works it out. */
private unfinishedNow(): Status["unfinished"] {
const short = new Set(["tiles:orthophotos/protomaps_20260423"]);
const waiting = MOCK_MARKET.filter(
(item) =>
!item.required &&
this.marketChosen.has(item.key) &&
(this.marketPresent.get(item.key) !== "installed" || short.has(item.key)),
);
if (waiting.length === 0) return null;
return {
items: waiting.length,
bytes: waiting.reduce((sum, item) => sum + item.bytes, 0),
titles: waiting.slice(0, 3).map((item) => item.title),
};
}
private commit(patch: Partial<Status>) {
this.status = derive({ ...this.status, ...patch });
this.emit("status", this.status);
@@ -520,11 +562,20 @@ class MockBackend implements Backend {
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),
})),
items: MOCK_MARKET.map((item) => {
const presence = this.marketPresent.get(item.key) ?? "absent";
return {
...item,
presence,
// Mirrors market.rs `chosen`: an item the DHP cannot re-send counts as chosen
// while it is here, or an unticked box would offer to delete what only a drive
// could bring back.
selected:
item.required ||
this.marketChosen.has(item.key) ||
(!item.on_dhp && presence === "installed"),
};
}),
free_bytes: 297_100_000_000,
};
}
@@ -534,21 +585,62 @@ class MockBackend implements Backend {
this.marketChosen = new Set(selected.filter((key) => optional.has(key)));
}
async checkMarket(): Promise<MarketCheck[]> {
await this.wait(700);
// One set is deliberately short here: the badge said installed, the disk disagrees.
return MOCK_MARKET.filter((item) => item.kind === "tiles" && this.marketPresent.get(item.key) === "installed").map(
(item) => {
const expected = Math.max(1, Math.round(item.bytes / 6500));
const found = item.key === "tiles:orthophotos/protomaps_20260423" ? Math.round(expected * 0.42) : expected;
return { key: item.key, found, expected, whole: found >= expected };
},
);
}
async finishMarket(): Promise<void> {
// Mirrors market.rs `held_keys`: chosen OR already here, so nothing is proposed for
// deletion just because no choice about it was ever recorded.
const keep = new Set(this.marketChosen);
for (const item of MOCK_MARKET) {
if (!item.required && this.marketPresent.has(item.key)) keep.add(item.key);
}
await this.applyMarket([...keep]);
}
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 going = MOCK_MARKET.filter(
(item) => !item.required && !this.marketChosen.has(item.key) && this.marketPresent.has(item.key),
);
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.";
if (going.length === 0 && adds.length === 0) return;
if (adds.length && !s.online) throw "Your DHP is unreachable: connect to download marketplace items.";
void this.runMarketChanges(going, adds);
}
/** Deleting a map is hundreds of thousands of files: the window watches it happen. */
private async runMarketChanges(going: typeof MOCK_MARKET, adds: typeof MOCK_MARKET) {
const job = ++this.job;
for (const [i, item] of going.entries()) {
this.commit({ phase: "removing", error: null });
// A tile set is ~2 KB a file at these sizes; enough to make the count look real.
const total = item.kind === "tiles" ? Math.max(1, Math.round(item.bytes / 6500)) : 1;
for (let done = 0; done <= total; done += Math.max(1, Math.round(total / 40))) {
if (job !== this.job) return;
this.emit("remove-progress", { title: item.title, done: Math.min(done, total), total, index: i + 1, count: going.length });
await this.wait(120);
}
this.marketPresent.delete(item.key);
this.log("info", `Removed ${item.title}.`);
}
if (adds.length === 0) {
this.commit({ phase: "idle" });
return;
}
void this.runMarketDownload(adds);
}
@@ -640,6 +732,10 @@ class MockBackend implements Backend {
};
}
async syncTiles(): Promise<void> {
// The mock has no DHP to take tiles from; the button is simply a no-op there.
}
async syncNow(): Promise<void> {
if (!this.status.online) throw "Your DHP is unreachable: changes stay on this PC until the next connected sync.";
if (this.status.phase !== "idle" && this.status.phase !== "error") throw "ODIAN is busy: sync once it has stopped.";
@@ -694,6 +790,12 @@ class MockBackend implements Backend {
onInstallProgress(handler: (progress: InstallProgress) => void) {
return this.listen("install-progress", handler);
}
onTileSyncProgress(handler: (progress: TileSyncProgress) => void) {
return this.listen("tile-sync-progress", handler);
}
onRemoveProgress(handler: (progress: RemoveProgress) => void) {
return this.listen("remove-progress", handler);
}
onStack(handler: (event: StackEvent) => void) {
return this.listen("stack", handler);
}
+72
View File
@@ -5,6 +5,8 @@ export type Phase =
| "idle" // nothing running
| "checking" // probing the site
| "signing_in" // browser sign-in in progress
| "pulling_tiles" // taking tiles the DHP baked while this PC was away
| "removing" // deleting what was unticked in the marketplace
| "installing" // downloading + verifying a version
| "restoring" // loading the database snapshot into local Postgres
| "syncing" // exchanging operator data with the DHP (ODIAN is not running)
@@ -57,6 +59,61 @@ export interface Status {
map_from_site: boolean | null;
/** Why, in one line, when `map_from_site` is false. */
map_problem: string | null;
/**
* Tiles the DHP has baked for the sets this PC holds since it last synced. null when
* offline, with nothing installed, or when the DHP's tile service cannot say. Zero
* tiles means this PC is level with the site.
*/
tiles_waiting: TilesWaiting | null;
/**
* Marketplace items the operator chose that are not all on this PC. null when nothing is
* installed or nothing is missing. ODIAN still launches - with less map than was asked for.
*/
unfinished: Unfinished | null;
}
/** What is missing of what was chosen. */
export interface Unfinished {
items: number;
/** What finishing them downloads, in full; the installer skips what is already verified. */
bytes: number;
/** The first few, to name in one line. */
titles: string[];
}
/** What a tile sync would fetch from the DHP. */
export interface TilesWaiting {
tiles: number;
bytes: number;
/** One page's worth, with more behind it: shown as "2000+". */
more: boolean;
}
/** While unticked marketplace items are being deleted. */
export interface RemoveProgress {
title: string;
done: number;
total: number;
/** Which of them, and how many in all: one Apply can drop several. */
index: number;
count: number;
}
/** While a tile sync runs. */
export interface TileSyncProgress {
tiles: number;
bytes: number;
page_tiles: number;
page_done: number;
}
/** One item weighed against what the version says it holds. */
export interface MarketCheck {
key: string;
found: number;
expected: number;
/** At least everything the version names is on disk. More than that is normal. */
whole: boolean;
}
/** A marketplace item: one tile set or source file of the version. */
@@ -68,12 +125,19 @@ export interface MarketItem {
description: string;
/** What installing it downloads, in full. */
bytes: number;
/** How many files it is made of: what a removal counts down, and what a check weighs. */
files: 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;
/**
* False when the DHP does not carry this item's bytes and never will (the 705 GB terrain
* source). It is listed so an operator knows it exists; it arrives on an ODIAN drive.
*/
on_dhp: boolean;
}
export interface Market {
@@ -238,6 +302,10 @@ export interface Backend {
* ones download (install-progress follows). Before the first install it only saves the choice.
*/
applyMarket(selected: string[]): Promise<void>;
/** Count what is really on disk for the items the markers call installed. Slow. */
checkMarket(): Promise<MarketCheck[]>;
/** Download what was chosen and never fully arrived, without changing the choice. */
finishMarket(): 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>;
@@ -250,6 +318,8 @@ export interface Backend {
pickFolder(): Promise<string | null>;
/** Push and pull operator data now; only while ODIAN is stopped. */
syncNow(): Promise<void>;
/** Take the tiles the DHP baked while this PC was away. */
syncTiles(): Promise<void>;
/** What the operator has to decide, oldest first. */
getSyncConflicts(): Promise<SyncConflict[]>;
/** Records one decision; resolves with what is left to decide. */
@@ -262,6 +332,8 @@ export interface Backend {
onStatus(handler: (status: Status) => void): Promise<Unlisten>;
onInstallProgress(handler: (progress: InstallProgress) => void): Promise<Unlisten>;
onTileSyncProgress(handler: (progress: TileSyncProgress) => void): Promise<Unlisten>;
onRemoveProgress(handler: (progress: RemoveProgress) => void): Promise<Unlisten>;
onStack(handler: (event: StackEvent) => void): Promise<Unlisten>;
onLog(handler: (event: LogEvent) => void): Promise<Unlisten>;
}