Merge main and the community batch into #306

# Conflicts:
#	apps/figcraft/src/main.rs
This commit is contained in:
bflatastic
2026-10-15 19:27:31 +07:00
101 changed files with 3118 additions and 294 deletions
+21
View File
@@ -78,6 +78,27 @@ fn run_export_info_convert() {
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn run_takes_the_documented_gradient_shorthand() {
// #252: only MCP used to accept it; `run` said "`fills` must be paints or colours".
let d = tmp("gradient");
let script = d.join("s.json");
std::fs::write(
&script,
r##"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":200,"height":100}},
{"command":"node.set","params":{"ids":[2],"fills":[{"type":"linearGradient","stops":["#ff0000","#0000ff"],"from":[0,0.5],"to":[1,0.5]}]}}]"##,
)
.unwrap();
let file = d.join("g.figcraft");
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
assert!(out.status.success(), "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
let json = d.join("g.json");
assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success());
let doc = std::fs::read_to_string(&json).unwrap();
assert!(doc.contains("\"kind\":\"linear\"") || doc.contains("\"kind\": \"linear\""), "{doc}");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn mcp_over_stdio() {
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
@@ -800,3 +800,41 @@ fn the_server_forgets_addresses_whose_connections_are_gone() {
assert!(server.blocks_remembered() <= 2, "{} blocks remembered", server.blocks_remembered());
server.stop();
}
#[test]
fn a_library_publish_that_fails_keeps_serving_the_old_version() {
let dir = TempDir::new("libfail");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
// Version 1, then a changed library as version 2.
let mut s = Session::new();
let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap();
let c = s.execute("component.create", &json!({"ids": [f]})).unwrap()["id"].as_u64().unwrap();
s.execute("library.publish", &json!({})).unwrap();
let v1 = figcraft_engine::library_server::archive(&s.doc().unwrap().published.clone().unwrap()).unwrap();
s.execute("node.set", &json!({"id": c, "props": {"fill": "#00ff00"}})).unwrap();
s.execute("library.publish", &json!({})).unwrap();
let v2 = figcraft_engine::library_server::archive(&s.doc().unwrap().published.clone().unwrap()).unwrap();
assert_eq!(store.publish_library(&file.id, &v1).unwrap(), 1);
let files = dir.path().join(crate::store::FILES).join(&file.id);
// The description can't be written: a directory sits where its temporary file goes.
let tmp = crate::disk::temp_of(&files.join(crate::disk::META));
std::fs::create_dir(&tmp).unwrap();
assert!(store.publish_library(&file.id, &v2).is_err());
let fetch = |s: &mut Store| s.library(ip("198.51.100.1"), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None });
let served = |reply: LibraryReply| match reply {
LibraryReply::Library { version, archive } => (version, archive),
other => panic!("{other:?}"),
};
assert_eq!(served(fetch(&mut store)), (1, v1.clone()), "version 1, with its own bytes");
// A crash right after the archive was written leaves it on disk, the description unchanged.
std::fs::write(files.join(crate::store::library_name(2)), &v2).unwrap();
drop(store);
std::fs::remove_dir(&tmp).unwrap();
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
assert_eq!(served(fetch(&mut store)), (1, v1), "still version 1 after a restart");
// Publishing again works, and serves the new version.
assert_eq!(store.publish_library(&file.id, &v2).unwrap(), 2);
assert_eq!(served(fetch(&mut store)), (2, v2));
assert!(!files.join(crate::store::library_name(1)).exists(), "the old version is deleted");
}
+67 -16
View File
@@ -10,7 +10,8 @@
//! and a join code's hash and wrapped token (see `codes`)
//! <data>/files/<id>/snapshot.bin the session state at the last compaction
//! <data>/files/<id>/log.bin what changed since (see `disk`)
//! <data>/files/<id>/library.bin its team library, when one was published (a `.figcraft` archive)
//! <data>/files/<id>/library-<version>.bin its team library, when one was published (a `.figcraft`
//! archive; `library.bin` from servers before versioned names)
//! <data>/files/<id>/usage.json how other files use that library, as their apps report it
//! <data>/plugins/ the plugin directory (see `directory`)
//! <data>/blocked.json addresses the operator blocked
@@ -179,7 +180,7 @@ impl Meta {
}
}
/// What a file's team library is (`library.bin` holds it).
/// What a file's team library is ([`library_file`] holds it).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct LibraryMeta {
id: String,
@@ -265,6 +266,9 @@ pub struct Store {
next_sweep: u64,
/// Compactions so far (tests count them).
compactions: u64,
/// Open files closed by a compaction cut short (their snapshot replaced, their log not):
/// reported at the next flush, so nothing appended after it is confirmed.
cut_short: Vec<String>,
/// The free space last seen on the data directory's disk, and when.
free: Option<(Instant, Option<u64>)>,
_lock: File,
@@ -360,6 +364,7 @@ impl Store {
gone_codes: VecDeque::new(),
next_sweep: 0,
compactions: 0,
cut_short: Vec::new(),
free: None,
_lock: lock,
};
@@ -919,11 +924,12 @@ impl Store {
/// A file's team library for `ip`, unless its version is `have`; within what one address and
/// everyone may download (the size is known before anything is read).
fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option<u64>) -> Result<LibraryReply, String> {
let (path, max) = (self.dir(file).join(LIBRARY), self.cfg.max_file_bytes);
let (dir, max) = (self.dir(file), self.cfg.max_file_bytes);
let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) };
if have == Some(lib.version) {
return Ok(LibraryReply::Unchanged { version: lib.version });
}
let path = library_file(&dir, lib.version);
let size = fs::metadata(&path).map_or(0, |m| m.len());
let now = Instant::now();
if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) {
@@ -965,14 +971,24 @@ impl Store {
return Err(format!("version {} is on the server already: publish again to follow it", old.version));
}
}
// The archive first: a description never names a version whose library isn't there (a
// failed description write leaves the older number, which the next publish follows).
disk::write_atomic(&dir.join(LIBRARY), archive).map_err(|e| format!("couldn't keep the library: {e}"))?;
// Each version has a file of its own, written before the description names it: a failed
// description write or a crash in between leaves the previous version served whole, never
// the new bytes under the old number.
let path = dir.join(library_name(m.version));
disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?;
let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version });
if let Err(e) = write_json(&dir.join(META), &entry.meta) {
// Never served (the description doesn't name it); the next publish of it replaces it.
if let Err(r) = fs::remove_file(&path) {
log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}");
}
entry.meta.library = before;
return Err(format!("couldn't keep the library: {e}"));
}
// The previous version isn't served any more.
if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) {
log::warn!("file {file}: the previous library version couldn't be deleted: {e}");
}
log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len());
Ok(m.version)
}
@@ -1124,7 +1140,7 @@ impl Store {
Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 }
}
// A new snapshot and log of the next generation, so appends start clean.
_ => self.rewrite(file, generation + 1, &server)?,
_ => self.rewrite(file, generation + 1, &server).map_err(|e| e.why)?,
};
self.entry(file)?.log = Some(log);
Ok(server)
@@ -1137,21 +1153,24 @@ impl Store {
}
/// Write `server`'s state as snapshot `generation` and start an empty log of it.
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, String> {
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, RewriteError> {
let dir = self.dir(file);
let state = server.state().map_err(|e| e.to_string())?;
let bytes = disk::encode_snapshot(generation, &state)?;
let kept = |why: String| RewriteError { why, snapshot_replaced: false };
let state = server.state().map_err(|e| kept(e.to_string()))?;
let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?;
// Never replace a readable snapshot with one that can't be opened again: the old snapshot
// and log still hold everything.
if bytes.len() as u64 > self.snapshot_cap() {
return Err(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20));
return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20)));
}
if bytes.len() as u64 > self.cfg.max_file_bytes {
log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20);
}
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| format!("couldn't write the snapshot: {e}"))?;
disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| format!("couldn't start the log: {e}"))?;
let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| format!("couldn't open the log: {e}"))?;
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?;
// From here the old log no longer goes with the snapshot on disk.
let replaced = |why: String| RewriteError { why, snapshot_replaced: true };
disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?;
let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?;
if let Ok(e) = self.entry(file) {
e.bytes = (bytes.len() + LOG_HEADER) as u64;
e.meta.seq = server.seq();
@@ -1204,8 +1223,19 @@ impl Store {
}
log::debug!("compacted file {file}");
}
// The new snapshot holds everything, but a restart would drop whatever is appended to
// the old log now (its generation no longer matches): the file closes, the hub hears
// it at the next flush, and it opens again from the new snapshot.
Err(RewriteError { why, snapshot_replaced: true }) => {
self.note(format!("file {file}: compaction was cut short ({why}); it was closed"));
log::error!("file {file}: compaction was cut short: {why}");
if let Some(e) = self.files.get_mut(file) {
e.log = None;
}
self.cut_short.push(file.to_string());
}
// The old snapshot and log still hold everything: carry on appending.
Err(e) => log::error!("file {file}: compaction failed: {e}"),
Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"),
}
}
@@ -1213,7 +1243,7 @@ impl Store {
/// Those are closed here (their pending records dropped: nobody was told of them) and open
/// again from what is on disk.
fn flush_all(&mut self) -> Vec<String> {
let mut failed = Vec::new();
let mut failed = std::mem::take(&mut self.cut_short);
for (id, e) in &mut self.files {
if let Some(log) = e.log.as_mut() {
match flush_log(id, log) {
@@ -1229,6 +1259,13 @@ impl Store {
}
}
/// Why a snapshot and log couldn't be rewritten, and whether the snapshot on disk was replaced
/// before it failed (then the old log no longer goes with it).
struct RewriteError {
why: String,
snapshot_replaced: bool,
}
/// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log
/// can't be trusted to hold them).
fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
@@ -1249,6 +1286,18 @@ fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
}
}
/// The name of the file holding version `version` of a file's team library.
pub fn library_name(version: u64) -> String {
format!("library-{version}.bin")
}
/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a
/// version published before each version had one.
fn library_file(dir: &Path, version: u64) -> PathBuf {
let path = dir.join(library_name(version));
if path.exists() { path } else { dir.join(LIBRARY) }
}
fn write_json<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), String> {
let text = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
disk::write_atomic(path, &text).map_err(|e| e.to_string())
@@ -1316,6 +1365,8 @@ impl Backend for Store {
if dirty {
self.compact(file, server);
}
// It closes anyway, everything in its snapshot: a compaction cut short needs no report.
self.cut_short.retain(|f| f != file);
if let Some(e) = self.files.get_mut(file) {
e.log = None;
}
+44
View File
@@ -269,3 +269,47 @@ fn random_bytes_never_panic_the_readers() {
let _ = disk::read_log(&bytes);
}
}
/// The first page's name in `server`'s document.
fn page_name(server: &Server) -> String {
let d = server.replica().document();
d.node(d.first_page().unwrap()).unwrap().name.clone()
}
#[test]
fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() {
let dir = TempDir::new("cutshort");
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
let folder = dir.path().join(FILES);
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let (id, _, _) = publish(&mut store, "Busy");
let mut server = store.open(&id).unwrap();
let mut a = join(&mut server, Access::Edit);
store.changed(&id, &mut server);
// The next compaction writes its snapshot, then can't start its log.
let blocker = disk::temp_of(&folder.join(&id).join(LOG));
fs::create_dir(&blocker).unwrap();
// What the hub would confirm: every change whose flush didn't report the file.
let mut confirmed = String::new();
let mut failed = false;
let names = ["P0", "P1", "P2", "After 1", "After 2"];
for name in names {
rename(&mut server, &mut a, name);
store.changed(&id, &mut server);
if store.flush().contains(&id) {
failed = true;
break;
}
confirmed = name.to_string();
}
assert!(failed, "the hub hears the file can't be kept; confirmed {confirmed:?}");
// A crash, then the disk recovers.
drop(store);
fs::remove_dir(&blocker).unwrap();
let mut store = Store::open(dir.path(), c).unwrap();
let restored = store.open(&id).unwrap();
// Every confirmed change is there (the snapshot may also hold the one that was cut short).
let at = |n: &str| names.iter().position(|x| *x == n);
let name = page_name(&restored);
assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}");
}
+11 -3
View File
@@ -40,7 +40,7 @@ pub fn start() {
// on the socket wakes the page for a frame.
let ctx = cc.egui_ctx.clone();
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion }));
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
Ok(Box::new(WebShell { fig }))
}),
)
@@ -69,14 +69,22 @@ impl eframe::App for WebShell {
}
}
/// Browser services. File dialogs return nothing for now (the trait's defaults); the clipboard
/// goes through egui's own web clipboard handling.
/// Browser services. File dialogs return nothing for now (the trait's defaults). Copies go to the
/// system clipboard through egui's output; pastes arrive as paste events with their text (a page
/// can't read the clipboard when it likes).
struct WebServices {
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
reduce_motion: Option<web_sys::MediaQueryList>,
ctx: egui::Context,
}
impl Services for WebServices {
fn clipboard_set_text(&mut self, s: &str) {
figcraft_ui_egui::egui_copy_text(&self.ctx, s);
}
fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) {
figcraft_ui_egui::egui_copy_png(&self.ctx, w, h, rgba);
}
fn open_url(&mut self, url: &str) {
let Some(window) = web_sys::window() else { return };
if let Err(e) = window.open_with_url_and_target(url, "_blank") {
+7
View File
@@ -31,6 +31,13 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
# Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by GridCraft,
# EffectCraft and PhotoCraft) registers the handler behind a safe API (Objective-C interop needs
# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping.
[target.'cfg(target_os = "macos")'.dependencies]
fmv-macos-events = "=0.1.0"
[dev-dependencies]
wat = { workspace = true }
+57
View File
@@ -0,0 +1,57 @@
//! macOS open-documents and quit Apple events (#289).
//!
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a FigCraft file.figcraft`
//! don't pass paths on the command line: LaunchServices sends the running (or just-launched) app a
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
//! `NSApplicationDelegate`, so AppKit answered "FigCraft cannot open files in the FigCraft Document
//! format". Handling it ourselves needs Objective-C class declarations, i.e. `unsafe`, which this
//! workspace forbids. The `fmv-macos-events` crate (also used by GridCraft, EffectCraft and
//! PhotoCraft) wraps exactly that, an `NSAppleEventManager` handler registered before Finder's
//! launch event that leaves winit's delegate alone, behind a safe main-thread API.
use std::path::PathBuf;
use figcraft_ui_egui::FigApp;
use fmv_macos_events::{Event, Inbox, Registration};
use serde_json::json;
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
pub struct AppleEvents {
_registration: Registration,
inbox: Inbox,
}
impl AppleEvents {
/// Register the handlers. Call on the main thread before the event loop starts, so the event
/// that launched the app (a Finder double-click) is caught too.
pub fn install() -> Self {
let (registration, inbox) = Registration::install();
Self { _registration: registration, inbox }
}
/// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`.
pub fn connect(&self, ctx: &egui::Context) -> Inbox {
let ctx = ctx.clone();
self.inbox.set_wake(move || ctx.request_repaint());
self.inbox.clone()
}
}
/// Open the documents and act on the quit requests that arrived since the last frame.
pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) {
for e in inbox.drain() {
match e {
Event::Open(paths) => open_documents(fig, paths),
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
}
}
}
/// Open `paths` as File › Open does; a file that fails shows its error and the others still open.
pub fn open_documents(fig: &mut FigApp, paths: Vec<PathBuf>) {
for p in paths {
if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() {
fig.ui.home = false;
}
}
}
+30
View File
@@ -0,0 +1,30 @@
//! Documents macOS hands over in an open-documents Apple event (a Finder double-click, Open With,
//! a drop on the Dock icon) open as File › Open opens them (#289).
use std::path::PathBuf;
use figcraft_engine::Session;
use figcraft_ui_egui::{FigApp, NoServices};
use serde_json::json;
use crate::apple_events::open_documents;
#[test]
fn documents_from_finder_open_and_leave_the_home_screen() {
let dir = std::env::temp_dir().join(format!("figcraft-apple-events-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("BLA-001.figcraft");
let mut s = Session::new();
s.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap();
let mut app = FigApp::new(Session::empty(), Box::new(NoServices));
app.ui.home = true;
// A file that can't be read is reported and doesn't keep the others from opening.
open_documents(&mut app, vec![dir.join("Missing.figcraft"), file.clone()]);
let paths: Vec<_> = app.session.docs.iter().map(|d| d.path.clone().map(PathBuf::from)).collect();
assert_eq!(paths, [Some(file)]);
assert!(!app.ui.home, "the opened document shows");
assert!(app.ui.last_error.as_deref().is_some_and(|e| e.contains("Missing.figcraft")), "{:?}", app.ui.last_error);
let _ = std::fs::remove_dir_all(&dir);
}
+29 -1
View File
@@ -9,6 +9,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#[cfg(target_os = "macos")]
mod apple_events;
mod args;
mod control_server;
mod gamepad;
@@ -17,6 +19,8 @@ mod recovery;
mod services;
mod startup;
#[cfg(all(test, target_os = "macos"))]
mod apple_events_tests;
#[cfg(test)]
mod args_tests;
#[cfg(test)]
@@ -46,6 +50,9 @@ struct App {
title: String,
/// Shared with the services, which write the copies after a panic.
recovery: recovery::Shared,
/// Documents and quit requests from Finder, the Dock and Open With.
#[cfg(target_os = "macos")]
apple_events: fmv_macos_events::Inbox,
}
impl eframe::App for App {
@@ -58,6 +65,8 @@ impl eframe::App for App {
}
let title = with_app(|fig| {
control_server::drain_queue(&self.queue, &mut fig.control_queue);
#[cfg(target_os = "macos")]
apple_events::poll(&self.apple_events, fig, ctx);
// Closing the window asks about unsaved changes (and keeps the app running while other
// windows are open).
figcraft_ui_egui::windows::on_close_request(fig, ctx);
@@ -191,7 +200,26 @@ fn main() -> eframe::Result {
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
graphics::configure(&mut options.wgpu_options);
figcraft_ui_egui::windows::install(fig);
let result = eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery }))));
// Registered before the event loop starts, so it catches the Finder event that launched the app
// as well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
let result = eframe::run_native(
"FigCraft",
options,
Box::new(move |_cc| {
Ok(Box::new(App {
queue,
ctx_slot,
title: String::new(),
recovery,
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&_cc.egui_ctx),
}))
}),
);
// Say why instead of quitting silently: release builds on Windows have no console.
if let Err(e) = &result {
startup::failed(e, port.is_some());
+49 -14
View File
@@ -41,14 +41,40 @@ pub struct Recovery {
dir: PathBuf,
/// Held while the app runs: a free lock means the run that wrote the folder is gone.
_lock: File,
/// The revision each document was last copied at, and the write in flight.
written: HashMap<u64, (u64, Option<JoinHandle<()>>)>,
/// What was copied of each document, and the write in flight.
written: HashMap<u64, Copy>,
/// When (UI clock) the next copies are due.
next: f64,
/// Copies of online files from earlier runs, kept until their outcome is known.
waiting: Vec<Waiting>,
}
/// The state of the copy of one document: only a write that succeeded counts as a copy.
#[derive(Default)]
struct Copy {
/// The revision whose copy was written.
done: Option<u64>,
/// The write in flight: the revision it copies, and whether it succeeded once it ends.
flight: Option<(u64, JoinHandle<bool>)>,
}
impl Copy {
/// Take in the result of the write in flight once it ended; a failed one leaves its revision uncopied.
fn reap(&mut self) {
if self.flight.as_ref().is_some_and(|f| f.1.is_finished())
&& let Some((revision, h)) = self.flight.take()
&& h.join().unwrap_or(false)
{
self.done = Some(revision);
}
}
/// Whether `revision` has neither been copied nor is being copied.
fn pending(&self, revision: u64) -> bool {
self.done != Some(revision) && self.flight.as_ref().is_none_or(|f| f.0 != revision)
}
}
/// A copy of one of your online files whose file is being joined again as session `session`.
pub struct Waiting {
session: u64,
@@ -86,15 +112,21 @@ impl Recovery {
/// an idle window can sleep).
pub fn tick(&mut self, s: &Session, now: f64) -> Option<f64> {
self.settle(s);
self.written.values_mut().for_each(Copy::reap);
// A join on its way settles within its timeouts: look again soon.
let settling = (!self.waiting.is_empty()).then_some(0.5);
let pending = s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.0 != d.revision));
if now < self.next {
return pending.then_some(self.next - now).or(settling);
return self.pending(s).then_some(self.next - now).or(settling);
}
self.write(s, false);
self.next = now + INTERVAL;
settling
// A copy that failed is tried again at the next turn, without another edit.
settling.or(self.pending(s).then_some(INTERVAL))
}
/// Whether a document with unsaved changes has a revision that is neither copied nor being copied.
fn pending(&self, s: &Session) -> bool {
s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.pending(d.revision)))
}
/// Remove earlier runs' copies whose outcome is known, once what they became is copied here.
@@ -131,9 +163,10 @@ impl Recovery {
}
continue;
}
let entry = self.written.entry(d.uid).or_insert((0, None));
let busy = entry.1.as_ref().is_some_and(|h| !h.is_finished());
if entry.0 == d.revision || busy {
let entry = self.written.entry(d.uid).or_default();
entry.reap();
// A write still in flight (for any revision) is waited for; a failed one is tried again.
if entry.done == Some(d.revision) || entry.flight.is_some() {
continue;
}
let doc = d.doc.clone();
@@ -144,20 +177,22 @@ impl Recovery {
let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq));
let job = move || {
if !write_atomic(&path, &doc) {
return;
return false;
}
let _ = match online {
Some(text) => std::fs::write(&note, text),
None => std::fs::remove_file(&note),
};
true
};
// The revision counts as copied only once its write succeeded.
if wait {
job();
entry.1 = None;
if job() {
entry.done = Some(d.revision);
}
} else {
entry.1 = std::thread::Builder::new().name("recovery".into()).spawn(job).ok();
entry.flight = std::thread::Builder::new().name("recovery".into()).spawn(job).ok().map(|h| (d.revision, h));
}
entry.0 = d.revision;
}
// Closed documents.
let open: Vec<u64> = s.docs.iter().map(|d| d.uid).collect();
@@ -177,7 +212,7 @@ impl Recovery {
pub fn finish(self) {
let Self { dir, _lock, written, .. } = self;
// Windows removes a folder only once nothing in it is open: copies being written first.
for h in written.into_values().filter_map(|w| w.1) {
for (_, h) in written.into_values().filter_map(|w| w.flight) {
let _ = h.join();
}
drop(_lock);
+53
View File
@@ -62,3 +62,56 @@ fn copies_follow_saves_and_a_normal_quit_leaves_nothing() {
assert!(!dir.exists(), "a normal quit removes the copies");
let _ = std::fs::remove_dir_all(&root);
}
/// The files named `*.figcraft` in the run folder under `root`.
fn copy_count(root: &std::path::Path) -> usize {
let Some(dir) = std::fs::read_dir(root).unwrap().flatten().next().map(|e| e.path()) else { return 0 };
std::fs::read_dir(dir).unwrap().flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count()
}
/// A folder at the path of `s`'s first document's copy in the run folder under `root`.
fn blocked_copy(root: &std::path::Path, s: &Session) -> std::path::PathBuf {
let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path();
let blocker = dir.join(format!("{}.figcraft", s.docs[0].uid));
std::fs::create_dir(&blocker).unwrap();
blocker
}
#[test]
fn a_copy_that_failed_is_tried_again_without_another_edit() {
let root = root("retry");
let s = edited();
let mut r = Recovery::start(&root).unwrap();
// A folder where the copy goes makes its write fail on every platform (renaming the run folder
// away fails on Windows, where the run's lock file keeps it open).
let blocker = blocked_copy(&root, &s);
r.write(&s, true);
std::fs::remove_dir(&blocker).unwrap();
// Nothing was edited since: the same revision still has to be copied.
r.write(&s, true);
assert_eq!(copy_count(&root), 1, "the failed revision was copied again");
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_background_copy_that_failed_is_tried_again_without_another_edit() {
let root = root("retry-bg");
let s = edited();
let mut r = Recovery::start(&root).unwrap();
let blocker = blocked_copy(&root, &s);
r.write(&s, false);
// Let the failing write end before the way is clear again.
std::thread::sleep(std::time::Duration::from_millis(300));
std::fs::remove_dir(&blocker).unwrap();
for _ in 0..50 {
r.write(&s, false);
if copy_count(&root) == 1 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert_eq!(copy_count(&root), 1, "the failed revision was copied again");
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
+6 -2
View File
@@ -59,12 +59,16 @@ impl Desktop {
/// The system's preferred interface languages, most preferred first, comma-separated
/// ("nl-NL,de-DE,en-US"): Windows' language list, macOS's AppleLanguages, `LANGUAGE` and the
/// locale variables elsewhere. The UI shows the first one it has a catalog for.
/// locale variables elsewhere. The UI shows the first language in the list that has a catalog.
/// Windows keeps only its first preferred language (and English when it has no catalog): that
/// list is also the input-language list, so a language added for a keyboard (Deutsch on a Polish
/// system) must not decide the interface language.
fn system_language() -> Option<String> {
#[cfg(target_os = "windows")]
{
windows::System::UserProfile::GlobalizationPreferences::Languages()
.map(|l| l.into_iter().map(|s| s.to_string()).collect::<Vec<_>>().join(","))
.and_then(|l| l.GetAt(0))
.map(|s| s.to_string())
.inspect_err(|e| log::info!("language: can't read the system languages: {e}"))
.ok()
}