mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-03 00:39:44 -06:00
- 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>
127 lines
4.5 KiB
Rust
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())
|
|
}
|
|
}
|
|
}
|