feat(rust): write the recommended PopupNotifications banner (runicnpc 9e, D327, D328) #39
@@ -20,6 +20,7 @@ use anyhow::{bail, Context, Result};
|
|||||||
|
|
||||||
use super::npc;
|
use super::npc;
|
||||||
use super::plugin::{self, ConfigView};
|
use super::plugin::{self, ConfigView};
|
||||||
|
use super::popup;
|
||||||
use super::record::{ComponentRecord, DeployedFile, Instance, NpcRecord, RustRecord, SCHEMA};
|
use super::record::{ComponentRecord, DeployedFile, Instance, NpcRecord, RustRecord, SCHEMA};
|
||||||
use super::server::{self, RustServer};
|
use super::server::{self, RustServer};
|
||||||
use super::sidecar;
|
use super::sidecar;
|
||||||
@@ -49,6 +50,8 @@ struct Planned {
|
|||||||
retired_helpers: Vec<String>,
|
retired_helpers: Vec<String>,
|
||||||
/// RunicNPC, when the bundle carries it (D224): absent, identical or replaced.
|
/// RunicNPC, when the bundle carries it (D224): absent, identical or replaced.
|
||||||
npc_action: Option<BinaryAction>,
|
npc_action: Option<BinaryAction>,
|
||||||
|
/// PopupNotifications' config: the banner where it is missing or the plugin's defaults (D327).
|
||||||
|
popup: popup::State,
|
||||||
game_port: u16,
|
game_port: u16,
|
||||||
web_port: u16,
|
web_port: u16,
|
||||||
config_path: PathBuf,
|
config_path: PathBuf,
|
||||||
@@ -395,6 +398,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
|||||||
|| p.npc_action.is_some_and(|a| a.writes())
|
|| p.npc_action.is_some_and(|a| a.writes())
|
||||||
|| p.helper_actions.iter().any(|a| a.writes())
|
|| p.helper_actions.iter().any(|a| a.writes())
|
||||||
|| !p.retired_helpers.is_empty()
|
|| !p.retired_helpers.is_empty()
|
||||||
|
|| p.popup.writes()
|
||||||
})
|
})
|
||||||
.count();
|
.count();
|
||||||
if restored == 0 && !binary_action.writes() {
|
if restored == 0 && !binary_action.writes() {
|
||||||
@@ -537,10 +541,12 @@ fn plan_instance(
|
|||||||
Ok(_) => BinaryAction::Replace,
|
Ok(_) => BinaryAction::Replace,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
let popup = popup::inspect(&server.framework.config_dir(&server.path).join(popup::FILE));
|
||||||
let config_path = layout.rust_config(id);
|
let config_path = layout.rust_config(id);
|
||||||
Ok(Planned {
|
Ok(Planned {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
running: server::is_running(&server.path),
|
running: server::is_running(&server.path),
|
||||||
|
popup,
|
||||||
config_exists: config_path.exists(),
|
config_exists: config_path.exists(),
|
||||||
config_path,
|
config_path,
|
||||||
db_path: layout.rust_db(id),
|
db_path: layout.rust_db(id),
|
||||||
@@ -641,6 +647,18 @@ fn print_plan(
|
|||||||
if p.config_exists { "kept" } else { "written" }
|
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(
|
ui::row(
|
||||||
"ports",
|
"ports",
|
||||||
&format!(
|
&format!(
|
||||||
@@ -703,6 +721,26 @@ fn deploy_instance(
|
|||||||
ui::ok(&format!("wrote {}", plugin_config_path.display()));
|
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.
|
// The sidecar's config, once; the token is the sidecar's to generate.
|
||||||
if !plan.config_exists {
|
if !plan.config_exists {
|
||||||
if let Some(parent) = plan.config_path.parent() {
|
if let Some(parent) = plan.config_path.parent() {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ mod doctor;
|
|||||||
mod install;
|
mod install;
|
||||||
mod npc;
|
mod npc;
|
||||||
mod plugin;
|
mod plugin;
|
||||||
|
mod popup;
|
||||||
mod record;
|
mod record;
|
||||||
mod server;
|
mod server;
|
||||||
mod sidecar;
|
mod sidecar;
|
||||||
|
|||||||
312
src/rustgame/popup.rs
Normal file
312
src/rustgame/popup.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user