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
This commit is contained in:
2026-10-09 01:24:25 -05:00
parent 6ab40a1e6a
commit 0db3a8ac73
3 changed files with 351 additions and 0 deletions

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,
@@ -395,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() {
@@ -537,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),
@@ -641,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!(
@@ -703,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() {