885 lines
31 KiB
Svelte
885 lines
31 KiB
Svelte
<script lang="ts">
|
|
import Icon, { type IconName } from "./Icon.svelte";
|
|
import ProgressBar from "./ProgressBar.svelte";
|
|
import StackList from "./StackList.svelte";
|
|
import StateDot, { type DotTone } from "./StateDot.svelte";
|
|
import { launcher } from "../lib/launcher.svelte";
|
|
import { STAGE_LABEL, formatBytes, formatCount, formatDuration, formatSpeed } from "../lib/format";
|
|
|
|
const s = $derived(launcher.status);
|
|
const p = $derived(launcher.progress);
|
|
const action = $derived(launcher.action);
|
|
const busy = $derived(launcher.busy);
|
|
const checking = $derived(s?.phase === "checking");
|
|
const blocked = $derived(busy !== null || checking);
|
|
const site = $derived(launcher.siteName);
|
|
|
|
const fraction = $derived.by(() => {
|
|
if (!p) return null;
|
|
if (p.total_bytes > 0) return p.done_bytes / p.total_bytes;
|
|
if (p.total_items > 0) return p.done_items / p.total_items;
|
|
return null;
|
|
});
|
|
|
|
const transferred = $derived.by(() => {
|
|
if (!p) return "—";
|
|
if (p.total_bytes > 0) return `${formatBytes(p.done_bytes)} / ${formatBytes(p.total_bytes)}`;
|
|
if (p.total_items > 0) return `${formatCount(p.done_items)} / ${formatCount(p.total_items)}`;
|
|
return "—";
|
|
});
|
|
|
|
interface Head {
|
|
label: string;
|
|
tone: DotTone;
|
|
pulse: boolean;
|
|
meta: string | null;
|
|
}
|
|
|
|
const head: Head = $derived.by(() => {
|
|
const stage = p ? `Stage ${p.stage_index}/${p.stage_count}` : null;
|
|
const version = (v: string | null | undefined) => (v ? `v${v}` : null);
|
|
switch (action) {
|
|
case "loading":
|
|
return { label: "Connecting", tone: "idle", pulse: true, meta: null };
|
|
case "sign_in":
|
|
if (s?.installed && s.update_required) {
|
|
return { label: "Update required", tone: "warn", pulse: false, meta: `${s.installed} → ${s.available}` };
|
|
}
|
|
return s?.installed
|
|
? { label: "Signed out", tone: "idle", pulse: false, meta: version(s.installed) }
|
|
: { label: "Not installed", tone: "idle", pulse: false, meta: version(s?.available) };
|
|
case "signed_out_offline":
|
|
return { label: "Signed out", tone: "warn", pulse: false, meta: "offline" };
|
|
case "signing_in":
|
|
return { label: "Signing in", tone: "accent", pulse: true, meta: null };
|
|
case "install":
|
|
return { label: "Ready to install", tone: "accent", pulse: false, meta: version(s?.available) };
|
|
case "offline_unavailable":
|
|
return { label: "Not installed", tone: "warn", pulse: false, meta: "offline" };
|
|
case "installing":
|
|
return { label: s?.installed ? "Updating" : "Installing", tone: "accent", pulse: true, meta: stage };
|
|
case "restoring":
|
|
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 "building":
|
|
return { label: "Building the checkout", tone: "accent", pulse: true, meta: null };
|
|
case "update":
|
|
return { label: "Update required", tone: "warn", pulse: false, meta: `${s?.installed} → ${s?.available}` };
|
|
case "launch":
|
|
return { label: s?.online ? "Ready" : "Ready offline", tone: "ok", pulse: false, meta: version(s?.installed) };
|
|
case "stack":
|
|
if (launcher.stopping) return { label: "Stopping", tone: "warn", pulse: true, meta: version(s?.installed) };
|
|
return s?.phase === "running"
|
|
? { label: "Running", tone: "ok", pulse: false, meta: version(s?.installed) }
|
|
: { label: "Starting ODIAN", tone: "accent", pulse: true, meta: version(s?.installed) };
|
|
case "error":
|
|
return { label: "Needs attention", tone: "danger", pulse: false, meta: null };
|
|
}
|
|
});
|
|
|
|
// 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;
|
|
});
|
|
|
|
// Said out loud whenever this run is not the installed ODIAN. A dev client looks exactly
|
|
// like the real one on screen, and mistaking the two is how a bug gets chased in the
|
|
// wrong tree - or a demo gets given from an unbuilt branch.
|
|
const devOn = $derived(launcher.devOn);
|
|
const devRun = $derived(launcher.devReady);
|
|
const devRoot = $derived(launcher.settings?.dev_root ?? "");
|
|
const devTarget = $derived(launcher.settings?.dev_target ?? "client");
|
|
const devBuild = $derived(launcher.settings?.dev_build ?? false);
|
|
const devUserField = $derived(launcher.settings?.dev_user ?? "");
|
|
const savingDev = $derived(busy === "settings");
|
|
// One stack either way: both clients talk to the same API and the same database, which is
|
|
// the only way a .exe/web divergence is visible at all.
|
|
const TARGETS = [
|
|
{ key: "client", name: "ODIAN", note: "Odian.exe" },
|
|
{ key: "web", name: "Web", note: ":5173" },
|
|
{ key: "both", name: "Both", note: "one stack" },
|
|
] as const;
|
|
|
|
// ---- releasing ------------------------------------------------------------
|
|
async function openRelease() {
|
|
launcher.releaseOpen = true;
|
|
await launcher.loadRelease();
|
|
}
|
|
|
|
// While one runs, this button is the way BACK to its output rather than a way to start a
|
|
// second: closing the console otherwise left a running release with nowhere to be seen.
|
|
const releasing = $derived(launcher.consoleRunning);
|
|
const hasOutput = $derived(launcher.consoleLines.length > 0 && !launcher.consoleOpen);
|
|
|
|
async function browseCheckout() {
|
|
const path = await launcher.pickFolder();
|
|
if (path) await launcher.setDev({ dev_root: path });
|
|
}
|
|
// Named only when it is someone ELSE: running as yourself is not worth a warning.
|
|
const devUser = $derived.by(() => {
|
|
const asked = launcher.settings?.dev_user?.trim() ?? "";
|
|
return devRun && asked && asked !== s?.operator ? asked : "";
|
|
});
|
|
|
|
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>
|
|
|
|
{#snippet primary(label: string, icon: IconName, onclick: () => void, disabled: boolean, pending = false)}
|
|
<div class="primary-wrap">
|
|
<button class="primary" {onclick} {disabled} aria-busy={pending}>
|
|
<span class="primary-icon">
|
|
{#if pending}
|
|
<span class="spinner" aria-hidden="true"></span>
|
|
{:else}
|
|
<Icon name={icon} size={18} strokeWidth={1.75} />
|
|
{/if}
|
|
</span>
|
|
<span class="primary-label">{label}</span>
|
|
</button>
|
|
<span class="corners" aria-hidden="true"></span>
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet driveLink(lead: string)}
|
|
<p class="secondary">
|
|
{lead}
|
|
<button class="btn-link" disabled={blocked} onclick={() => launcher.chooseDrive()}>Install from a drive…</button>
|
|
</p>
|
|
{/snippet}
|
|
|
|
<section class="action glass" aria-labelledby="action-title">
|
|
<header class="head">
|
|
<StateDot tone={head.tone} pulse={head.pulse} />
|
|
<h2 id="action-title" class="eyebrow title" aria-live="polite">{head.label}</h2>
|
|
{#if head.meta}<span class="meta mono">{head.meta}</span>{/if}
|
|
</header>
|
|
|
|
{#if action === "loading"}
|
|
{@render primary("Loading", "play", () => {}, true, true)}
|
|
{:else if action === "sign_in"}
|
|
{@render primary(
|
|
s?.update_required ? "Sign in to update" : s?.installed ? "Sign in" : "Sign in to install",
|
|
"key",
|
|
() => launcher.signIn(),
|
|
blocked,
|
|
busy === "sign_in",
|
|
)}
|
|
<p class="secondary">
|
|
{#if s?.update_required}
|
|
ODIAN <strong>{s.available}</strong> is required before launch while connected. Sign in with your {site}
|
|
account to download it.
|
|
{:else if s?.installed}
|
|
Sign in with your {site} account. This PC then remembers you, online and offline, until you sign out.
|
|
{:else}
|
|
Sign in with your {site} account to download ODIAN{#if s?.available} <strong>{s.available}</strong>{/if}.
|
|
{/if}
|
|
</p>
|
|
{#if !s?.installed}{@render driveLink("No access to the DHP?")}{/if}
|
|
{:else if action === "signing_in"}
|
|
{@render primary("Waiting for sign-in", "key", () => {}, true, true)}
|
|
<p class="secondary">
|
|
Complete the Keycloak sign-in in your browser, then come back here.
|
|
<button class="btn-link" onclick={() => launcher.cancelSignIn()}>Cancel sign-in</button>
|
|
</p>
|
|
{:else if action === "install"}
|
|
{@render primary("Install", "download", () => launcher.install(), blocked, busy === "install")}
|
|
<p class="secondary">
|
|
ODIAN <strong>{s?.available}</strong> from {site}, into <span class="mono path">{s?.install_dir}</span>.
|
|
</p>
|
|
<!-- A licensed PC with nothing installed should be running from its checkout against the
|
|
DHP. When it cannot, say which single thing is in the way - this screen used to give
|
|
no clue at all, which sent people looking at the install folder every time. -->
|
|
{#if s?.dhp_blocked}
|
|
<p class="secondary small">
|
|
<strong>Dev mode:</strong> a run straight against {site} needs no install, but {s.dhp_blocked}
|
|
</p>
|
|
{/if}
|
|
{@render driveLink("Have an ODIAN drive?")}
|
|
{:else if action === "offline_unavailable"}
|
|
{@render primary("Connect to your DHP to install", "globe", () => {}, true)}
|
|
{@render driveLink("Installing needs your DHP, or an ODIAN drive.")}
|
|
{:else if action === "signed_out_offline"}
|
|
{@render primary("Connect to your DHP to sign in", "globe", () => {}, true)}
|
|
<p class="secondary">
|
|
This PC doesn't remember an operator anymore. Sign in once while connected to {site}, and ODIAN launches offline
|
|
again after that.
|
|
</p>
|
|
{:else if action === "update"}
|
|
{@render primary(`Update to ${s?.available}`, "update", () => launcher.install(), blocked, busy === "install")}
|
|
<p class="secondary">
|
|
Updating is required before launch while connected to {site}. Everything already verified is reused.
|
|
</p>
|
|
{:else if action === "launch"}
|
|
{@render primary("Launch", "play", () => launcher.launch(), blocked || !s?.can_launch, busy === "launch")}
|
|
<p class="secondary">
|
|
{#if checking}
|
|
Checking {site}…
|
|
{:else if !s?.installed}
|
|
Nothing is installed here: the client comes from your checkout and runs on
|
|
<strong>{site}</strong>'s data. You sign in inside ODIAN, not here.
|
|
{:else if s?.update_required}
|
|
<!-- Only reachable on a dev run: any other PC would be on the "update" action. -->
|
|
ODIAN <strong>{s.installed}</strong> is installed, <strong>{s.available}</strong> is on {site}. A dev run starts
|
|
your checkout, so it does not need it.
|
|
<button class="btn-link" disabled={blocked} onclick={() => launcher.install()}>Update anyway</button>
|
|
{:else if s?.online}
|
|
ODIAN <strong>{s.installed}</strong> is up to date.
|
|
{:else}
|
|
Offline: ODIAN <strong>{s?.installed}</strong> runs on local data and map tiles.
|
|
{/if}
|
|
</p>
|
|
{#if launcher.syncAttention > 0}
|
|
<p class="secondary attention">
|
|
<Icon name="alert" size={14} />
|
|
<span>
|
|
<strong>{launcher.syncAttention}</strong>
|
|
{launcher.syncAttention === 1 ? "change needs" : "changes need"} your decision.
|
|
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Review</button>
|
|
</span>
|
|
</p>
|
|
{:else if s?.online || s?.sync}
|
|
<p class="secondary small">
|
|
Operator data syncs with {site} before every launch.
|
|
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
|
|
</p>
|
|
{/if}
|
|
{#if devOn}
|
|
<!-- Dev mode's own controls, where the launching happens. Everything that decides
|
|
what Launch does is here rather than behind Settings: the target, the checkout,
|
|
and whether it builds first. -->
|
|
<section class="dev" aria-label="Dev mode">
|
|
<header class="dev-head">
|
|
<Icon name="code" size={13} />
|
|
<span class="dev-title">DEV</span>
|
|
<!-- What Launch will actually start, in three words. The folder is not repeated
|
|
here: it is in the field two rows down. -->
|
|
<span class="dev-from mono">{devRun ? TARGETS.find((o) => o.key === devTarget)?.name ?? "" : "no checkout"}</span>
|
|
</header>
|
|
|
|
<div class="dev-targets" role="radiogroup" aria-label="Launch starts">
|
|
{#each TARGETS as option (option.key)}
|
|
<label class="dev-target" class:on={devTarget === option.key}>
|
|
<input
|
|
type="radio"
|
|
name="dev-target"
|
|
value={option.key}
|
|
checked={devTarget === option.key}
|
|
disabled={blocked || savingDev}
|
|
onchange={() => launcher.setDev({ dev_target: option.key })}
|
|
/>
|
|
<span class="dev-target-name">{option.name}</span>
|
|
<span class="dev-target-note mono">{option.note}</span>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="dev-row">
|
|
<input
|
|
class="dev-path"
|
|
type="text"
|
|
spellcheck="false"
|
|
placeholder="Folder holding ODIAN_Engine"
|
|
value={devRoot}
|
|
disabled={blocked || savingDev}
|
|
onchange={(e) => launcher.setDev({ dev_root: e.currentTarget.value.trim() })}
|
|
/>
|
|
<button type="button" class="btn" disabled={blocked || savingDev} onclick={browseCheckout}>Browse…</button>
|
|
</div>
|
|
|
|
<div class="dev-row">
|
|
<label class="dev-label" for="dev-run-as">Run as</label>
|
|
<input
|
|
id="dev-run-as"
|
|
class="dev-path"
|
|
type="text"
|
|
spellcheck="false"
|
|
autocomplete="off"
|
|
placeholder={s?.operator ?? "the operator who signed in"}
|
|
value={devUserField}
|
|
disabled={blocked || savingDev}
|
|
onchange={(e) => launcher.setDev({ dev_user: e.currentTarget.value.trim() })}
|
|
/>
|
|
</div>
|
|
|
|
<div class="dev-row">
|
|
{#if releasing}
|
|
<button class="btn dev-release" onclick={() => launcher.showConsole()}>Release running…</button>
|
|
{:else}
|
|
<button class="btn dev-release" disabled={blocked || !s?.online} onclick={openRelease}>
|
|
Build & deploy…
|
|
</button>
|
|
{/if}
|
|
{#if hasOutput && !releasing}
|
|
<button class="btn-link" onclick={() => launcher.showConsole()}>last output</button>
|
|
{:else if !s?.online}
|
|
<span class="dev-note">needs the DHP</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<label class="dev-check">
|
|
<input
|
|
type="checkbox"
|
|
checked={devBuild}
|
|
disabled={blocked || savingDev}
|
|
onchange={(e) => launcher.setDev({ dev_build: e.currentTarget.checked })}
|
|
/>
|
|
<span>Build first</span>
|
|
</label>
|
|
|
|
{#if !devRun}
|
|
<p class="dev-note">Name the folder that holds <code>ODIAN_Engine</code> and Launch runs from it.</p>
|
|
{:else if devUser}
|
|
<p class="dev-note attention">
|
|
Running as <strong>{devUser}</strong> — this PC will not sync with the DHP.
|
|
</p>
|
|
{/if}
|
|
</section>
|
|
{/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">
|
|
<span class="stage-name">{p ? STAGE_LABEL[p.stage] : "Preparing"}</span>
|
|
{#if fraction !== null}<span class="pct mono tabular">{Math.floor(fraction * 100)}%</span>{/if}
|
|
</div>
|
|
<ProgressBar
|
|
value={fraction}
|
|
label={p ? `${STAGE_LABEL[p.stage]}, stage ${p.stage_index} of ${p.stage_count}` : "Preparing install"}
|
|
/>
|
|
{#if p}
|
|
<div class="segments" style:--count={p.stage_count} aria-hidden="true">
|
|
{#each { length: p.stage_count } as _, i (i)}
|
|
<span class="seg" class:done={i < p.stage_index - 1} class:current={i === p.stage_index - 1}></span>
|
|
{/each}
|
|
</div>
|
|
<dl class="metrics">
|
|
<div>
|
|
<dt>Transferred</dt>
|
|
<dd class="mono tabular" title={transferred}>{transferred}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Speed</dt>
|
|
<dd class="mono tabular">{launcher.speed > 0 ? formatSpeed(launcher.speed) : "—"}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Stage ETA</dt>
|
|
<dd class="mono tabular">{launcher.eta !== null ? formatDuration(launcher.eta) : "—"}</dd>
|
|
</div>
|
|
</dl>
|
|
{#if p.message}<p class="message mono" title={p.message}>{p.message}</p>{/if}
|
|
{/if}
|
|
</div>
|
|
<button class="btn btn-danger wide" disabled={busy !== null} onclick={() => launcher.cancelInstall()}>
|
|
{busy === "cancel" ? "Cancelling…" : "Cancel"}
|
|
</button>
|
|
<p class="secondary small">Cancelling keeps every file already verified; the next install resumes from there.</p>
|
|
{:else if action === "restoring"}
|
|
<div class="progress">
|
|
<div class="stage-line">
|
|
<span class="stage-name">Loading the database snapshot</span>
|
|
</div>
|
|
<ProgressBar value={null} label="Restoring the database" />
|
|
{#if p?.stage === "restore" && p.message}<p class="message mono" title={p.message}>{p.message}</p>{/if}
|
|
</div>
|
|
<p class="secondary">This can take a few minutes. Keep the launcher open until it finishes.</p>
|
|
{:else if action === "syncing"}
|
|
<div class="progress">
|
|
<div class="stage-line">
|
|
<span class="stage-name">Exchanging operator data</span>
|
|
</div>
|
|
<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 === "building"}
|
|
<div class="progress">
|
|
<div class="stage-line">
|
|
<span class="stage-name">Compiling ODIAN from your checkout</span>
|
|
</div>
|
|
<ProgressBar value={null} label="Building" />
|
|
</div>
|
|
<p class="secondary">The build's own window has its output. A first build takes a while.</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}
|
|
<!-- The map is this PC's own. Said plainly, because it looks like nothing is
|
|
wrong: tiles still draw, they are simply the ones this PC downloaded. -->
|
|
<div class="notice" role="status">
|
|
<Icon name="alert" size={14} />
|
|
<p class="selectable">
|
|
{site}'s map service is unreachable: drawing from this PC's own tiles.
|
|
{#if s?.map_problem}<span class="mono dim"> ({s.map_problem})</span>{/if}
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
<button class="btn btn-danger wide" disabled={launcher.stopping || busy !== null} onclick={() => launcher.stop()}>
|
|
<Icon name="stop" size={12} />
|
|
{launcher.stopping ? "Stopping…" : s?.phase === "running" ? "Stop ODIAN" : "Stop"}
|
|
</button>
|
|
{:else if action === "error"}
|
|
<div class="error-box" role="alert">
|
|
<Icon name="alert" size={16} />
|
|
<p class="selectable">{s?.error ?? "Something went wrong."}</p>
|
|
</div>
|
|
{@render primary("Retry", "refresh", () => launcher.retry(), blocked, busy !== null)}
|
|
<p class="secondary">
|
|
<button class="btn-link" onclick={() => launcher.setActivityOpen(true)}>View activity</button> for the full log.
|
|
</p>
|
|
{/if}
|
|
|
|
{#if showNotice}
|
|
<div class="notice" role="alert">
|
|
<Icon name="alert" size={14} />
|
|
<p class="selectable">{launcher.notice}</p>
|
|
<button class="icon-btn dismiss" aria-label="Dismiss message" onclick={() => launcher.dismissNotice()}>
|
|
<Icon name="close" size={12} />
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<style>
|
|
/* Dev mode's controls. Deliberately compact and deliberately here: what Launch will do
|
|
is decided in the same place you press it, not two drawers away. */
|
|
.dev {
|
|
margin-top: 0.75rem;
|
|
padding: 0.7rem 0.75rem;
|
|
border: 1px solid color-mix(in srgb, #46d17f 32%, transparent);
|
|
border-radius: 8px;
|
|
background: color-mix(in srgb, #46d17f 7%, transparent);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.55rem;
|
|
}
|
|
.dev-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
color: #46d17f;
|
|
}
|
|
.dev-title {
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.12em;
|
|
}
|
|
.dev-from {
|
|
margin-left: auto;
|
|
font-size: 0.72rem;
|
|
color: var(--text-dim, #9aa3ad);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
max-width: 55%;
|
|
direction: rtl; /* keep the END of a long path, which is the part that differs */
|
|
text-align: right;
|
|
}
|
|
.dev-targets {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 0.35rem;
|
|
}
|
|
.dev-target {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.1rem;
|
|
padding: 0.35rem 0.25rem;
|
|
border: 1px solid var(--line, #2a3139);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
line-height: 1.2;
|
|
}
|
|
.dev-target.on {
|
|
border-color: color-mix(in srgb, #46d17f 60%, transparent);
|
|
background: color-mix(in srgb, #46d17f 14%, transparent);
|
|
}
|
|
.dev-target:has(input:disabled) {
|
|
opacity: 0.55;
|
|
cursor: default;
|
|
}
|
|
.dev-target input {
|
|
position: absolute;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
.dev-target-name {
|
|
font-size: 0.82rem;
|
|
font-weight: 600;
|
|
}
|
|
.dev-target-note {
|
|
font-size: 0.68rem;
|
|
color: var(--text-dim, #9aa3ad);
|
|
}
|
|
.dev-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
}
|
|
.dev-path {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
padding: 0.3rem 0.45rem;
|
|
font-size: 0.78rem;
|
|
font-family: inherit;
|
|
color: inherit;
|
|
background: rgba(0, 0, 0, 0.25);
|
|
border: 1px solid var(--line, #2a3139);
|
|
border-radius: 6px;
|
|
}
|
|
.dev-label {
|
|
flex: none;
|
|
font-size: 0.78rem;
|
|
color: var(--text-dim, #9aa3ad);
|
|
}
|
|
.dev-check {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
font-size: 0.8rem;
|
|
white-space: nowrap;
|
|
}
|
|
.dev-note {
|
|
font-size: 0.75rem;
|
|
color: var(--text-dim, #9aa3ad);
|
|
margin: 0;
|
|
}
|
|
.dev-note code {
|
|
font-size: 0.72rem;
|
|
}
|
|
.dev-release {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.action {
|
|
display: grid;
|
|
gap: 14px;
|
|
width: 100%;
|
|
padding: 18px 22px 22px;
|
|
}
|
|
|
|
.head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-height: 20px;
|
|
}
|
|
|
|
.title {
|
|
flex: 1;
|
|
color: var(--ink-2);
|
|
}
|
|
|
|
.meta {
|
|
color: var(--muted);
|
|
font-size: var(--text-xs);
|
|
}
|
|
|
|
/* Primary button */
|
|
.primary-wrap {
|
|
position: relative;
|
|
margin: 4px 0 2px;
|
|
}
|
|
|
|
.primary {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
width: 100%;
|
|
height: 64px;
|
|
padding: 0 24px;
|
|
border: 1px solid var(--accent);
|
|
border-radius: var(--radius);
|
|
background: var(--accent);
|
|
color: var(--accent-ink);
|
|
font-family: var(--font-display);
|
|
font-size: var(--text-xl);
|
|
font-weight: 600;
|
|
letter-spacing: 0.02em;
|
|
box-shadow: 0 14px 40px -16px rgba(91, 140, 255, 0.7);
|
|
transition:
|
|
background-color var(--dur-fast) var(--ease),
|
|
border-color var(--dur-fast) var(--ease),
|
|
box-shadow var(--dur) var(--ease),
|
|
transform var(--dur-fast) var(--ease);
|
|
}
|
|
.primary:hover:not(:disabled) {
|
|
border-color: var(--accent-hover);
|
|
background: var(--accent-hover);
|
|
box-shadow: 0 18px 48px -14px rgba(91, 140, 255, 0.85);
|
|
}
|
|
.primary:active:not(:disabled) {
|
|
background: var(--accent-press);
|
|
transform: translateY(1px);
|
|
}
|
|
.primary:focus-visible {
|
|
outline: 2px solid var(--ink);
|
|
outline-offset: 4px;
|
|
}
|
|
.primary:disabled {
|
|
border-color: var(--line-strong);
|
|
background: var(--surface-2);
|
|
color: var(--muted);
|
|
box-shadow: none;
|
|
font-size: var(--text-lg);
|
|
}
|
|
|
|
.primary-icon {
|
|
display: grid;
|
|
place-items: center;
|
|
width: 20px;
|
|
}
|
|
|
|
.primary-label {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-align: left;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.corners {
|
|
--c: var(--accent-line);
|
|
position: absolute;
|
|
inset: -6px;
|
|
background:
|
|
linear-gradient(var(--c), var(--c)) 0 0 / 10px 1px,
|
|
linear-gradient(var(--c), var(--c)) 0 0 / 1px 10px,
|
|
linear-gradient(var(--c), var(--c)) 100% 0 / 10px 1px,
|
|
linear-gradient(var(--c), var(--c)) 100% 0 / 1px 10px,
|
|
linear-gradient(var(--c), var(--c)) 0 100% / 10px 1px,
|
|
linear-gradient(var(--c), var(--c)) 0 100% / 1px 10px,
|
|
linear-gradient(var(--c), var(--c)) 100% 100% / 10px 1px,
|
|
linear-gradient(var(--c), var(--c)) 100% 100% / 1px 10px;
|
|
background-repeat: no-repeat;
|
|
pointer-events: none;
|
|
transition:
|
|
inset var(--dur) var(--ease),
|
|
opacity var(--dur) var(--ease);
|
|
}
|
|
.primary-wrap:hover .corners {
|
|
inset: -8px;
|
|
}
|
|
.primary:disabled + .corners {
|
|
opacity: 0;
|
|
}
|
|
|
|
.spinner {
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 1.5px solid currentColor;
|
|
border-right-color: transparent;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
/* Text */
|
|
.secondary {
|
|
color: var(--muted);
|
|
font-size: var(--text-sm);
|
|
line-height: 1.55;
|
|
}
|
|
.secondary.small {
|
|
font-size: var(--text-xs);
|
|
}
|
|
.attention {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
color: var(--ink-2);
|
|
}
|
|
.attention :global(.icon) {
|
|
margin-top: 3px;
|
|
color: var(--warn);
|
|
}
|
|
.secondary strong {
|
|
color: var(--ink-2);
|
|
font-weight: 500;
|
|
}
|
|
.path {
|
|
color: var(--ink-2);
|
|
font-size: var(--text-xs);
|
|
}
|
|
|
|
.wide {
|
|
width: 100%;
|
|
height: 44px;
|
|
}
|
|
|
|
/* Progress */
|
|
.progress {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
|
|
.stage-line {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
gap: var(--space-3);
|
|
}
|
|
|
|
.stage-name {
|
|
color: var(--ink);
|
|
font-family: var(--font-display);
|
|
font-size: var(--text-lg);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.pct {
|
|
color: var(--ink-2);
|
|
font-size: var(--text-sm);
|
|
}
|
|
|
|
.segments {
|
|
display: grid;
|
|
grid-template-columns: repeat(var(--count), 1fr);
|
|
gap: 3px;
|
|
}
|
|
.seg {
|
|
height: 2px;
|
|
background: var(--line-strong);
|
|
transition: background-color var(--dur) var(--ease);
|
|
}
|
|
.seg.done {
|
|
background: var(--accent);
|
|
}
|
|
.seg.current {
|
|
background: var(--accent-line);
|
|
}
|
|
|
|
.metrics {
|
|
display: grid;
|
|
grid-template-columns: 1.45fr 1fr 1fr;
|
|
gap: 1px;
|
|
margin: 2px 0 0;
|
|
border: 1px solid var(--line);
|
|
background: var(--line);
|
|
}
|
|
.metrics > div {
|
|
min-width: 0;
|
|
padding: 7px 10px;
|
|
background: var(--surface);
|
|
}
|
|
.metrics dt {
|
|
color: var(--muted);
|
|
font-size: var(--text-xs);
|
|
}
|
|
.metrics dd {
|
|
margin: 0;
|
|
overflow: hidden;
|
|
color: var(--ink);
|
|
font-size: var(--text-xs);
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.message {
|
|
overflow: hidden;
|
|
color: var(--muted);
|
|
font-size: var(--text-xs);
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
/* Errors */
|
|
.error-box,
|
|
.notice {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
padding: 10px 12px;
|
|
border: 1px solid rgba(248, 113, 113, 0.32);
|
|
border-radius: var(--radius);
|
|
background: var(--danger-soft);
|
|
color: var(--danger);
|
|
}
|
|
.error-box p,
|
|
.notice .dim {
|
|
color: var(--muted);
|
|
}
|
|
|
|
.notice p {
|
|
flex: 1;
|
|
color: var(--ink-2);
|
|
font-size: var(--text-sm);
|
|
line-height: 1.5;
|
|
}
|
|
.error-box :global(.icon),
|
|
.notice :global(.icon) {
|
|
margin-top: 2px;
|
|
}
|
|
.notice {
|
|
align-items: center;
|
|
padding: 6px 6px 6px 12px;
|
|
}
|
|
.dismiss {
|
|
width: 24px;
|
|
height: 24px;
|
|
}
|
|
</style>
|