mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-07 02:51:41 +00:00
A file's library can be published to figcraft-server and enabled in other files by a share link (view access is enough); placing, styles, variables and library updates with overrides kept work as for local libraries, with a background check while the Assets panel is open. library.swap re-points instances, styles and variables to another library by name (one undo step, unmatched items listed). The server keeps per-file usage reports (library.usage).
197 lines
9.4 KiB
Rust
197 lines
9.4 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 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::NO_TEAM_LIBRARIES;
|
|
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;
|
|
|
|
/// 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(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 info = self.live.info.as_ref().filter(|i| i.role == crate::live::Role::Guest && i.access == crate::live::Access::Edit)?;
|
|
self.live_doc(uid).then(|| info.link.clone())
|
|
}
|
|
|
|
/// One request about the team library behind share link `link`, waiting for the server (a
|
|
/// refusal is an error).
|
|
// shortcut: commands wait on the calling thread (at most the 5 s connect timeout when the server
|
|
// is down; the panel's checks run in the background); queue them on the link if that shows.
|
|
pub(crate) fn library_request(&mut self, link: &str, req: LibraryReq) -> Result<LibraryReply> {
|
|
let net = self.live.link().ok_or_else(|| EngineError::Other(NO_TEAM_LIBRARIES.into()))?;
|
|
match net.library(link, req).map_err(EngineError::Other)? {
|
|
LibraryReply::Failed { reason } => Err(EngineError::Other(reason)),
|
|
r => Ok(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) {
|
|
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.live.link() {
|
|
Some(net) => net.library(path, LibraryReq::Fetch { have, usage }),
|
|
None => Err(NO_TEAM_LIBRARIES.into()),
|
|
};
|
|
self.take_library(path, reply).map_err(EngineError::Other)
|
|
}
|
|
|
|
/// 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).
|
|
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))
|
|
.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)]
|
|
#[path = "library_server_tests.rs"]
|
|
mod tests;
|