Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1bbd46cbd | |||
| bd73930584 | |||
| 0db3a8ac73 | |||
| 6ab40a1e6a | |||
| 04bab67778 | |||
| 2567cf5dd3 | |||
| ab2efb62e4 |
@@ -64,12 +64,14 @@ SERVUO_LINK_REPO="RunicGateway/link"
|
|||||||
SERVUO_OVERLAY_REPO="RunicGateway/servuo-plugins"
|
SERVUO_OVERLAY_REPO="RunicGateway/servuo-plugins"
|
||||||
RUST_LINK_REPO="RunicGateway/Rust-Link"
|
RUST_LINK_REPO="RunicGateway/Rust-Link"
|
||||||
RUST_PLUGIN_REPO="RunicGateway/Rust-Plugins"
|
RUST_PLUGIN_REPO="RunicGateway/Rust-Plugins"
|
||||||
|
RUNICNPC_REPO="RunicGateway/runicnpc-rust"
|
||||||
|
|
||||||
# Fixed top-level directories inside each payload tarball. Deliberately NOT
|
# Fixed top-level directories inside each payload tarball. Deliberately NOT
|
||||||
# versioned — a versioned prefix would mean parsing the version out of a path in
|
# versioned — a versioned prefix would mean parsing the version out of a path in
|
||||||
# order to read the manifest that declares the version.
|
# order to read the manifest that declares the version.
|
||||||
SERVUO_OVERLAY_PREFIX="runicgateway-overlay"
|
SERVUO_OVERLAY_PREFIX="runicgateway-overlay"
|
||||||
RUST_PLUGIN_PREFIX="runicgateway-rust-plugin"
|
RUST_PLUGIN_PREFIX="runicgateway-rust-plugin"
|
||||||
|
RUNICNPC_PREFIX="runicnpc"
|
||||||
|
|
||||||
mkdir -p "$WORK" "$PUBLISHED"
|
mkdir -p "$WORK" "$PUBLISHED"
|
||||||
: > "$WORK/summary.md"
|
: > "$WORK/summary.md"
|
||||||
@@ -375,6 +377,40 @@ compose_rust() {
|
|||||||
[ "$link_protocol" = "$plugin_protocol" ] || fail \
|
[ "$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."
|
"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, 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")"
|
||||||
|
[ "$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 \
|
jq -n \
|
||||||
--arg link_repo "$RUST_LINK_REPO" --arg link_tag "$link_tag" \
|
--arg link_repo "$RUST_LINK_REPO" --arg link_tag "$link_tag" \
|
||||||
--argjson assets "$assets" --argjson launcher "$launcher" \
|
--argjson assets "$assets" --argjson launcher "$launcher" \
|
||||||
@@ -382,6 +418,7 @@ compose_rust() {
|
|||||||
--slurpfile m "$manifest" \
|
--slurpfile m "$manifest" \
|
||||||
--argjson p_asset "$(asset_json rust-plugin "$tarball")" \
|
--argjson p_asset "$(asset_json rust-plugin "$tarball")" \
|
||||||
--argjson protocol "$link_protocol" \
|
--argjson protocol "$link_protocol" \
|
||||||
|
--argjson npc "$npc" \
|
||||||
'{ schema: 2, game: "rust", protocol: $protocol,
|
'{ schema: 2, game: "rust", protocol: $protocol,
|
||||||
sidecar: { repo: $link_repo, tag: $link_tag, version: ($link_tag | ltrimstr("v")),
|
sidecar: { repo: $link_repo, tag: $link_tag, version: ($link_tag | ltrimstr("v")),
|
||||||
protocol: $protocol, assets: $assets, launcher: $launcher },
|
protocol: $protocol, assets: $assets, launcher: $launcher },
|
||||||
@@ -390,7 +427,8 @@ compose_rust() {
|
|||||||
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
|
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
|
||||||
carbon: { min_version: $m[0].min_carbon_version } },
|
carbon: { min_version: $m[0].min_carbon_version } },
|
||||||
requires_plugins: $m[0].requires_plugins },
|
requires_plugins: $m[0].requires_plugins },
|
||||||
asset: $p_asset } }' > "$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.
|
# 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"))
|
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 \
|
and (.payload.compat.requires_plugins | type == "array")' "$WORK/rust-s2.json" >/dev/null \
|
||||||
@@ -403,7 +441,7 @@ compose_rust() {
|
|||||||
local tag
|
local tag
|
||||||
tag="$(fresh_tag "$PUBLISHED/v2/rust")"
|
tag="$(fresh_tag "$PUBLISHED/v2/rust")"
|
||||||
publish_doc "$PUBLISHED/v2/rust" "$tag" "$WORK/rust-s2.json"
|
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}."
|
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"
|
echo "rust ${tag}" >> "$WORK/published-games.txt"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
#
|
#
|
||||||
# current.json, bundle-<tag>.json schema 1, ServUO: link + overlay
|
# current.json, bundle-<tag>.json schema 1, ServUO: link + overlay
|
||||||
# v2/servuo/current.json, bundle-<tag>.json schema 2, game "servuo"
|
# 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
|
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust": Rust-Link + Rust-Plugins,
|
||||||
|
# and RunicNPC, required since its stage 9 (D224, D310)
|
||||||
#
|
#
|
||||||
# Schema 2 (D146) names ONE game, with a `game` discriminant and a `payload`
|
# 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
|
# that is an overlay for ServUO and a plugin for Rust. Schema 1 (D147) is still
|
||||||
@@ -56,8 +57,8 @@
|
|||||||
# the push.
|
# the push.
|
||||||
#
|
#
|
||||||
# ── Triggers (PLAN.md §7.2) ──────────────────────────────────────────────────
|
# ── Triggers (PLAN.md §7.2) ──────────────────────────────────────────────────
|
||||||
# workflow_dispatch — POSTed by link's, servuo-plugins', Rust-Link's and
|
# workflow_dispatch — POSTed by link's, servuo-plugins', Rust-Link's,
|
||||||
# Rust-Plugins' release workflows as their final step,
|
# Rust-Plugins' and runicnpc-rust's release workflows as their final step,
|
||||||
# so a new release recomposes the bundle immediately.
|
# so a new release recomposes the bundle immediately.
|
||||||
# schedule (nightly) — recomputes from whatever the latest releases actually
|
# schedule (nightly) — recomputes from whatever the latest releases actually
|
||||||
# are, so a missed or failed dispatch self-heals instead
|
# are, so a missed or failed dispatch self-heals instead
|
||||||
|
|||||||
@@ -361,6 +361,27 @@ pub struct RustBundle {
|
|||||||
pub protocol: u32,
|
pub protocol: u32,
|
||||||
pub sidecar: RustSidecar,
|
pub sidecar: RustSidecar,
|
||||||
pub payload: PluginPayload,
|
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. 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One exact RunicNPC release, chosen by CI to answer at least the API the bridge's
|
||||||
|
/// `runicnpc_api` asks for.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct NpcComponent {
|
||||||
|
pub repo: String,
|
||||||
|
pub tag: String,
|
||||||
|
pub version: String,
|
||||||
|
pub commit: String,
|
||||||
|
/// The RunicNPC API it answers (`RunicNpc_ApiVersion`).
|
||||||
|
pub api: u32,
|
||||||
|
pub compat: PluginCompat,
|
||||||
|
/// One tarball: `runicnpc/{RunicNPC.cs, manifest.json}`.
|
||||||
|
pub asset: Asset,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -485,6 +506,14 @@ pub fn parse_rust(body: &str) -> Result<RustBundle> {
|
|||||||
bundle.bundle
|
bundle.bundle
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(npc) = &bundle.npc {
|
||||||
|
if npc.asset.url.is_empty() || npc.asset.sha256.is_empty() {
|
||||||
|
bail!(
|
||||||
|
"Rust bundle {} names a RunicNPC asset with no URL or checksum",
|
||||||
|
bundle.bundle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(bundle)
|
Ok(bundle)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,6 +618,37 @@ mod tests {
|
|||||||
assert_eq!(v2, v1);
|
assert_eq!(v2, v1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rust_bundle_without_runicnpc_still_parses() {
|
||||||
|
let bundle = parse_rust(RUST).unwrap();
|
||||||
|
assert!(bundle.npc.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rust_bundle_with_runicnpc_parses_and_is_checked() {
|
||||||
|
let mut value: serde_json::Value = serde_json::from_str(RUST).unwrap();
|
||||||
|
value["npc"] = serde_json::json!({
|
||||||
|
"repo": "RunicGateway/runicnpc-rust",
|
||||||
|
"tag": "v0.2.0",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"commit": "abc",
|
||||||
|
"api": 3,
|
||||||
|
"compat": {
|
||||||
|
"frameworks": { "oxide": { "min_version": "2.0.7726" }, "carbon": { "min_version": "2.0.259" } },
|
||||||
|
"requires_plugins": ["Kits"]
|
||||||
|
},
|
||||||
|
"asset": { "name": "runicnpc-0.2.0.tar.gz", "url": "https://x/runicnpc-0.2.0.tar.gz", "sha256": "ab" }
|
||||||
|
});
|
||||||
|
let bundle = parse_rust(&value.to_string()).unwrap();
|
||||||
|
let npc = bundle.npc.as_ref().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(npc.api, npc.compat.requires_plugins.clone()),
|
||||||
|
(3, vec!["Kits".to_string()])
|
||||||
|
);
|
||||||
|
value["npc"]["asset"]["sha256"] = serde_json::json!("");
|
||||||
|
assert!(parse_rust(&value.to_string()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_rust_bundle_is_refused_by_the_servuo_reader() {
|
fn a_rust_bundle_is_refused_by_the_servuo_reader() {
|
||||||
let err = parse(RUST).unwrap_err().to_string();
|
let err = parse(RUST).unwrap_err().to_string();
|
||||||
|
|||||||
@@ -178,6 +178,34 @@ fn instance_rows(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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) {
|
||||||
|
Ok(bytes) if crate::util::sha256_bytes(&bytes) == file.sha256 => {
|
||||||
|
rows.push(Row::ok(&label("RunicNPC"), format!("{} (runicnpc-rust {tag})", file.path)))
|
||||||
|
}
|
||||||
|
Ok(_) => rows.push(
|
||||||
|
Row::warn(&label("RunicNPC"), format!("{} is not the file that was deployed", file.path))
|
||||||
|
.note("edited or replaced by hand; `update --game rust` puts the released one back"),
|
||||||
|
),
|
||||||
|
Err(_) => rows.push(
|
||||||
|
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 ───────────────────────────────────
|
// ── The plugin's config: the website's ───────────────────────────────────
|
||||||
match plugin::read_config(Path::new(&instance.plugin_config)) {
|
match plugin::read_config(Path::new(&instance.plugin_config)) {
|
||||||
Ok(Some(view)) if view.server_id.as_deref() == Some(id) => rows.push(Row::ok(
|
Ok(Some(view)) if view.server_id.as_deref() == Some(id) => rows.push(Row::ok(
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
|
|
||||||
|
use super::npc;
|
||||||
use super::plugin::{self, ConfigView};
|
use super::plugin::{self, ConfigView};
|
||||||
use super::record::{ComponentRecord, DeployedFile, Instance, RustRecord, SCHEMA};
|
use super::popup;
|
||||||
|
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;
|
||||||
use crate::bundle::{self, RustBundle};
|
use crate::bundle::{self, RustBundle};
|
||||||
@@ -46,6 +48,10 @@ struct Planned {
|
|||||||
helper_actions: Vec<BinaryAction>,
|
helper_actions: Vec<BinaryAction>,
|
||||||
/// Helpers this instance's record holds that the release no longer ships: removed.
|
/// Helpers this instance's record holds that the release no longer ships: removed.
|
||||||
retired_helpers: Vec<String>,
|
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,
|
game_port: u16,
|
||||||
web_port: u16,
|
web_port: u16,
|
||||||
config_path: PathBuf,
|
config_path: PathBuf,
|
||||||
@@ -141,6 +147,16 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
|||||||
bundle.payload.protocol
|
bundle.payload.protocol
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if let Some(npc) = &bundle.npc {
|
||||||
|
ui::row(
|
||||||
|
"RunicNPC",
|
||||||
|
&format!(
|
||||||
|
"{:<24} API {}",
|
||||||
|
format!("runicnpc-rust {}", npc.tag),
|
||||||
|
npc.api
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// The plugin is fetched before anything is planned: its manifest is the last statement of the
|
// The plugin is fetched before anything is planned: its manifest is the last statement of the
|
||||||
// protocol to check, and a pair that disagrees must stop the run before any file moves.
|
// protocol to check, and a pair that disagrees must stop the run before any file moves.
|
||||||
@@ -170,6 +186,39 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
crate::net::download_verified(&component.asset.url, &path, &component.asset.sha256)?;
|
||||||
|
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.",
|
||||||
|
bundle.bundle,
|
||||||
|
released.manifest.api,
|
||||||
|
component.api
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if released.manifest.version != component.version {
|
||||||
|
ui::warn(&format!(
|
||||||
|
"the RunicNPC tarball says version {} but bundle {} names {}. The checksum matched, \n so this is a labelling mismatch in the release rather than a wrong download.",
|
||||||
|
released.manifest.version, bundle.bundle, component.version
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(released)
|
||||||
|
}
|
||||||
|
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 ──────────────────────────────────────────────────
|
// ── Plan every instance ──────────────────────────────────────────────────
|
||||||
let mut planned = Vec::new();
|
let mut planned = Vec::new();
|
||||||
for (id, root) in &targets {
|
for (id, root) in &targets {
|
||||||
@@ -180,6 +229,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
|||||||
id,
|
id,
|
||||||
root,
|
root,
|
||||||
&released,
|
&released,
|
||||||
|
npc_released.as_ref(),
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
let binary_action = crate::sidecar::decide(&asset, &layout.rust_sidecar_bin)?;
|
let binary_action = crate::sidecar::decide(&asset, &layout.rust_sidecar_bin)?;
|
||||||
@@ -229,6 +279,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&bundle,
|
&bundle,
|
||||||
&released,
|
&released,
|
||||||
|
npc_released.as_ref(),
|
||||||
plan,
|
plan,
|
||||||
binary_action.writes(),
|
binary_action.writes(),
|
||||||
prior
|
prior
|
||||||
@@ -288,6 +339,16 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
|||||||
&bundle.payload.commit,
|
&bundle.payload.commit,
|
||||||
bundle.payload.protocol,
|
bundle.payload.protocol,
|
||||||
);
|
);
|
||||||
|
// A bundle without RunicNPC leaves a recorded one where it is: it may be an operator's.
|
||||||
|
if let Some(component) = &bundle.npc {
|
||||||
|
record.npc = Some(NpcRecord {
|
||||||
|
repo: component.repo.clone(),
|
||||||
|
tag: component.tag.clone(),
|
||||||
|
version: component.version.clone(),
|
||||||
|
commit: component.commit.clone(),
|
||||||
|
api: component.api,
|
||||||
|
});
|
||||||
|
}
|
||||||
record.service_user_created |= prepared.user_created;
|
record.service_user_created |= prepared.user_created;
|
||||||
|
|
||||||
match prior.as_ref() {
|
match prior.as_ref() {
|
||||||
@@ -334,8 +395,10 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|p| {
|
.filter(|p| {
|
||||||
p.plugin_action.writes()
|
p.plugin_action.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() {
|
||||||
@@ -377,6 +440,7 @@ fn plan_instance(
|
|||||||
id: &str,
|
id: &str,
|
||||||
root: &Path,
|
root: &Path,
|
||||||
released: &plugin::Released,
|
released: &plugin::Released,
|
||||||
|
npc_released: Option<&npc::Released>,
|
||||||
) -> Result<Planned> {
|
) -> Result<Planned> {
|
||||||
let server = server::open(root)
|
let server = server::open(root)
|
||||||
.with_context(|| format!("cannot use {} as a Rust server root", root.display()))?;
|
.with_context(|| format!("cannot use {} as a Rust server root", root.display()))?;
|
||||||
@@ -466,10 +530,23 @@ fn plan_instance(
|
|||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
// RunicNPC as the plugin is: put back when it was deleted or edited (D224).
|
||||||
|
let npc_action =
|
||||||
|
npc_released.map(
|
||||||
|
|r| match std::fs::read(server.plugins_dir().join(npc::FILE)) {
|
||||||
|
Err(_) => BinaryAction::Install,
|
||||||
|
Ok(bytes) if crate::util::sha256_bytes(&bytes) == r.sha256 => {
|
||||||
|
BinaryAction::Unchanged
|
||||||
|
}
|
||||||
|
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),
|
||||||
@@ -478,6 +555,7 @@ fn plan_instance(
|
|||||||
plugin_action,
|
plugin_action,
|
||||||
helper_actions,
|
helper_actions,
|
||||||
retired_helpers,
|
retired_helpers,
|
||||||
|
npc_action,
|
||||||
game_port,
|
game_port,
|
||||||
web_port,
|
web_port,
|
||||||
})
|
})
|
||||||
@@ -536,6 +614,16 @@ fn print_plan(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(action) = p.npc_action {
|
||||||
|
ui::row(
|
||||||
|
"RunicNPC",
|
||||||
|
&format!(
|
||||||
|
"{} {}",
|
||||||
|
p.server.plugins_dir().join(npc::FILE).display(),
|
||||||
|
action.label()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
ui::row(
|
ui::row(
|
||||||
"plugin config",
|
"plugin config",
|
||||||
&match &p.plugin_config {
|
&match &p.plugin_config {
|
||||||
@@ -559,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!(
|
||||||
@@ -566,10 +666,16 @@ fn print_plan(
|
|||||||
p.game_port, p.web_port
|
p.game_port, p.web_port
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
let missing = plugin::missing_plugins(
|
// RunicNPC's own requirements join the bridge's: it will not load without Kits (D217).
|
||||||
&p.server.plugins_dir(),
|
let mut required = bundle.payload.compat.requires_plugins.clone();
|
||||||
&bundle.payload.compat.requires_plugins,
|
if let Some(component) = &bundle.npc {
|
||||||
);
|
for name in &component.compat.requires_plugins {
|
||||||
|
if !required.contains(name) {
|
||||||
|
required.push(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let missing = plugin::missing_plugins(&p.server.plugins_dir(), &required);
|
||||||
if !missing.is_empty() {
|
if !missing.is_empty() {
|
||||||
ui::warn(&format!(
|
ui::warn(&format!(
|
||||||
"{} not in {} — the features that use {} stay off until you install {} from uMod. \
|
"{} not in {} — the features that use {} stay off until you install {} from uMod. \
|
||||||
@@ -584,11 +690,13 @@ fn print_plan(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Writes one instance: plugin config, sidecar config, service, plugin — in that order.
|
/// Writes one instance: plugin config, sidecar config, service, plugin — in that order.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn deploy_instance(
|
fn deploy_instance(
|
||||||
layout: &paths::Layout,
|
layout: &paths::Layout,
|
||||||
prepared: &service::Prepared,
|
prepared: &service::Prepared,
|
||||||
bundle: &RustBundle,
|
bundle: &RustBundle,
|
||||||
released: &plugin::Released,
|
released: &plugin::Released,
|
||||||
|
npc_released: Option<&npc::Released>,
|
||||||
plan: &Planned,
|
plan: &Planned,
|
||||||
binary_changed: bool,
|
binary_changed: bool,
|
||||||
plugin_config_written_before: bool,
|
plugin_config_written_before: bool,
|
||||||
@@ -613,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() {
|
||||||
@@ -758,6 +886,35 @@ fn deploy_instance(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunicNPC before the bridge (D224): the bridge reports it at hello, and one already loaded is
|
||||||
|
// one its first hello names. Written in place, for the plugin's reason below.
|
||||||
|
let npc_file = match (npc_released, plan.npc_action) {
|
||||||
|
(Some(r), Some(action)) => {
|
||||||
|
let path = plan.server.plugins_dir().join(npc::FILE);
|
||||||
|
if action.writes() {
|
||||||
|
std::fs::create_dir_all(plan.server.plugins_dir()).with_context(|| {
|
||||||
|
format!("cannot create {}", plan.server.plugins_dir().display())
|
||||||
|
})?;
|
||||||
|
std::fs::write(&path, &r.source)
|
||||||
|
.with_context(|| format!("cannot write {}", path.display()))?;
|
||||||
|
ui::ok(&format!(
|
||||||
|
"RunicNPC {} {}",
|
||||||
|
if action == BinaryAction::Replace {
|
||||||
|
"replaced"
|
||||||
|
} else {
|
||||||
|
"installed"
|
||||||
|
},
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(DeployedFile {
|
||||||
|
path: path.display().to_string(),
|
||||||
|
sha256: r.sha256.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
// The plugin last: it loads the moment it lands, and its sidecar is now there to dial.
|
// The plugin last: it loads the moment it lands, and its sidecar is now there to dial.
|
||||||
let plugin_path = plan.server.plugin_path();
|
let plugin_path = plan.server.plugin_path();
|
||||||
if plan.plugin_action.writes() {
|
if plan.plugin_action.writes() {
|
||||||
@@ -791,6 +948,7 @@ fn deploy_instance(
|
|||||||
plugin_path: plugin_path.display().to_string(),
|
plugin_path: plugin_path.display().to_string(),
|
||||||
plugin_sha256: released.sha256.clone(),
|
plugin_sha256: released.sha256.clone(),
|
||||||
helpers,
|
helpers,
|
||||||
|
npc: npc_file,
|
||||||
plugin_config: plugin_config_path.display().to_string(),
|
plugin_config: plugin_config_path.display().to_string(),
|
||||||
plugin_config_written: wrote_plugin_config || plugin_config_written_before,
|
plugin_config_written: wrote_plugin_config || plugin_config_written_before,
|
||||||
game_port: plan.game_port,
|
game_port: plan.game_port,
|
||||||
@@ -832,6 +990,7 @@ fn empty_record(bundle: &RustBundle, url: &str) -> RustRecord {
|
|||||||
sha256: String::new(),
|
sha256: String::new(),
|
||||||
},
|
},
|
||||||
plugin: component("", "", "", "", 0),
|
plugin: component("", "", "", "", 0),
|
||||||
|
npc: None,
|
||||||
service_user_created: false,
|
service_user_created: false,
|
||||||
instances: Default::default(),
|
instances: Default::default(),
|
||||||
extra: Default::default(),
|
extra: Default::default(),
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
|
|
||||||
mod doctor;
|
mod doctor;
|
||||||
mod install;
|
mod install;
|
||||||
|
mod npc;
|
||||||
mod plugin;
|
mod plugin;
|
||||||
|
mod popup;
|
||||||
mod record;
|
mod record;
|
||||||
mod server;
|
mod server;
|
||||||
mod sidecar;
|
mod sidecar;
|
||||||
|
|||||||
172
src/rustgame/npc.rs
Normal file
172
src/rustgame/npc.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
//! RunicNPC: Runic Gateway's NPC plugin, a third file beside the bridge (docs/runicnpc/PLAN.md
|
||||||
|
//! D224, stage 4).
|
||||||
|
//!
|
||||||
|
//! **`RunicNPC.cs` is the installer's**, like the bridge and its helpers: it comes from the bundle,
|
||||||
|
//! is replaced when the bundle moves, is put back by `update` when it was edited or deleted, and is
|
||||||
|
//! removed by `uninstall`. Its DATA is not: `data/RunicNPC/` holds an admin's placements and
|
||||||
|
//! routes, which RunicNPC writes and the site edits through the bridge, and nothing here touches
|
||||||
|
//! 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 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;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::util::sha256_bytes;
|
||||||
|
|
||||||
|
/// The fixed top directory inside RunicNPC's release tarball (runicnpc-rust's release.yml).
|
||||||
|
const PREFIX: &str = "runicnpc";
|
||||||
|
|
||||||
|
/// The one file placed, in the same plugins directory as the bridge.
|
||||||
|
pub const FILE: &str = "RunicNPC.cs";
|
||||||
|
|
||||||
|
/// `runicnpc/manifest.json`, which RunicNPC's release folds from `plugin.toml`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Manifest {
|
||||||
|
pub version: String,
|
||||||
|
pub api: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub files: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RunicNPC's released file, read out of its tarball.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Released {
|
||||||
|
pub manifest: Manifest,
|
||||||
|
pub source: Vec<u8>,
|
||||||
|
pub sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads RunicNPC and its manifest out of a downloaded tarball, and checks the two agree.
|
||||||
|
pub fn read_tarball(path: &Path) -> Result<Released> {
|
||||||
|
let file =
|
||||||
|
std::fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
|
||||||
|
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file));
|
||||||
|
let mut manifest: Option<Vec<u8>> = None;
|
||||||
|
let mut source: Option<Vec<u8>> = None;
|
||||||
|
for entry in archive
|
||||||
|
.entries()
|
||||||
|
.context("the RunicNPC tarball is not a tar.gz")?
|
||||||
|
{
|
||||||
|
let mut entry = entry.context("the RunicNPC tarball is truncated")?;
|
||||||
|
let name = entry.path()?.to_string_lossy().replace('\\', "/");
|
||||||
|
if name == format!("{PREFIX}/manifest.json") {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
entry.read_to_end(&mut bytes)?;
|
||||||
|
manifest = Some(bytes);
|
||||||
|
} else if name == format!("{PREFIX}/{FILE}") {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
entry.read_to_end(&mut bytes)?;
|
||||||
|
source = Some(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let manifest = manifest
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("the RunicNPC tarball has no {PREFIX}/manifest.json"))?;
|
||||||
|
let source =
|
||||||
|
source.ok_or_else(|| anyhow::anyhow!("the RunicNPC tarball has no {PREFIX}/{FILE}"))?;
|
||||||
|
let manifest: Manifest =
|
||||||
|
serde_json::from_slice(&manifest).context("RunicNPC's manifest.json is unreadable")?;
|
||||||
|
let sha256 = sha256_bytes(&source);
|
||||||
|
match manifest.files.get(FILE) {
|
||||||
|
Some(declared) if declared.eq_ignore_ascii_case(&sha256) => {}
|
||||||
|
Some(declared) => bail!(
|
||||||
|
"RunicNPC in the tarball does not match its own manifest (sha256 {sha256}, manifest \
|
||||||
|
says {declared}). The tarball matched the bundle's checksum, so the release itself is \
|
||||||
|
inconsistent — refusing it."
|
||||||
|
),
|
||||||
|
None => bail!("RunicNPC's manifest does not list {FILE} — refusing a release that does not say what it ships"),
|
||||||
|
}
|
||||||
|
Ok(Released {
|
||||||
|
manifest,
|
||||||
|
source,
|
||||||
|
sha256,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::util::TempDir;
|
||||||
|
|
||||||
|
fn tarball(dir: &Path, entries: &[(&str, &[u8])]) -> std::path::PathBuf {
|
||||||
|
let path = dir.join("runicnpc.tar.gz");
|
||||||
|
let file = std::fs::File::create(&path).unwrap();
|
||||||
|
let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
|
||||||
|
file,
|
||||||
|
flate2::Compression::default(),
|
||||||
|
));
|
||||||
|
for (name, bytes) in entries {
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(bytes.len() as u64);
|
||||||
|
header.set_mode(0o644);
|
||||||
|
header.set_cksum();
|
||||||
|
builder
|
||||||
|
.append_data(&mut header, format!("{PREFIX}/{name}"), *bytes)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
builder.into_inner().unwrap().finish().unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest(sha: &str) -> Vec<u8> {
|
||||||
|
serde_json::json!({
|
||||||
|
"component": "runicnpc", "version": "0.2.0", "commit": "abc", "api": 3,
|
||||||
|
"requires_plugins": ["Kits"], "files": { FILE: sha }
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
.into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_release_is_read_and_its_file_checked_against_its_manifest() {
|
||||||
|
let dir = TempDir::new("rg-npc").unwrap();
|
||||||
|
let source = b"// Requires: Kits\nclass RunicNPC {}";
|
||||||
|
let good = tarball(
|
||||||
|
dir.path(),
|
||||||
|
&[
|
||||||
|
("manifest.json", &manifest(&sha256_bytes(source))),
|
||||||
|
(FILE, source),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let released = read_tarball(&good).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(released.manifest.api, released.manifest.version.as_str()),
|
||||||
|
(3, "0.2.0")
|
||||||
|
);
|
||||||
|
assert_eq!(released.source, source);
|
||||||
|
|
||||||
|
let bad = tarball(
|
||||||
|
dir.path(),
|
||||||
|
&[
|
||||||
|
("manifest.json", &manifest(&"00".repeat(32))),
|
||||||
|
(FILE, source),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert!(read_tarball(&bad)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("does not match its own manifest"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_release_without_the_file_or_the_manifest_is_refused() {
|
||||||
|
let dir = TempDir::new("rg-npc-missing").unwrap();
|
||||||
|
let only_manifest = tarball(dir.path(), &[("manifest.json", &manifest("ab"))]);
|
||||||
|
assert!(read_tarball(&only_manifest)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("has no runicnpc/RunicNPC.cs"));
|
||||||
|
let only_file = tarball(dir.path(), &[(FILE, b"x")]);
|
||||||
|
assert!(read_tarball(&only_file)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("manifest.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,9 @@ pub struct RustRecord {
|
|||||||
pub sidecar: ComponentRecord,
|
pub sidecar: ComponentRecord,
|
||||||
pub binary: BinaryRef,
|
pub binary: BinaryRef,
|
||||||
pub plugin: ComponentRecord,
|
pub plugin: ComponentRecord,
|
||||||
|
/// RunicNPC's release, when the bundle carried one (docs/runicnpc/PLAN.md D224).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub npc: Option<NpcRecord>,
|
||||||
/// This installer created the shared `runicgateway` service user. Kept here rather than on each
|
/// This installer created the shared `runicgateway` service user. Kept here rather than on each
|
||||||
/// instance: the account outlives any one instance and may also run ServUO's sidecar, so only
|
/// instance: the account outlives any one instance and may also run ServUO's sidecar, so only
|
||||||
/// the last Rust instance's removal — on a host with no ServUO record — may delete it.
|
/// the last Rust instance's removal — on a host with no ServUO record — may delete it.
|
||||||
@@ -51,6 +54,17 @@ pub struct ComponentRecord {
|
|||||||
pub protocol: u32,
|
pub protocol: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which RunicNPC release is deployed, and the API it answers.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct NpcRecord {
|
||||||
|
pub repo: String,
|
||||||
|
pub tag: String,
|
||||||
|
pub version: String,
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub commit: String,
|
||||||
|
pub api: u32,
|
||||||
|
}
|
||||||
|
|
||||||
/// One Rust server and its sidecar.
|
/// One Rust server and its sidecar.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct Instance {
|
pub struct Instance {
|
||||||
@@ -64,6 +78,9 @@ pub struct Instance {
|
|||||||
/// plugin. Absent from a record written before helpers existed, which reads back as none.
|
/// plugin. Absent from a record written before helpers existed, which reads back as none.
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
pub helpers: BTreeMap<String, DeployedFile>,
|
pub helpers: BTreeMap<String, DeployedFile>,
|
||||||
|
/// `RunicNPC.cs`, when the bundle carried RunicNPC. Its data directory is not the installer's.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub npc: Option<DeployedFile>,
|
||||||
/// The plugin's config — the website's file, recorded so `doctor` knows where to look.
|
/// The plugin's config — the website's file, recorded so `doctor` knows where to look.
|
||||||
pub plugin_config: String,
|
pub plugin_config: String,
|
||||||
/// The installer wrote that config (it did not exist). Informational; it is kept either way.
|
/// The installer wrote that config (it did not exist). Informational; it is kept either way.
|
||||||
@@ -154,6 +171,7 @@ mod tests {
|
|||||||
plugin_path: format!("{root}/oxide/plugins/RunicGateway.cs"),
|
plugin_path: format!("{root}/oxide/plugins/RunicGateway.cs"),
|
||||||
plugin_sha256: "ab".repeat(32),
|
plugin_sha256: "ab".repeat(32),
|
||||||
helpers: BTreeMap::new(),
|
helpers: BTreeMap::new(),
|
||||||
|
npc: None,
|
||||||
plugin_config: format!("{root}/oxide/config/RunicGateway.json"),
|
plugin_config: format!("{root}/oxide/config/RunicGateway.json"),
|
||||||
plugin_config_written: true,
|
plugin_config_written: true,
|
||||||
game_port: game,
|
game_port: game,
|
||||||
@@ -191,6 +209,7 @@ mod tests {
|
|||||||
commit: "abc".into(),
|
commit: "abc".into(),
|
||||||
protocol: 12,
|
protocol: 12,
|
||||||
},
|
},
|
||||||
|
npc: None,
|
||||||
service_user_created: true,
|
service_user_created: true,
|
||||||
instances: BTreeMap::from([
|
instances: BTreeMap::from([
|
||||||
("alpha".to_string(), instance("/srv/a", 7799, 8090)),
|
("alpha".to_string(), instance("/srv/a", 7799, 8090)),
|
||||||
@@ -211,6 +230,34 @@ mod tests {
|
|||||||
assert!(!text.contains("auth_token") && !text.contains("token\""));
|
assert!(!text.contains("auth_token") && !text.contains("token\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_record_with_runicnpc_round_trips_and_one_without_loads_as_none() {
|
||||||
|
let dir = TempDir::new("rg-rust-record-npc").unwrap();
|
||||||
|
let path = dir.path().join("install.json");
|
||||||
|
let mut record = sample();
|
||||||
|
record.npc = Some(NpcRecord {
|
||||||
|
repo: "RunicGateway/runicnpc-rust".into(),
|
||||||
|
tag: "v0.2.0".into(),
|
||||||
|
version: "0.2.0".into(),
|
||||||
|
commit: "abc".into(),
|
||||||
|
api: 3,
|
||||||
|
});
|
||||||
|
record.instances.get_mut("alpha").unwrap().npc = Some(DeployedFile {
|
||||||
|
path: "/srv/a/oxide/plugins/RunicNPC.cs".into(),
|
||||||
|
sha256: "ef".repeat(32),
|
||||||
|
});
|
||||||
|
record.save(&path).unwrap();
|
||||||
|
assert_eq!(RustRecord::load(&path).unwrap().unwrap(), record);
|
||||||
|
|
||||||
|
// A record from before stage 4 has neither key, and reads back as none.
|
||||||
|
let old = sample();
|
||||||
|
old.save(&path).unwrap();
|
||||||
|
let text = std::fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(!text.contains("\"npc\""));
|
||||||
|
let back = RustRecord::load(&path).unwrap().unwrap();
|
||||||
|
assert!(back.npc.is_none() && back.instances.values().all(|i| i.npc.is_none()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_record_written_before_helpers_loads_with_none() {
|
fn a_record_written_before_helpers_loads_with_none() {
|
||||||
// Every host installed before D182 has one of these: no `helpers` key at all.
|
// Every host installed before D182 has one of these: no `helpers` key at all.
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ pub fn run(cli: &Cli) -> Result<i32> {
|
|||||||
for helper in i.helpers.values() {
|
for helper in i.helpers.values() {
|
||||||
println!(" · remove {}", helper.path);
|
println!(" · remove {}", helper.path);
|
||||||
}
|
}
|
||||||
|
if let Some(npc) = &i.npc {
|
||||||
|
println!(
|
||||||
|
" · remove {} (RunicNPC; its placements in data/RunicNPC/ are kept)",
|
||||||
|
npc.path
|
||||||
|
);
|
||||||
|
}
|
||||||
println!(
|
println!(
|
||||||
" · keep {} (the website's; it names this server)",
|
" · keep {} (the website's; it names this server)",
|
||||||
i.plugin_config
|
i.plugin_config
|
||||||
@@ -114,6 +120,10 @@ pub fn run(cli: &Cli) -> Result<i32> {
|
|||||||
for helper in instance.helpers.values() {
|
for helper in instance.helpers.values() {
|
||||||
remove_file(Path::new(&helper.path), &mut done, &mut problems);
|
remove_file(Path::new(&helper.path), &mut done, &mut problems);
|
||||||
}
|
}
|
||||||
|
// RunicNPC is the installer's file (D224); its data directory is the admins', and stays.
|
||||||
|
if let Some(npc) = &instance.npc {
|
||||||
|
remove_file(Path::new(&npc.path), &mut done, &mut problems);
|
||||||
|
}
|
||||||
remove_file(Path::new(&instance.plugin_path), &mut done, &mut problems);
|
remove_file(Path::new(&instance.plugin_path), &mut done, &mut problems);
|
||||||
if cli.purge {
|
if cli.purge {
|
||||||
remove_file(Path::new(&instance.config_path), &mut done, &mut problems);
|
remove_file(Path::new(&instance.config_path), &mut done, &mut problems);
|
||||||
|
|||||||
Reference in New Issue
Block a user