Files
figcraft/crates/engine/src/invite_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

81 lines
3.5 KiB
Rust

//! Opening invite links from the system: a `figcraft://join?…` link clicked in a browser, a chat
//! or an email opens FigCraft, which joins the session (milestone M9).
//!
//! The app makes itself the opener of the `figcraft` URL scheme. Where the app registers it at run
//! time (Windows: per user, no administrator rights), the desktop app installs a
//! [`LinkRegistrar`] (like the [`LiveLink`](crate::live::LiveLink)); the engine keeps the
//! preference ([`Prefs::invite_links`](crate::Prefs::invite_links)) and the
//! `collab.registerLinks` command. The preference defaults to on for installed builds and off for
//! development builds, so running from a source checkout never takes the scheme over. (macOS and
//! Linux packages declare the scheme in the bundle and the desktop entry instead.)
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::params::boolean;
use crate::{EngineError, Result, Session, spec};
/// Registers this copy of the app as the opener of `figcraft://` links (the desktop app installs
/// one: `session.live.links`).
pub trait LinkRegistrar: Send {
/// This copy of the app is the registered opener.
fn registered(&self) -> bool;
/// This is an installed build (the preference defaults to on), not a development build.
fn installed(&self) -> bool;
/// Register this copy (`on`), or remove its registration (`off`: another copy's stays).
fn set(&mut self, on: bool) -> std::result::Result<(), String>;
}
impl Session {
/// Whether invite links should open this app: the preference, or on for installed builds.
/// `None` when this app can't register them.
pub fn invite_links_on(&self) -> Option<bool> {
let r = self.live.links.as_ref()?;
Some(self.prefs.invite_links.unwrap_or_else(|| r.installed()))
}
/// At startup: register as the opener when the preference says so and it isn't already this
/// copy; remove this copy's registration only when the preference was turned off.
pub fn invite_links_startup(&mut self) {
let pref = self.prefs.invite_links;
let Some(r) = self.live.links.as_mut() else { return };
let want = match pref {
Some(on) => on,
None if r.installed() => true,
// A development build leaves whatever is registered alone.
None => return,
};
if want != r.registered()
&& let Err(e) = r.set(want)
{
log::warn!("invite links: {e}");
}
}
}
fn register(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "collab.registerLinks";
let Some(r) = s.live.links.as_mut() else {
return Err(EngineError::Disabled(
CMD.into(),
"this app doesn't register link openers itself (the Windows desktop app does; the macOS and Linux packages declare figcraft:// when installed)"
.into(),
));
};
let on = boolean(p, "on").unwrap_or_else(|| !s.prefs.invite_links.unwrap_or_else(|| r.installed()));
r.set(on).map_err(|e| EngineError::Other(format!("couldn't {} invite links: {e}", if on { "register" } else { "unregister" })))?;
let registered = r.registered();
s.prefs.invite_links = Some(on);
Ok(json!({"on": on, "registered": registered}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"collab.registerLinks",
"Open invite links in FigCraft",
"{on? (default: switch)} → {on, registered}: make this app the opener of figcraft:// invite links (or stop); also the Preferences checkbox",
false,
register
)]
}