Files
figcraft/crates/collab-net/src/links.rs
T
BFlatandClaude Opus 5.5 435415770f M9: live sessions in the browser; join links and dialog polish (#106)
- Browser client (step 4): the web build joins sessions a desktop app hosts
  through the browser's WebSocket API (web-sys, binary frames, the same
  message encoding). Its connection logic is a target-independent state
  machine (collab-net driver.rs): hello on open, changes and presence held
  until the welcome, throttled presence, keep-alive pings, a silent host
  counted as dropped, reconnect with backoff, final refusals. Tested natively
  against a scripted socket and a real host over loopback. Hosting stays
  desktop-only (LiveLink::can_host; collab.peers canHost). https pages are
  told up front that they can only reach ws:// sessions on this computer.
- Shared join logic (join.rs: GuestConfig, outcomes, taking in host messages)
  and transport limits (limits.rs) used by the native guest and the browser.
- Invite links: `figcraft figcraft://join?...` joins on start (a bad link
  opens the Join dialog with it); file:// arguments are paths. Windows
  registers a per-user figcraft:// opener (HKCU\Software\Classes\figcraft)
  behind a Registry trait (tests use a fake): collab.registerLinks {on?} and a
  Preferences checkbox, default on for installed builds, never for builds
  from source. macOS Info.plist declares CFBundleURLTypes; the Linux desktop
  entry declares x-scheme-handler/figcraft and passes %U.
- Join dialog: Escape closes it and no longer reaches the canvas (selection
  kept); its field has the keyboard when it opens.
- Docs: collab-protocol (browser client, links, mixed content), mcp,
  control-protocol, README, web hosting notes, collaboration wording in the
  progress docs (numbers unchanged).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:10:59 +07:00

127 lines
4.5 KiB
Rust

//! Making the desktop app the opener of `figcraft://` invite links (the engine's
//! [`LinkRegistrar`]). On Windows that's a per-user URL scheme under
//! `HKEY_CURRENT_USER\Software\Classes\figcraft` (no administrator rights; uninstalling the user's
//! registration removes nothing else); the shell then starts `figcraft.exe "<link>"`, and the app
//! joins the session it names. macOS and Linux declare the scheme in their packages
//! (`CFBundleURLTypes`, the desktop entry's `x-scheme-handler/figcraft`), so there is nothing to
//! register at run time.
//!
//! The registry sits behind [`Registry`] so tests use a fake: nothing here touches the real
//! registry unless the app installs [`system`]'s registrar and the user (or an installed build's
//! default) asks for it.
use std::path::{Path, PathBuf};
use figcraft_engine::invite_links::LinkRegistrar;
/// The scheme's key under the current user's hive.
pub const KEY: &str = r"Software\Classes\figcraft";
/// String values in a registry hive (the current user's).
pub trait Registry: Send {
/// The value `name` of `key` (`""`: the key's default value), if there is one.
fn get(&self, key: &str, name: &str) -> Option<String>;
/// Set the string value `name` of `key`, creating the key.
fn set(&mut self, key: &str, name: &str, value: &str) -> Result<(), String>;
/// Delete `key` and everything under it.
fn remove_tree(&mut self, key: &str) -> Result<(), String>;
}
/// The app's registration as the opener of `figcraft://` links.
pub struct UrlScheme<R> {
reg: R,
exe: PathBuf,
installed: bool,
}
impl<R: Registry> UrlScheme<R> {
/// For the app at `exe`; `installed` sets the preference's default (see [`installed_build`]).
pub fn new(reg: R, exe: PathBuf, installed: bool) -> Self {
Self { reg, exe, installed }
}
/// The command the shell runs for a link.
pub fn command(&self) -> String {
format!("\"{}\" \"%1\"", self.exe.display())
}
fn values(&self) -> [(String, &'static str, String); 4] {
[
(KEY.into(), "", "URL:FigCraft invite link".into()),
(KEY.into(), "URL Protocol", String::new()),
(format!(r"{KEY}\DefaultIcon"), "", format!("\"{}\",0", self.exe.display())),
(format!(r"{KEY}\shell\open\command"), "", self.command()),
]
}
/// The registry, for tests.
pub fn registry(&self) -> &R {
&self.reg
}
}
impl<R: Registry> LinkRegistrar for UrlScheme<R> {
fn registered(&self) -> bool {
self.reg.get(&format!(r"{KEY}\shell\open\command"), "").is_some_and(|c| c.eq_ignore_ascii_case(&self.command()))
}
fn installed(&self) -> bool {
self.installed
}
fn set(&mut self, on: bool) -> Result<(), String> {
if on {
for (key, name, value) in self.values() {
self.reg.set(&key, name, &value)?;
}
Ok(())
} else if self.registered() {
self.reg.remove_tree(KEY)
} else {
// Another copy's registration (or none): not ours to remove.
Ok(())
}
}
}
/// `exe` is an installed copy of the app: a release build that isn't inside a Cargo build
/// directory (which holds a `CACHEDIR.TAG`), so `cargo run` and test builds never register.
pub fn installed_build(exe: &Path) -> bool {
!cfg!(debug_assertions) && !exe.ancestors().skip(1).any(|d| d.join("CACHEDIR.TAG").is_file())
}
/// This machine's registrar for the running app, where the app registers the scheme itself
/// (Windows); `None` elsewhere.
pub fn system() -> Option<Box<dyn LinkRegistrar>> {
#[cfg(windows)]
{
let exe = std::env::current_exe().ok()?;
let installed = installed_build(&exe);
Some(Box::new(UrlScheme::new(hkcu::CurrentUser, exe, installed)))
}
#[cfg(not(windows))]
{
None
}
}
#[cfg(windows)]
mod hkcu {
use windows_registry::CURRENT_USER;
/// `HKEY_CURRENT_USER`.
pub struct CurrentUser;
impl super::Registry for CurrentUser {
fn get(&self, key: &str, name: &str) -> Option<String> {
CURRENT_USER.open(key).ok()?.get_string(name).ok()
}
fn set(&mut self, key: &str, name: &str, value: &str) -> Result<(), String> {
CURRENT_USER.create(key).and_then(|k| k.set_string(name, value)).map_err(|e| e.message())
}
fn remove_tree(&mut self, key: &str) -> Result<(), String> {
CURRENT_USER.remove_tree(key).map_err(|e| e.message())
}
}
}