Files
figcraft/crates/engine/src/assets/library_server.rs
T
BFlat 7b886404c3 M8: Opening a file contacts no team library server it names until you check it (#1621)
* M8: opening a file contacts no team library server it names until you check it (#1097)

* M8: translate the team library check strings (#1097)

* M8: the Assets panel library test checks what allows a library, without its own fake link (Sonar duplication and coverage)
2026-10-30 13:03:31 +07:00

245 lines
12 KiB
Rust

//! Team libraries: a file's published library kept on a `figcraft-server` under that file, and
//! used by other files through one of its share links (a view link is enough to read it).
//!
//! A library on a server is the same published library a library file holds (manifest and
//! snapshot, with per-asset versions), carried as a small `.figcraft` archive holding only it. Its
//! link takes the place of a library file's path in [`LibraryLink::path`], so everything else
//! (the Assets panel, placing components, updates with overrides kept) works as for library files.
//! The engine never opens a socket: requests go through the app's [`LiveLink`](crate::live::LiveLink).
//! A command that needs a library it hasn't fetched yet waits for the server (the UI runs such
//! commands without waiting: [`crate::background`]); the Assets panel refreshes in the background
//! ([`Session::refresh_server_libraries`]) and keeps the last library fetched while the server
//! can't be reached.
use std::io::Cursor;
use std::sync::Arc;
use figcraft_collab::message::{LibraryReply, LibraryReq, Usage};
use figcraft_doc::{Document, NodeId, NodeKind, PublishedLibrary};
use crate::live::{NetReply, NetRequest};
use crate::{EngineError, Result, Session};
/// What a server library that hasn't been fetched yet says.
pub const NOT_FETCHED: &str = "the library hasn't been fetched from its server yet";
/// What a server file without a library says.
pub const NO_LIBRARY: &str = "that file has no library on the server yet: publish it there first (Libraries ▸ Publish to server)";
/// Most components one usage report lists.
pub const MAX_USAGE: usize = 10_000;
/// Most team libraries remembered as yours ([`Prefs::team_libraries`](crate::Prefs)): adding or
/// checking another forgets the one used longest ago.
pub const MAX_TEAM_LIBRARIES: usize = 500;
/// How [`Prefs::team_libraries`](crate::Prefs) keeps a link: its SHA-256, so the preferences hold
/// no token.
fn link_key(link: &str) -> String {
figcraft_format::image_hash(link.trim().as_bytes())
}
/// Whether a library's path is a share link of a file on a server (else it is a library file).
pub fn is_server_link(path: &str) -> bool {
let p = path.trim_start().to_ascii_lowercase();
["figcraft://", "ws://", "wss://"].iter().any(|s| p.starts_with(s))
}
/// A library path to show: a server link without its token.
pub fn shown_path(path: &str) -> String {
if !is_server_link(path) {
return path.to_string();
}
let Some(at) = path.find("token=") else { return path.to_string() };
let end = path.get(at..).and_then(|rest| rest.find('&')).map_or(path.len(), |e| at + e);
format!("{}token=…{}", path.get(..at).unwrap_or_default(), path.get(end..).unwrap_or_default())
}
/// `lib` as the archive a server keeps: a `.figcraft` file holding only the library.
pub fn archive(lib: &PublishedLibrary) -> Result<Vec<u8>> {
let mut holder = Document::new(lib.manifest.name.clone());
holder.published = Some(Arc::new(lib.clone()));
figcraft_format::save_bytes(&holder, None).map_err(|e| EngineError::Other(format!("couldn't pack the library: {e}")))
}
/// The library in an archive from a server (untrusted: read with the library file's limits).
pub fn read_archive(bytes: &[u8]) -> std::result::Result<PublishedLibrary, String> {
match figcraft_format::read_library(Cursor::new(bytes)) {
Ok(Some(lib)) => Ok(crate::library::settled(lib)),
Ok(None) => Err("the server sent a file without a library".into()),
Err(e) => Err(format!("the server sent a library that can't be read ({e})")),
}
}
/// How `doc` uses library `library`: its instances of each of the library's components (outside
/// the library copies themselves), as the server's usage report.
// shortcut: files are told apart by their names (a file has no id of its own) and a file that
// removes the library stays in the reports; send a file id and a removal once files have ids.
pub fn usage(doc: &Document, library: &str) -> Usage {
let store = doc.library_store();
let mut counts = std::collections::BTreeMap::<NodeId, u32>::new();
let records: std::collections::BTreeMap<NodeId, NodeId> =
doc.libraries.components.iter().filter(|r| r.library == library).map(|r| (r.root, r.key)).collect();
if !records.is_empty() {
for n in doc.nodes() {
if let NodeKind::Instance { component, .. } = &n.kind
&& store.is_none_or(|st| doc.page_of(n.id) != Some(st))
&& let Some(key) = crate::library::asset_root(doc, *component).and_then(|r| records.get(&r))
{
let c = counts.entry(*key).or_default();
*c = c.saturating_add(1);
}
}
}
Usage { file: doc.name.clone(), components: counts.into_iter().take(MAX_USAGE).collect() }
}
/// A reply that doesn't answer the request.
fn unexpected(r: &LibraryReply) -> EngineError {
EngineError::Other(format!("the server answered {r:?}"))
}
impl Session {
/// The share link of the active document's live session when the document is a server file
/// you may edit (where `library.publishToServer` publishes by default).
pub fn server_link(&self) -> Option<String> {
let uid = self.ds().ok()?.uid;
let live = Some(self.live_of(uid)).filter(|l| l.syncing() && l.doc_uid == Some(uid))?;
live.info.as_ref().filter(|i| i.role == crate::live::Role::Guest && i.access == crate::live::Access::Edit).map(|i| i.link.clone())
}
/// One request about the team library behind share link `link` (a refusal is an error), with
/// the request answered when a command running in the background got its answer earlier
/// ([`crate::background`]). Callers outside the background wait for the server (at most the
/// connect timeout when it can't be reached).
pub(crate) fn library_exchange(&mut self, link: &str, req: LibraryReq) -> Result<(LibraryReply, Option<LibraryReq>)> {
let (reply, sent) = self.net(NetRequest::Library { link: link.to_string(), req })?;
let sent = match sent {
Some(NetRequest::Library { req, .. }) => Some(req),
_ => None,
};
match reply {
NetReply::Library(Ok(LibraryReply::Failed { reason }) | Err(reason)) => Err(EngineError::Other(reason)),
NetReply::Library(Ok(r)) => Ok((r, sent)),
_ => Err(EngineError::Other("the server answered something else".into())),
}
}
/// [`library_exchange`](Self::library_exchange) without the request.
pub(crate) fn library_request(&mut self, link: &str, req: LibraryReq) -> Result<LibraryReply> {
self.library_exchange(link, req).map(|(r, _)| r)
}
/// The published library at `path`: a library file (read again when it changed), or a server
/// library link (fetched once; then the background refresh keeps it current).
pub fn load_library(&mut self, path: &str) -> Result<Arc<PublishedLibrary>> {
self.fetch_library(path, false)
}
/// [`load_library`](Self::load_library); with `fresh`, a team library's server is asked for a
/// newer version first (`library.updates`, `library.list`). Fetches report how the active
/// document uses the library.
pub fn fetch_library(&mut self, path: &str, fresh: bool) -> Result<Arc<PublishedLibrary>> {
if !is_server_link(path) {
// A library link is stored in the document, so opening someone else's file would
// otherwise reach whatever path it names. Only a path on this computer is followed: a
// network share would make opening a document contact that server, and on Windows
// send the credentials of whoever opened it.
if !craft_docpath::is_local(path) {
return Err(EngineError::Other(format!("{path}: a library is opened only from this computer")));
}
return self.libraries.load(path);
}
let have = match self.libraries.cached(path) {
Some(Ok(lib)) if !fresh => return Ok(lib.clone()),
Some(Ok(lib)) => Some(lib.manifest.version),
_ => None,
};
let usage = self.doc().ok().map(|d| {
let id = d.libraries.enabled.iter().find(|l| l.path == path).map(|l| l.id.clone()).unwrap_or_default();
usage(d, &id)
});
let reply = match self.net(NetRequest::Library { link: path.to_string(), req: LibraryReq::Fetch { have, usage } }) {
Ok((NetReply::Library(r), _)) => r,
Ok(_) => Err("the server answered something else".into()),
Err(EngineError::Waiting) => return Err(EngineError::Waiting),
Err(e) => Err(e.to_string()),
};
self.take_library(path, reply).map_err(EngineError::Other)
}
/// Whether team library `link` is one you added or checked here ([`Prefs::team_libraries`](crate::Prefs)):
/// only those are checked for updates by themselves.
pub fn library_allowed(&self, link: &str) -> bool {
self.prefs.team_libraries.contains(&link_key(link))
}
/// Remember team library `link` as one you use (the app saves the preferences at once).
pub(crate) fn allow_library(&mut self, link: &str) {
let key = link_key(link);
let list = &mut self.prefs.team_libraries;
if list.first() != Some(&key) {
list.retain(|k| *k != key);
list.insert(0, key);
list.truncate(MAX_TEAM_LIBRARIES);
self.live.unsaved = true;
}
}
/// Ask the servers of the active document's team libraries for newer versions, in the
/// background, reporting how the document uses each (the Assets panel calls it now and then).
/// Only libraries you added or checked here are asked: opening a file that names another
/// server contacts nothing until you check it (`library.check`, #1097).
pub fn refresh_server_libraries(&mut self) {
let Ok(doc) = self.doc() else { return };
let jobs: Vec<(String, LibraryReq)> = doc
.libraries
.enabled
.iter()
.filter(|l| is_server_link(&l.path) && self.library_allowed(&l.path))
.map(|l| {
let have = match self.libraries.cached(&l.path) {
Some(Ok(lib)) => Some(lib.manifest.version),
_ => None,
};
(l.path.clone(), LibraryReq::Fetch { have, usage: Some(usage(doc, &l.id)) })
})
.collect();
if let (false, Some(net)) = (jobs.is_empty(), self.live.link()) {
net.library_background(jobs);
}
}
/// A background library request was answered: a newer library replaces the one shown ("Library
/// updates available" follows).
pub(crate) fn library_answer(&mut self, link: &str, reply: std::result::Result<LibraryReply, String>) {
let _ = self.take_library(link, reply);
}
/// Take the answer to a fetch of the team library behind `link` into the cache. While the
/// server can't be reached (or refuses) the last library fetched stays in use.
fn take_library(&mut self, link: &str, reply: std::result::Result<LibraryReply, String>) -> std::result::Result<Arc<PublishedLibrary>, String> {
let kept = match self.libraries.cached(link) {
Some(Ok(lib)) => Some(lib.clone()),
_ => None,
};
let result = match reply {
Ok(LibraryReply::Library { archive, .. }) => read_archive(&archive).map(Arc::new),
Ok(LibraryReply::Unchanged { .. }) => kept.clone().ok_or_else(|| NOT_FETCHED.to_string()),
Ok(LibraryReply::Empty) => Err(NO_LIBRARY.into()),
Ok(LibraryReply::Failed { reason }) | Err(reason) => Err(reason),
Ok(other) => Err(unexpected(&other).to_string()),
};
match (result, kept) {
(Err(e), Some(lib)) => {
log::info!("team library {}: {e}; the last version fetched stays in use", shown_path(link));
Ok(lib)
}
(result, _) => {
self.libraries.insert(link, result.clone());
result
}
}
}
}
#[cfg(test)]
mod tests;