10 Commits

Author SHA1 Message Date
736ca0c4c2 Merge pull request 'fix(cli): game-neutral --help for --game and --site-url' (#43) from fix/help-neutral-wording into main
All checks were successful
sync-project-tree / sync (push) Successful in 9s
Release installer / release (push) Successful in 9m12s
Compose bundle / compose (push) Successful in 38s
Reviewed-on: #43
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-22 19:47:36 +00:00
6366a44bc4 fix(cli): game-neutral --help for --game and --site-url
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m50s
`--help` describes options that apply to both games, but it used Ultima
Online's words for them:

- `--game` read "Which game's shard side". It now reads "Which game this
  run sets up".
- `--site-url` named "Admin → Shard (uo-link)", which is the ServUO page.
  A Rust run links /admin/rust/servers. It now says the link is to the
  admin page the run prints at the end.
- The opening line said "a ServUO shard", and the closing line said "your
  shard". They now say "a ServUO server" and "your game server".

The per-game hand-off blocks are unchanged. The ServUO hand-off still names
Admin → Shard, because that is that page's real label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 14:43:38 -05:00
d282666d6e Merge pull request 'chore(release): cut edge over to main — doctor names the RaidableBases helper' (#42) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in -2m19s
Release installer / release (push) Successful in 15m34s
Reviewed-on: #42
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-22 10:24:12 +00:00
bd199a7dc1 Merge pull request 'feat(rust): name what a missing RaidableBases helper costs in doctor (PLAN_REDESIGNS §11.4)' (#41) from feat/raidable-bases into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m38s
Reviewed-on: #41
2026-10-17 06:33:19 +00:00
c9b773f79b feat(rust): name what a missing RaidableBases helper costs in doctor (PLAN_REDESIGNS §11.4)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m26s
RunicGatewayRaids.cs ships beside the bridge from this release (Rust-Plugins
release.yml lists it in the manifest's files), so doctor reports it like the
other helpers: without it the bridge still reports raid bases, but an
event's base cannot be placed, set or locked and despawns on its own timer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-16 15:18:53 -05:00
f1bbd46cbd Merge pull request 'feat(rust): RunicNPC required in the Rust bundle, the banner, edge → main (runicnpc 9e step 3, merge 2 of 3)' (#40) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in -1m53s
Release installer / release (push) Successful in 13m8s
Compose bundle / compose (push) Successful in -1m55s
Reviewed-on: #40
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-09 07:21:41 +00:00
bd73930584 Merge pull request 'feat(rust): write the recommended PopupNotifications banner (runicnpc 9e, D327, D328)' (#39) from feat/9e-popup-banner into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m19s
Reviewed-on: #39
2026-10-09 06:29:49 +00:00
0db3a8ac73 feat(rust): write the recommended PopupNotifications banner (runicnpc 9e, D327, D328)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m5s
install and update write PopupNotifications.json into the framework's config
directory, as a banner across the top of the screen, only where the file is
missing or every setting is still PopupNotifications 0.2.1's own default (the
Version aside, numbers compared as numbers). A config with any setting
changed, or one that does not parse, is kept whole. The file carries the
plugin's Version, because PopupNotifications resets a config without one to
its defaults on load. The plan prints a "popup look" row naming the case.

Run against two scratch roots (a temporary local bypass of the RunicNPC check,
since no published bundle carries RunicNPC yet): Oxide with no config got the
banner; Carbon at the rig's defaults got the walk's banner file exactly; on
update a hand-changed config and an existing banner were both kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-09 01:24:25 -05:00
6ab40a1e6a Merge pull request 'feat(rust): RunicNPC is required in the Rust bundle (runicnpc stage 9, D310)' (#38) from feat/runicnpc-required into edge
Reviewed-on: #38
2026-10-07 02:42:56 +00:00
04bab67778 feat(rust): RunicNPC is required in the Rust bundle (runicnpc stage 9, D310)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m13s
- install/update refuse a Rust bundle that carries no RunicNPC, before anything
  is written, and say how to get one that does.
- doctor fails when RunicNPC is missing or was never installed; a file edited
  by hand stays a warning, since it still loads.
- compose-bundles.sh composes no Rust bundle without a RunicNPC release that
  answers the bridge's runicnpc_api (the published one stays), and fails a
  bridge that declares none; every Rust bundle now has `npc`.
- A RunicNPC API-mismatch message lost its line continuation; restored.

The bundle type still parses a bundle without `npc`, so doctor and an old pin
can name it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-06 16:58:31 -05:00
9 changed files with 428 additions and 59 deletions

View File

@@ -377,44 +377,39 @@ compose_rust() {
[ "$link_protocol" = "$plugin_protocol" ] || fail \
"PROTOCOL MISMATCH — Rust-Link ${link_tag} speaks ${link_protocol}, Rust-Plugins ${plugin_tag} declares ${plugin_protocol}. The game link has no 409: a mismatched plugin would mis-parse. Fix: land the matching half and let its release cut, or bump Rust-Plugins/overlay.toml."
# RunicNPC (docs/runicnpc/PLAN.md D224): a third file, optional until its stage 9.
# The bridge's manifest says which RunicNPC API its npc.* commands need
# (`runicnpc_api`, from overlay.toml); the latest RunicNPC release is carried
# only when it answers at least that. A bridge that needs none carries none: a
# RunicNPC it cannot talk to would be an NPC plugin nobody manages.
local npc='null' need npc_tag npc_tarball npc_manifest npc_api npc_carried=''
# RunicNPC (docs/runicnpc/PLAN.md D224): a third file, REQUIRED since its stage 9
# (D310) — the bridge refuses every NPC an event places without it, and the
# installer refuses a Rust bundle that does not carry it. The bridge's manifest
# says which RunicNPC API it needs (`runicnpc_api`, from overlay.toml); the
# latest RunicNPC release is carried only when it answers at least that. With
# none that does, the Rust bundle is not composed: the published one stays.
local npc need npc_tag npc_tarball npc_manifest npc_api
need="$(jq -r '.runicnpc_api // 0' "$manifest")"
if [ "$need" -gt 0 ]; then
rc=0
resolve_latest "$RUNICNPC_REPO" runicnpc || rc=$?
if [ "$rc" -eq 2 ]; then
note "- **Rust**: the bridge needs RunicNPC API ${need}, and RunicNPC has not released yet — the bundle carries none."
else
npc_tag="$(release_tag runicnpc)"
verify_assets runicnpc
npc_tarball="$(single_tarball runicnpc)"
npc_manifest="$(payload_manifest runicnpc "$npc_tarball" "$RUNICNPC_PREFIX" "$npc_tag")"
[ -f "$(dirname "$npc_manifest")/RunicNPC.cs" ] \
|| fail "the RunicNPC tarball has no ${RUNICNPC_PREFIX}/RunicNPC.cs"
npc_api="$(jq -r '.api' "$npc_manifest")"
echo "==> rust: RunicNPC ${npc_tag} api=${npc_api} | the bridge needs ${need}"
if [ "$npc_api" -lt "$need" ]; then
note "- **Rust**: RunicNPC \`${npc_tag}\` answers API ${npc_api} and the bridge needs ${need} — the bundle carries none until RunicNPC releases one that does."
else
npc="$(jq -n --arg repo "$RUNICNPC_REPO" --arg tag "$npc_tag" --slurpfile m "$npc_manifest" \
--argjson asset "$(asset_json runicnpc "$npc_tarball")" \
'{ repo: $repo, tag: $tag, version: ($tag | ltrimstr("v")), commit: $m[0].commit, api: $m[0].api,
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
carbon: { min_version: $m[0].min_carbon_version } },
requires_plugins: $m[0].requires_plugins },
asset: $asset }')"
jq -e '([.commit, .compat.frameworks[].min_version] | all(type == "string"))
and (.api | type == "number") and (.compat.requires_plugins | type == "array")' <<<"$npc" >/dev/null \
|| fail "the RunicNPC manifest is missing commit, api, a framework floor, or requires_plugins"
npc_carried="$npc_tag"
fi
fi
fi
[ "$need" -gt 0 ] || fail \
"Rust-Plugins ${plugin_tag} declares no runicnpc_api, and RunicNPC is required (D310) — a bridge older than RunicNPC's stage 4 cannot be bundled any more."
rc=0
resolve_latest "$RUNICNPC_REPO" runicnpc || rc=$?
[ "$rc" -ne 2 ] || fail "the bridge needs RunicNPC API ${need}, and RunicNPC has not released — RunicNPC is required (D310)."
npc_tag="$(release_tag runicnpc)"
verify_assets runicnpc
npc_tarball="$(single_tarball runicnpc)"
npc_manifest="$(payload_manifest runicnpc "$npc_tarball" "$RUNICNPC_PREFIX" "$npc_tag")"
[ -f "$(dirname "$npc_manifest")/RunicNPC.cs" ] \
|| fail "the RunicNPC tarball has no ${RUNICNPC_PREFIX}/RunicNPC.cs"
npc_api="$(jq -r '.api' "$npc_manifest")"
echo "==> rust: RunicNPC ${npc_tag} api=${npc_api} | the bridge needs ${need}"
[ "$npc_api" -ge "$need" ] || fail \
"RunicNPC ${npc_tag} answers API ${npc_api} and Rust-Plugins ${plugin_tag} needs ${need} — RunicNPC is required (D310), so no Rust bundle is composed until it releases one that does."
npc="$(jq -n --arg repo "$RUNICNPC_REPO" --arg tag "$npc_tag" --slurpfile m "$npc_manifest" \
--argjson asset "$(asset_json runicnpc "$npc_tarball")" \
'{ repo: $repo, tag: $tag, version: ($tag | ltrimstr("v")), commit: $m[0].commit, api: $m[0].api,
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
carbon: { min_version: $m[0].min_carbon_version } },
requires_plugins: $m[0].requires_plugins },
asset: $asset }')"
jq -e '([.commit, .compat.frameworks[].min_version] | all(type == "string"))
and (.api | type == "number") and (.compat.requires_plugins | type == "array")' <<<"$npc" >/dev/null \
|| fail "the RunicNPC manifest is missing commit, api, a framework floor, or requires_plugins"
jq -n \
--arg link_repo "$RUST_LINK_REPO" --arg link_tag "$link_tag" \
@@ -432,8 +427,8 @@ compose_rust() {
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
carbon: { min_version: $m[0].min_carbon_version } },
requires_plugins: $m[0].requires_plugins },
asset: $p_asset } }
+ (if $npc == null then {} else { npc: $npc } end)' > "$WORK/rust-s2.json"
asset: $p_asset },
npc: $npc }' > "$WORK/rust-s2.json"
# A manifest missing a key would compose a `null` the installer then trusts.
jq -e '([.payload.compat.frameworks[].min_version, .payload.commit] | all(type == "string"))
and (.payload.compat.requires_plugins | type == "array")' "$WORK/rust-s2.json" >/dev/null \
@@ -446,7 +441,7 @@ compose_rust() {
local tag
tag="$(fresh_tag "$PUBLISHED/v2/rust")"
publish_doc "$PUBLISHED/v2/rust" "$tag" "$WORK/rust-s2.json"
note "- **Rust**: published \`${tag}\` — Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol}${npc_carried:+, RunicNPC \`${npc_tag}\`}."
note "- **Rust**: published \`${tag}\` — Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol}, RunicNPC \`${npc_tag}\`."
echo "rust ${tag}" >> "$WORK/published-games.txt"
}

View File

@@ -19,7 +19,7 @@
# current.json, bundle-<tag>.json schema 1, ServUO: link + overlay
# v2/servuo/current.json, bundle-<tag>.json schema 2, game "servuo"
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust": Rust-Link + Rust-Plugins,
# and RunicNPC when the bridge needs it (D224)
# and RunicNPC, required since its stage 9 (D224, D310)
#
# Schema 2 (D146) names ONE game, with a `game` discriminant and a `payload`
# that is an overlay for ServUO and a plugin for Rust. Schema 1 (D147) is still

View File

@@ -362,8 +362,9 @@ pub struct RustBundle {
pub sidecar: RustSidecar,
pub payload: PluginPayload,
/// RunicNPC, Runic Gateway's NPC plugin (docs/runicnpc/PLAN.md D224): a third file placed in
/// the plugins directory beside the bridge. Optional until RunicNPC's stage 9 makes it
/// required, so a bundle without it — every one before stage 4 — still parses and installs.
/// the plugins directory beside the bridge. Required since RunicNPC's stage 9 (D310): a bundle
/// without it — every one before stage 4 — still PARSES, so `doctor` and an old pin can name
/// it, but `install` and `update` refuse it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub npc: Option<NpcComponent>,
}

View File

@@ -56,7 +56,7 @@ pub enum PatchChoice {
No,
}
/// Which game's shard side this run is about (docs/modules/rust/PLAN.md §34.2.3). ServUO is the
/// Which game this run sets up (docs/modules/rust/PLAN.md §34.2.3). ServUO is the
/// default, so every command line written before Rust existed means exactly what it meant.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Game {
@@ -94,7 +94,8 @@ pub struct Cli {
pub patches_unsupported_servuo: bool,
/// `--host <name>`: the hostname to print in the website URLs.
pub host: Option<String>,
/// `--site-url <url>`: the site's base URL, for the Admin → Shard (uo-link) link.
/// `--site-url <url>`: the site's base URL, printed as a link to the admin page that takes
/// the values.
pub site_url: Option<String>,
/// `--yes`: assume the default answer to every prompt.
pub assume_yes: bool,
@@ -128,7 +129,7 @@ impl Default for Cli {
}
pub const USAGE: &str = "\
Runic Gateway installer — connects a ServUO shard, or Rust servers, to a Runic
Runic Gateway installer — connects a ServUO server, or Rust servers, to a Runic
Gateway website.
Usage: runicgateway-installer <COMMAND> [OPTIONS]
@@ -144,7 +145,7 @@ Commands:
removes only its own plugin file and keeps the plugin config.
Options:
--game <servuo|rust> Which game's shard side. Default servuo.
--game <servuo|rust> Which game this run sets up. Default servuo.
--verify install, update. Dry run: report every
change that would be made, write nothing.
--servuo <PATH> install, doctor, update. The ServUO root,
@@ -161,7 +162,7 @@ Options:
--host <NAME> install. The hostname to print in the
website URLs.
--site-url <URL> install. Your site's base URL, for the
Admin → Shard (uo-link) link.
admin page link it prints at the end.
--yes Assume the default answer to every prompt.
On uninstall it means yes: that prompt
defaults to no, and typing `uninstall
@@ -194,7 +195,7 @@ Environment:
set it.
The installer never contacts your website, never deletes anything from your
ServUO tree, and never starts or stops your shard.
ServUO tree, and never starts or stops your game server.
";
/// Parses arguments **without** the program name.

View File

@@ -178,9 +178,10 @@ fn instance_rows(
}
}
// ── RunicNPC: ours when the bundle carried it (D224) ──────────────────────
// A warning, like a helper's: until RunicNPC's stage 9 the bridge runs without it, and what is
// lost is the site's NPC profiles and placements and events' profile NPCs.
// ── RunicNPC: ours, and required (D224, D310) ─────────────────────────────
// A failure when it is missing or was never placed: since RunicNPC's stage 9 the bridge
// refuses every NPC an event places without it. A file edited by hand still loads, so that
// stays a warning, as the bridge's own helpers are.
if let Some(file) = &instance.npc {
let tag = record.npc.as_ref().map(|n| n.tag.as_str()).unwrap_or("?");
match std::fs::read(&file.path) {
@@ -192,11 +193,17 @@ fn instance_rows(
.note("edited or replaced by hand; `update --game rust` puts the released one back"),
),
Err(_) => rows.push(
Row::warn(&label("RunicNPC"), format!("{} is missing", file.path))
.note("without it the site's NPC profiles and placements, and events' profile NPCs, are off")
Row::fail(&label("RunicNPC"), format!("{} is missing", file.path))
.note("RunicNPC is required: without it every NPC an event places is refused, Rust's own scientists included")
.note("`update --game rust` puts it back"),
),
}
} else {
rows.push(
Row::fail(&label("RunicNPC"), "not installed")
.note("RunicNPC is required: without it every NPC an event places is refused, Rust's own scientists included")
.note("`update --game rust` installs it from the current bundle"),
);
}
// ── The plugin's config: the website's ───────────────────────────────────
@@ -340,6 +347,11 @@ fn helper_absence(name: &str) -> String {
an event zone, because ZoneDomes cannot be called without it"
.to_string()
}
"RunicGatewayRaids.cs" => {
"the bridge still runs and still reports raid bases, but an event's RaidableBases base \
is not the event's: it cannot be placed, set or locked, and despawns on its own timer"
.to_string()
}
_ => format!("the bridge still runs without {name}; what it helped with falls back"),
}
}

View File

@@ -20,6 +20,7 @@ use anyhow::{bail, Context, Result};
use super::npc;
use super::plugin::{self, ConfigView};
use super::popup;
use super::record::{ComponentRecord, DeployedFile, Instance, NpcRecord, RustRecord, SCHEMA};
use super::server::{self, RustServer};
use super::sidecar;
@@ -49,6 +50,8 @@ struct Planned {
retired_helpers: Vec<String>,
/// RunicNPC, when the bundle carries it (D224): absent, identical or replaced.
npc_action: Option<BinaryAction>,
/// PopupNotifications' config: the banner where it is missing or the plugin's defaults (D327).
popup: popup::State,
game_port: u16,
web_port: u16,
config_path: PathBuf,
@@ -183,7 +186,9 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
);
}
// RunicNPC (D224), fetched and checked beside the plugin, before anything is planned.
// RunicNPC (D224), fetched and checked beside the plugin, before anything is planned. It is
// required since RunicNPC's stage 9 (D310): the bridge refuses every NPC an event places
// without it, so a bundle that cannot place it is refused before anything is written.
let npc_released = match &bundle.npc {
Some(component) => {
let path = scratch.path().join(&component.asset.name);
@@ -191,7 +196,8 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
let released = npc::read_tarball(&path)?;
if released.manifest.api != component.api {
bail!(
"RunicNPC in bundle {} answers API {}, but the bundle says {} — refusing a component the bundle does not describe.",
"RunicNPC in bundle {} answers API {}, but the bundle says {} — refusing a \
component the bundle does not describe.",
bundle.bundle,
released.manifest.api,
component.api
@@ -205,7 +211,12 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
}
Some(released)
}
None => None,
None => bail!(
"bundle {} carries no RunicNPC, and Runic Gateway's Rust events need it: without it \
the bridge refuses every NPC an event places. Install the current bundle (leave \
--bundle off), or pin one published with RunicNPC v1.0.0 or later.",
bundle.bundle
),
};
// ── Plan every instance ──────────────────────────────────────────────────
@@ -387,6 +398,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|| p.npc_action.is_some_and(|a| a.writes())
|| p.helper_actions.iter().any(|a| a.writes())
|| !p.retired_helpers.is_empty()
|| p.popup.writes()
})
.count();
if restored == 0 && !binary_action.writes() {
@@ -529,10 +541,12 @@ fn plan_instance(
Ok(_) => BinaryAction::Replace,
},
);
let popup = popup::inspect(&server.framework.config_dir(&server.path).join(popup::FILE));
let config_path = layout.rust_config(id);
Ok(Planned {
id: id.to_string(),
running: server::is_running(&server.path),
popup,
config_exists: config_path.exists(),
config_path,
db_path: layout.rust_db(id),
@@ -633,6 +647,18 @@ fn print_plan(
if p.config_exists { "kept" } else { "written" }
),
);
ui::row(
"popup look",
&format!(
"{} {}",
p.server
.framework
.config_dir(&p.server.path)
.join(popup::FILE)
.display(),
p.popup.label()
),
);
ui::row(
"ports",
&format!(
@@ -695,6 +721,26 @@ fn deploy_instance(
ui::ok(&format!("wrote {}", plugin_config_path.display()));
}
// PopupNotifications' look (D327): the banner, only over nothing or over the plugin's own
// defaults. Before the plugins, so a PopupNotifications that loads after them reads it; one
// already loaded keeps its old look until it is reloaded.
if let Some(banner) = popup::banner_for(plan.popup) {
let dir = plan.server.framework.config_dir(&plan.server.path);
std::fs::create_dir_all(&dir)
.with_context(|| format!("cannot create {}", dir.display()))?;
let path = dir.join(popup::FILE);
write_atomic(&path, banner.as_bytes())?;
ui::ok(&format!(
"wrote the PopupNotifications banner {}{}",
path.display(),
if plan.running {
" (takes effect when PopupNotifications next loads)"
} else {
""
}
));
}
// The sidecar's config, once; the token is the sidecar's to generate.
if !plan.config_exists {
if let Some(parent) = plan.config_path.parent() {

View File

@@ -15,6 +15,7 @@ mod doctor;
mod install;
mod npc;
mod plugin;
mod popup;
mod record;
mod server;
mod sidecar;

View File

@@ -8,8 +8,9 @@
//! it. RunicNPC makes that directory itself on first load, because one made from outside the game
//! is not writable by it (runicnpc PLAN.md §1.5).
//!
//! It is optional until RunicNPC's stage 9: a bundle without it installs as before, and a host
//! without it runs the bridge with Rust's own scientists only (D243).
//! It is required since RunicNPC's stage 9 (D310): the bridge refuses every NPC an event places
//! without it, Rust's own scientists included, so `install` and `update` refuse a bundle that does
//! not carry it and `doctor` fails a host that lacks it.
use std::collections::BTreeMap;
use std::io::Read;

312
src/rustgame/popup.rs Normal file
View File

@@ -0,0 +1,312 @@
//! The recommended PopupNotifications look: a banner across the top of the screen (docs/runicnpc/
//! PLAN.md stage 9e, D327).
//!
//! PopupNotifications is optional and the operator's to install (D141); its config is the
//! operator's too. The installer writes the banner into it only where it is **missing** or **still
//! exactly the plugin's own defaults** — every setting equal to 0.2.1's, the `Version` aside (D328).
//! A config with any one setting changed is somebody's choice and is kept whole, never merged into.
//! A config that does not parse is kept too: it is not ours to repair.
//!
//! The file carries a `Version`: PopupNotifications resets a config whose version is missing or
//! below 0.2.0 to its defaults on load, which would undo the banner at the first boot.
use std::path::Path;
use serde_json::Value;
/// The config's file name, in the framework's config directory beside the bridge's.
pub const FILE: &str = "PopupNotifications.json";
/// What is there now, and so what happens to it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
/// No config: the banner is written, and PopupNotifications reads it when it is installed.
Missing,
/// The plugin's own defaults, untouched: the banner replaces them. Holds the file's version.
Defaults(Version),
/// Changed by someone, or unreadable: kept.
Theirs,
}
/// PopupNotifications' `VersionNumber`, as its config writes it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Version {
pub major: u64,
pub minor: u64,
pub patch: u64,
}
/// The version written into a config made from nothing: the release the defaults are read from.
const WRITTEN_VERSION: Version = Version {
major: 0,
minor: 2,
patch: 1,
};
impl State {
pub fn writes(self) -> bool {
!matches!(self, Self::Theirs)
}
pub fn label(self) -> &'static str {
match self {
Self::Missing => "banner written (no config yet)",
Self::Defaults(_) => "banner written (was the plugin's defaults)",
Self::Theirs => "kept (not the plugin's defaults)",
}
}
}
/// Reads the config at `path` and says what it is.
pub fn inspect(path: &Path) -> State {
match std::fs::read(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => State::Missing,
Err(_) => State::Theirs,
Ok(bytes) => classify(&bytes),
}
}
fn classify(bytes: &[u8]) -> State {
let Ok(Value::Object(mut found)) = serde_json::from_slice::<Value>(bytes) else {
return State::Theirs;
};
let version = found.remove("Version");
let Ok(Value::Object(mut defaults)) = serde_json::from_str::<Value>(&render(DEFAULTS, None))
else {
unreachable!("the defaults are valid JSON")
};
defaults.remove("Version");
if !same(&Value::Object(found), &Value::Object(defaults)) {
return State::Theirs;
}
State::Defaults(
version
.as_ref()
.and_then(read_version)
.unwrap_or(WRITTEN_VERSION),
)
}
fn read_version(v: &Value) -> Option<Version> {
Some(Version {
major: v.get("Major")?.as_u64()?,
minor: v.get("Minor")?.as_u64()?,
patch: v.get("Patch")?.as_u64()?,
})
}
/// JSON equality with numbers compared as numbers: the plugin writes its floats through
/// Newtonsoft, and a value written `1.0` by one framework and `1` by another is the same setting.
fn same(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
(Some(x), Some(y)) => (x - y).abs() < 1e-6,
_ => false,
},
(Value::Object(x), Value::Object(y)) => {
x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same(v, w)))
}
(Value::Array(x), Value::Array(y)) => {
x.len() == y.len() && x.iter().zip(y).all(|(v, w)| same(v, w))
}
_ => a == b,
}
}
/// The banner, as the file to write for `state`. `None` for a config that is kept.
pub fn banner_for(state: State) -> Option<String> {
match state {
State::Missing => Some(render(BANNER, Some(WRITTEN_VERSION))),
State::Defaults(version) => Some(render(BANNER, Some(version))),
State::Theirs => None,
}
}
/// One look: the eleven settings PopupNotifications has.
struct Look {
left: &'static str,
bottom: &'static str,
width: &'static str,
height: &'static str,
spacing: &'static str,
close: bool,
alpha: &'static str,
font: &'static str,
font_size: u32,
}
/// PopupNotifications 0.2.1's `GetBaseConfig()`: a small grey box at the right of the screen.
const DEFAULTS: Look = Look {
left: "0.8",
bottom: "0.78",
width: "0.19",
height: "0.1",
spacing: "0.01",
close: true,
alpha: "0.5",
font: "droidsansmono.ttf",
font_size: 12,
};
/// The recommended look (9d player session, 2026-10-09): a banner across the top of the screen.
const BANNER: Look = Look {
left: "0.15",
bottom: "0.87",
width: "0.7",
height: "0.07",
spacing: "0.005",
close: false,
alpha: "0.8",
font: "robotocondensed-bold.ttf",
font_size: 18,
};
/// The config file, laid out as the plugin writes it. Duration, the most shown, both colours and
/// the close button's transparency are the plugin's own in both looks.
fn render(look: Look, version: Option<Version>) -> String {
let version = version.unwrap_or(WRITTEN_VERSION);
format!(
r##"{{
"Notification duration (in seconds)": 8,
"Maximum notifications shown at any time": 6,
"UI Positioning": {{
"Position of the left side of notification (0.0 - 1.0)": {left},
"Position of the bottom of noticiation (0.0 - 1.0)": {bottom},
"Width (0.0 - 1.0)": {width},
"Height (0.0 - 1.0)": {height},
"Space between notification (0.0 - 1.0)": {spacing}
}},
"UI Options": {{
"Show close button": {close},
"Panel color (hex)": "#2b2b2b",
"Panel transparency (0.0 - 1.0)": {alpha},
"Close button color (hex)": "#d85540",
"Close button transparency (0.0 - 1.0)": 0.5,
"Font": "{font}",
"Font size": {font_size}
}},
"Version": {{
"Major": {major},
"Minor": {minor},
"Patch": {patch}
}}
}}"##,
left = look.left,
bottom = look.bottom,
width = look.width,
height = look.height,
spacing = look.spacing,
close = look.close,
alpha = look.alpha,
font = look.font,
font_size = look.font_size,
major = version.major,
minor = version.minor,
patch = version.patch,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
/// The file PopupNotifications 0.2.1 wrote on both rigs at its first load (2026-10-09).
const RIG_DEFAULTS: &str = r##"{
"Notification duration (in seconds)": 8,
"Maximum notifications shown at any time": 6,
"UI Positioning": {
"Position of the left side of notification (0.0 - 1.0)": 0.8,
"Position of the bottom of noticiation (0.0 - 1.0)": 0.78,
"Width (0.0 - 1.0)": 0.19,
"Height (0.0 - 1.0)": 0.1,
"Space between notification (0.0 - 1.0)": 0.01
},
"UI Options": {
"Show close button": true,
"Panel color (hex)": "#2b2b2b",
"Panel transparency (0.0 - 1.0)": 0.5,
"Close button color (hex)": "#d85540",
"Close button transparency (0.0 - 1.0)": 0.5,
"Font": "droidsansmono.ttf",
"Font size": 12
},
"Version": {
"Major": 0,
"Minor": 2,
"Patch": 1
}
}"##;
#[test]
fn a_missing_config_gets_the_banner_with_a_version() {
let dir = TempDir::new("rg-popup-missing").unwrap();
let state = inspect(&dir.path().join(FILE));
assert_eq!(state, State::Missing);
let written: Value = serde_json::from_str(&banner_for(state).unwrap()).unwrap();
assert_eq!(written["UI Positioning"]["Width (0.0 - 1.0)"], 0.7);
assert_eq!(written["UI Options"]["Show close button"], false);
assert_eq!(written["UI Options"]["Font"], "robotocondensed-bold.ttf");
assert_eq!(written["UI Options"]["Font size"], 18);
// Without a version the plugin resets the file to its defaults at load.
assert_eq!(written["Version"]["Minor"], 2);
}
#[test]
fn the_plugins_own_defaults_are_replaced_and_their_version_kept() {
assert_eq!(
classify(RIG_DEFAULTS.as_bytes()),
State::Defaults(WRITTEN_VERSION)
);
let later = RIG_DEFAULTS.replace("\"Patch\": 1", "\"Patch\": 4");
let state = classify(later.as_bytes());
assert_eq!(
state,
State::Defaults(Version {
major: 0,
minor: 2,
patch: 4
})
);
assert!(banner_for(state).unwrap().contains("\"Patch\": 4"));
// A whole number written as a float is still the default.
let floats = RIG_DEFAULTS.replace("\"Font size\": 12", "\"Font size\": 12.0");
assert!(matches!(classify(floats.as_bytes()), State::Defaults(_)));
}
#[test]
fn the_rendered_defaults_are_the_file_the_plugin_writes() {
let ours: Value = serde_json::from_str(&render(DEFAULTS, None)).unwrap();
let rig: Value = serde_json::from_str(RIG_DEFAULTS).unwrap();
assert_eq!(ours, rig);
}
#[test]
fn any_change_keeps_the_whole_config() {
for changed in [
RIG_DEFAULTS.replace("\"Font size\": 12", "\"Font size\": 14"),
RIG_DEFAULTS.replace(
"\"Notification duration (in seconds)\": 8",
"\"Notification duration (in seconds)\": 9",
),
RIG_DEFAULTS.replace("#2b2b2b", "#000000"),
render(BANNER, None),
"{ not json".to_string(),
"[]".to_string(),
] {
let state = classify(changed.as_bytes());
assert_eq!(state, State::Theirs, "{changed}");
assert_eq!(banner_for(state), None);
}
}
#[test]
fn a_config_with_a_setting_added_or_missing_is_theirs() {
let mut v: Value = serde_json::from_str(RIG_DEFAULTS).unwrap();
v["Extra"] = Value::Bool(true);
assert_eq!(classify(v.to_string().as_bytes()), State::Theirs);
let mut v: Value = serde_json::from_str(RIG_DEFAULTS).unwrap();
v.as_object_mut().unwrap().remove("UI Options");
assert_eq!(classify(v.to_string().as_bytes()), State::Theirs);
}
}