Files
wordcraft/crates/control-key/src/lib.rs
T
3d8f8144d9 File: recover unsaved work after a crash (#518) (#586)
* M10: retain local crash recovery on current main

* i18n: Backstage Recover in every catalog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:21:57 -04:00

88 lines
3.7 KiB
Rust

//! The control channel's key file, one rule for the app (which writes it) and the MCP bridge
//! (which reads it): `<settings>/control-key.<instance>`. See `docs/control-protocol.md`, Keys.
use std::path::{Path, PathBuf};
/// The app's settings folder (preferences, logs, key files): macOS
/// `~/Library/Application Support/WordCraft`, Windows `%APPDATA%\WordCraft`, otherwise
/// `$XDG_CONFIG_HOME/wordcraft` or `~/.config/wordcraft`.
/// `WORDCRAFT_PROFILE_DIR` overrides the entire folder for isolated runs.
pub fn settings_dir() -> Option<PathBuf> {
if let Some(path) = std::env::var_os("WORDCRAFT_PROFILE_DIR").filter(|p| !p.is_empty()) {
return Some(PathBuf::from(path));
}
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/WordCraft"))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("WordCraft"))
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.map(|c| c.join("wordcraft"))
}
}
/// The `instance-id=` value of a `/.flatpak-info` file, only when it is `[A-Za-z0-9_-]+` (it
/// becomes part of a file name).
pub fn parse_instance_id(info: &str) -> Option<String> {
let v = info.lines().find_map(|l| l.strip_prefix("instance-id="))?.trim();
(!v.is_empty() && v.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')).then(|| v.to_string())
}
/// The Flatpak instance id, or `None` outside Flatpak.
pub fn instance_id() -> Option<String> {
parse_instance_id(&std::fs::read_to_string("/.flatpak-info").ok()?)
}
/// `<settings>/control-key.<instance>`, with `<instance>` the Flatpak instance id or
/// `local-<port>`.
pub fn key_path(settings: &Path, port: u16) -> PathBuf {
key_path_for(settings, instance_id().as_deref(), port)
}
fn key_path_for(settings: &Path, instance: Option<&str>, port: u16) -> PathBuf {
match instance {
Some(id) => settings.join(format!("control-key.{id}")),
None => settings.join(format!("control-key.local-{port}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn instance_id_only_safe_values() {
assert_eq!(parse_instance_id("[Instance]\ninstance-id=abc_123-X\n").as_deref(), Some("abc_123-X"));
assert_eq!(parse_instance_id("instance-id= 42 \n").as_deref(), Some("42"));
// It ends up in a file name.
assert_eq!(parse_instance_id("instance-id=../etc\n"), None);
assert_eq!(parse_instance_id("instance-id=a/b\n"), None);
assert_eq!(parse_instance_id("instance-id=a b\n"), None);
assert_eq!(parse_instance_id("instance-id=..\n"), None);
assert_eq!(parse_instance_id("instance-id=\n"), None);
assert_eq!(parse_instance_id("app=ai.storyteller.wordcraft\n"), None);
}
#[test]
fn key_file_per_instance() {
let dir = Path::new("/settings/wordcraft");
assert_eq!(key_path_for(dir, Some("1234"), 7981), dir.join("control-key.1234"));
assert_eq!(key_path_for(dir, None, 7981), dir.join("control-key.local-7981"));
assert_eq!(key_path_for(dir, None, 7982), dir.join("control-key.local-7982"));
if instance_id().is_none() {
assert_eq!(key_path(dir, 7981), dir.join("control-key.local-7981"));
}
}
#[test]
fn settings_dir_is_the_apps_folder() {
if let Some(d) = settings_dir() {
let name = d.file_name().and_then(|n| n.to_str()).unwrap_or("");
assert!(name == "wordcraft" || name == "WordCraft", "{}", d.display());
}
}
}