This commit is contained in:
Kelorastus
2026-09-23 16:05:19 +02:00
parent 4a92cc0b63
commit 4d620fc5ad
27 changed files with 3571 additions and 104 deletions
+4
View File
@@ -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"),
+41 -3
View File
@@ -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
View File
@@ -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.";
+44
View File
@@ -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>;