Compare commits

...

2 Commits

Author SHA1 Message Date
Kelorastus cf8f0d28a6 Merge branch 'main' of http://192.168.1.41:30061/admin/ODIAN_Launcher 2026-09-29 15:54:30 +02:00
Kelorastus 0252f95e35 fixed Dev mode 2026-09-29 15:54:26 +02:00
9 changed files with 266 additions and 141 deletions
+4 -1
View File
@@ -903,7 +903,10 @@ impl Launcher {
// launch: `dev_engine` would quietly hand back the installed version, and the window // launch: `dev_engine` would quietly hand back the installed version, and the window
// would go on saying the run came from the checkout. Being told the folder is wrong // would go on saying the run came from the checkout. Being told the folder is wrong
// while looking at it beats wondering later why an edit changed nothing. // while looking at it beats wondering later why an edit changed nothing.
if next.dev_mode { // Only a folder that was NAMED is checked. Dev mode is switched on from the hub now,
// and the checkout is chosen next to the Launch button afterwards, so refusing the
// switch for an empty folder would refuse the first half of a two-step job.
if next.dev_mode && !next.dev_root.is_empty() {
let build = PathBuf::from(&next.dev_root).join("ODIAN_Engine").join("tools").join("build").join("build.bat"); let build = PathBuf::from(&next.dev_root).join("ODIAN_Engine").join("tools").join("build").join("build.bat");
if !build.is_file() { if !build.is_file() {
return Err(format!("{} is not there: the dev folder is the one HOLDING ODIAN_Engine.", build.display())); return Err(format!("{} is not there: the dev folder is the one HOLDING ODIAN_Engine.", build.display()));
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "ODIAN Launcher", "productName": "ODIAN Launcher",
"version": "0.1.36", "version": "0.1.38",
"identifier": "fr.odian.launcher", "identifier": "fr.odian.launcher",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

+198 -10
View File
@@ -97,7 +97,24 @@
// Said out loud whenever this run is not the installed ODIAN. A dev client looks exactly // 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 // 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. // wrong tree - or a demo gets given from an unbuilt branch.
const devRun = $derived((s?.dev ?? null) !== null && (launcher.settings?.dev_mode ?? false)); 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 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;
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. // Named only when it is someone ELSE: running as yourself is not worth a warning.
const devUser = $derived.by(() => { const devUser = $derived.by(() => {
const asked = launcher.settings?.dev_user?.trim() ?? ""; const asked = launcher.settings?.dev_user?.trim() ?? "";
@@ -221,16 +238,79 @@
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button> <button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
</p> </p>
{/if} {/if}
{#if devRun} {#if devOn}
<p class="secondary attention"> <!-- Dev mode's own controls, where the launching happens. Everything that decides
<Icon name="alert" size={14} /> what Launch does is here rather than behind Settings: the target, the checkout,
<span> and whether it builds first. -->
Dev mode: ODIAN runs from your checkout, not from the installed <section class="dev" aria-label="Dev mode">
<strong>{s?.installed ?? "version"}</strong>.{#if devUser} <header class="dev-head">
Running as <strong>{devUser}</strong> — sync is off.{/if} <Icon name="code" size={13} />
</span> <span class="dev-title">DEV</span>
</p> <!-- 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 wrap">
<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 s?.online}
<!-- The stack is the install's, so its tiles and its data are still worth
refreshing from the DHP while there is a line to it. -->
<button class="btn-link" disabled={blocked} onclick={() => launcher.syncTiles()}>Sync tiles</button>
<button class="btn-link" disabled={blocked} onclick={() => launcher.syncNow()}>Sync database</button>
{:else}
<span class="dev-offline mono">offline — tiles and data stay as they are</span>
{/if}
</div>
{#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}
{#if unfinished} {#if unfinished}
<p class="secondary attention"> <p class="secondary attention">
<Icon name="alert" size={14} /> <Icon name="alert" size={14} />
@@ -376,6 +456,114 @@
</section> </section>
<style> <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-row.wrap {
flex-wrap: wrap;
row-gap: 0.3rem;
}
.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-check {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
white-space: nowrap;
}
.dev-offline,
.dev-note {
font-size: 0.75rem;
color: var(--text-dim, #9aa3ad);
margin: 0;
}
.dev-note code {
font-size: 0.72rem;
}
.action { .action {
display: grid; display: grid;
gap: 14px; gap: 14px;
+5 -1
View File
@@ -19,7 +19,8 @@
| "signout" | "signout"
| "key" | "key"
| "globe" | "globe"
| "market"; | "market"
| "code";
</script> </script>
<script lang="ts"> <script lang="ts">
@@ -98,6 +99,9 @@
<rect x="9" 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" /> <rect x="2.5" y="9" width="4.5" height="4.5" />
<path d="M11.25 9v4.5M9 11.25h4.5" /> <path d="M11.25 9v4.5M9 11.25h4.5" />
{:else if name === "code"}
<path d="M5.75 4.5 2 8l3.75 3.5" />
<path d="M10.25 4.5 14 8l-3.75 3.5" />
{/if} {/if}
</svg> </svg>
+9 -1
View File
@@ -5,11 +5,19 @@
// It starts where the news panel ends, so the wordmark is never half-hidden // It starts where the news panel ends, so the wordmark is never half-hidden
// behind it and the panel's side of the window stays plain black. // behind it and the panel's side of the window stays plain black.
import keyart from "../assets/keyart.webp"; import keyart from "../assets/keyart.webp";
import keyartDev from "../assets/keyart-dev.webp";
import { launcher } from "../lib/launcher.svelte";
// Dev mode gets its own picture. Not decoration: the backdrop is the one thing on screen
// you cannot miss, so a glance says which launcher you are looking at before you read a
// word of it. It follows the SWITCH, not the licence - a licensed PC with dev mode off is
// an ordinary launcher and should look like one.
const art = $derived(launcher.devOn ? keyartDev : keyart);
</script> </script>
<div class="keyart" aria-hidden="true"> <div class="keyart" aria-hidden="true">
<div class="frame"> <div class="frame">
<img class="art" src={keyart} alt="" draggable="false" /> <img class="art" src={art} alt="" draggable="false" />
</div> </div>
<div class="vignette"></div> <div class="vignette"></div>
</div> </div>
-127
View File
@@ -62,7 +62,6 @@
trimmed.dev_target !== current.dev_target), trimmed.dev_target !== current.dev_target),
); );
/** A drive's launcher needs a folder on a disk: a root or a share holds no install. */ /** A drive's launcher needs a folder on a disk: a root or a share holds no install. */
const dev = $derived(launcher.status?.dev ?? null);
const offDrive = $derived(portable && trimmed.install_dir !== "" && !/^[A-Za-z]:\\.+/.test(trimmed.install_dir)); const offDrive = $derived(portable && trimmed.install_dir !== "" && !/^[A-Za-z]:\\.+/.test(trimmed.install_dir));
// Blank is a deliberate answer for all three: no DHP, no sign-in service, no AI. It is // Blank is a deliberate answer for all three: no DHP, no sign-in service, no AI. It is
// how a PC is put fully offline on purpose - and how an offline site is rehearsed on a // how a PC is put fully offline on purpose - and how an offline site is rehearsed on a
@@ -294,82 +293,6 @@
</div> </div>
</fieldset> </fieldset>
{#if dev}
<!-- Only ever rendered on a PC holding a valid signed licence. An operator's
launcher has no dev section at all, which is the point of the signature. -->
<section class="group" aria-labelledby="set-dev-title">
<h3 id="set-dev-title" class="eyebrow">Dev</h3>
<p class="text">
{#if dev.machine}This PC{:else}This install{/if} carries a dev licence, valid until
<strong>{dev.expires_at}</strong>{#if dev.note} — {dev.note}{/if}. Dev mode runs the
client from your checkout, in a console, over this install's database and tiles.
</p>
<div class="field">
<label class="field-label" for="set-dev-root">Checkout folder</label>
<div class="row">
<input
id="set-dev-root"
class="input"
type="text"
spellcheck="false"
placeholder="E:\ODIAN_Dev"
bind:value={draft.dev_root}
disabled={locked || busy}
/>
<button type="button" class="btn" disabled={locked || busy} onclick={browseDev}>Browse…</button>
</div>
<p class="hint">The folder that holds <code>ODIAN_Engine</code>.</p>
</div>
<label class="check-row">
<input type="checkbox" bind:checked={draft.dev_mode} disabled={locked || busy || !draft.dev_root.trim()} />
<span>Launch the whole stack from the checkout instead of the installed version</span>
</label>
<label class="check-row">
<input type="checkbox" bind:checked={draft.dev_build} disabled={locked || busy || !draft.dev_mode} />
<span>Build it first, every launch</span>
</label>
<div class="field">
<span class="field-label" id="set-dev-target-label">Launch starts</span>
<div class="choices" role="radiogroup" aria-labelledby="set-dev-target-label">
{#each TARGETS as option (option.key)}
<label class="choice" class:on={draft.dev_target === option.key}>
<input
type="radio"
name="dev-target"
value={option.key}
bind:group={draft.dev_target}
disabled={locked || busy || !draft.dev_mode}
/>
<span class="choice-name">{option.name}</span>
<span class="choice-note">{option.note}</span>
</label>
{/each}
</div>
</div>
<div class="field">
<label class="field-label" for="set-dev-user">Run as</label>
<input
id="set-dev-user"
class="input"
type="text"
spellcheck="false"
placeholder={launcher.status?.operator ?? "the operator who signed in"}
bind:value={draft.dev_user}
disabled={locked || busy || !draft.dev_mode}
/>
<p class="hint">
{#if draft.dev_user.trim() && draft.dev_user.trim() !== launcher.status?.operator}
This PC will <strong>not sync with the DHP</strong> while it runs as someone else: work
authored as them would arrive there under your account.
{:else}
Another operator's name, to test what they see. Sync is switched off while it differs
from yours.
{/if}
</p>
</div>
</section>
{/if}
<section class="group" aria-labelledby="set-drive-title"> <section class="group" aria-labelledby="set-drive-title">
<h3 id="set-drive-title" class="eyebrow">Install from drive</h3> <h3 id="set-drive-title" class="eyebrow">Install from drive</h3>
<p class="text"> <p class="text">
@@ -447,56 +370,6 @@
</dialog> </dialog>
<style> <style>
/* The Dev section's own rows. Only ever drawn on a licensed PC. */
.check-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin-top: 0.6rem;
line-height: 1.35;
}
.check-row input {
margin-top: 0.15rem;
flex: none;
}
.hint {
margin: 0.35rem 0 0;
font-size: 0.82rem;
line-height: 1.45;
color: var(--text-dim, #9aa3ad);
}
.choices {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-top: 0.4rem;
}
.choice {
display: grid;
grid-template-columns: auto auto 1fr;
align-items: baseline;
gap: 0.5rem;
padding: 0.4rem 0.55rem;
border: 1px solid var(--border, #2a3139);
border-radius: 6px;
cursor: pointer;
}
.choice.on {
border-color: var(--accent, #5b8cff);
background: color-mix(in srgb, var(--accent, #5b8cff) 12%, transparent);
}
.choice:has(input:disabled) {
opacity: 0.5;
cursor: default;
}
.choice-name {
font-weight: 600;
}
.choice-note {
font-size: 0.8rem;
color: var(--text-dim, #9aa3ad);
}
.drawer { .drawer {
position: fixed; position: fixed;
inset: 0 0 0 auto; inset: 0 0 0 auto;
+32
View File
@@ -16,6 +16,10 @@
const status = $derived(launcher.status); const status = $derived(launcher.status);
const checking = $derived(launcher.refreshing || status?.phase === "checking"); const checking = $derived(launcher.refreshing || status?.phase === "checking");
// The licence, not a setting: an operator's launcher must show no trace of dev mode.
const licensed = $derived((status?.dev ?? null) !== null);
const devOn = $derived(launcher.devOn);
const saving = $derived(launcher.busy === "settings");
function windowAction(task: () => Promise<void>) { function windowAction(task: () => Promise<void>) {
return () => { return () => {
@@ -80,6 +84,22 @@
<div class="drag" data-tauri-drag-region></div> <div class="drag" data-tauri-drag-region></div>
{#if licensed}
<!-- Only on a PC that holds a dev licence. An operator's launcher never draws it, which
is the whole point of the licence - so this sits behind `status.dev`, not a setting. -->
<button
class="icon-btn dev"
class:on={devOn}
aria-label={devOn ? "Dev mode is on" : "Turn dev mode on"}
title={devOn ? "Dev mode is ON - ODIAN runs from your checkout" : "Dev mode"}
aria-pressed={devOn}
disabled={saving}
onclick={() => launcher.setDev({ dev_mode: !devOn })}
>
<Icon name="code" />
</button>
{/if}
<button class="icon-btn market" aria-label="Marketplace" title="Marketplace" aria-haspopup="dialog" onclick={onOpenMarket}> <button class="icon-btn market" aria-label="Marketplace" title="Marketplace" aria-haspopup="dialog" onclick={onOpenMarket}>
<Icon name="market" /> <Icon name="market" />
</button> </button>
@@ -107,6 +127,18 @@
</header> </header>
<style> <style>
/* The dev switch. Off it is one more grey icon button; on it is unmistakably GREEN,
because "am I running my own build or the installed one" is the question this whole
mode exists to answer, and guessing it wrong wastes an afternoon. */
.icon-btn.dev.on {
color: #46d17f;
border-color: color-mix(in srgb, #46d17f 55%, transparent);
background: color-mix(in srgb, #46d17f 16%, transparent);
}
.icon-btn.dev.on:hover:not(:disabled) {
background: color-mix(in srgb, #46d17f 26%, transparent);
}
.titlebar { .titlebar {
position: relative; position: relative;
z-index: 20; z-index: 20;
+17
View File
@@ -402,6 +402,23 @@ class Launcher {
return this.launch(); return this.launch();
} }
/** Change part of the settings and save, for the dev controls on the hub. */
setDev(change: Partial<Settings>) {
const current = this.settings;
if (!current) return Promise.resolve();
return this.saveSettings({ ...current, ...change });
}
/** Dev mode is ON: this PC has a licence and the switch is thrown. */
get devOn(): boolean {
return (this.status?.dev ?? null) !== null && (this.settings?.dev_mode ?? false);
}
/** ...and a checkout has been named, so a launch will really come from it. */
get devReady(): boolean {
return this.devOn && (this.settings?.dev_root ?? "").trim() !== "";
}
saveSettings(next: Settings) { saveSettings(next: Settings) {
return this.run("settings", async () => { return this.run("settings", async () => {
this.applyStatus(await backend.setSettings(next)); this.applyStatus(await backend.setSettings(next));