mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-12 11:12:08 +00:00
371 lines
15 KiB
Rust
371 lines
15 KiB
Rust
//! Desktop services: native file dialogs, clipboard, URLs, persisted preferences.
|
|
|
|
use std::cell::RefCell;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
use figcraft_engine::{Prefs, Session};
|
|
use figcraft_ui_egui::{DialogFuture, FigApp, Services};
|
|
use serde_json::{Value, json};
|
|
|
|
pub struct Desktop {
|
|
clipboard: Option<arboard::Clipboard>,
|
|
egui_ctx: Option<egui::Context>,
|
|
/// Where the preferences live ([`config_dir`]).
|
|
dir: Option<PathBuf>,
|
|
pub prefs: Option<Prefs>,
|
|
/// The system's reduce-motion setting (read in the background at startup).
|
|
reduce_motion: Arc<AtomicBool>,
|
|
/// The system's interface language (read at startup).
|
|
language: Option<String>,
|
|
/// Recovery copies of unsaved files (written at once after a panic).
|
|
recovery: crate::recovery::Shared,
|
|
}
|
|
|
|
pub fn config_dir() -> Option<PathBuf> {
|
|
#[cfg(target_os = "macos")]
|
|
let base = std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support"));
|
|
#[cfg(target_os = "windows")]
|
|
let base = std::env::var_os("APPDATA").map(PathBuf::from);
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
let base = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")));
|
|
base.map(|b| b.join("FigCraft"))
|
|
}
|
|
|
|
impl Desktop {
|
|
pub fn load(recovery: crate::recovery::Shared) -> Self {
|
|
Self::load_from(config_dir(), recovery)
|
|
}
|
|
|
|
/// With the preferences kept in `dir`.
|
|
pub fn load_from(dir: Option<PathBuf>, recovery: crate::recovery::Shared) -> Self {
|
|
let state: Value =
|
|
dir.as_ref().and_then(|d| std::fs::read_to_string(d.join(STATE)).ok()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}));
|
|
let prefs = state.get("prefs").and_then(|p| serde_json::from_value::<Prefs>(p.clone()).ok()).map(|mut p| {
|
|
// Recent files lived next to the preferences before they moved into them.
|
|
if p.recent_files.is_empty()
|
|
&& let Some(old) = state.get("recents").and_then(Value::as_array)
|
|
{
|
|
p.recent_files = old.iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
|
|
}
|
|
p.recent_files.truncate(figcraft_engine::cmd::file::MAX_RECENT);
|
|
p
|
|
});
|
|
let reduce_motion = Arc::new(AtomicBool::new(false));
|
|
let flag = reduce_motion.clone();
|
|
std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed));
|
|
Self { clipboard: arboard::Clipboard::new().ok(), egui_ctx: None, dir, prefs, reduce_motion, language: system_language(), recovery }
|
|
}
|
|
}
|
|
|
|
/// 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 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()
|
|
.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()
|
|
}
|
|
// `defaults` prints a list: ( "es-ES", en ).
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
let out = std::process::Command::new("defaults").args(["read", "-g", "AppleLanguages"]).output().ok()?;
|
|
let list: Vec<String> = String::from_utf8_lossy(&out.stdout)
|
|
.split(['(', ',', ')'])
|
|
.map(|s| s.trim().trim_matches('"').to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect();
|
|
Some(list.join(","))
|
|
}
|
|
// gettext's `LANGUAGE` priority list ("fr:de"), then the locale ("fr_FR.UTF-8").
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
{
|
|
let var = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty() && v != "C" && v != "POSIX");
|
|
let locale = ["LC_ALL", "LC_MESSAGES", "LANG"].into_iter().find_map(var);
|
|
let list: Vec<String> = var("LANGUAGE").map(|l| l.replace(':', ",")).into_iter().chain(locale).collect();
|
|
(!list.is_empty()).then(|| list.join(","))
|
|
}
|
|
#[cfg(not(any(windows, unix)))]
|
|
{
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Whether the system's accessibility settings ask for reduced motion: Windows' animation
|
|
/// effects (`UISettings.AnimationsEnabled`), macOS's Reduce motion, GNOME's animations.
|
|
fn system_reduce_motion() -> bool {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
match windows::UI::ViewManagement::UISettings::new().and_then(|s| s.AnimationsEnabled()) {
|
|
Ok(on) => !on,
|
|
Err(e) => {
|
|
log::info!("reduce motion: can't read the animation setting: {e}");
|
|
false
|
|
}
|
|
}
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
command_says("defaults", &["read", "com.apple.universalaccess", "reduceMotion"], "1")
|
|
}
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
{
|
|
command_says("gsettings", &["get", "org.gnome.desktop.interface", "enable-animations"], "false")
|
|
}
|
|
#[cfg(not(any(windows, unix)))]
|
|
{
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Whether `program args` runs and prints `want` (trimmed).
|
|
#[cfg(unix)]
|
|
fn command_says(program: &str, args: &[&str], want: &str) -> bool {
|
|
std::process::Command::new(program)
|
|
.args(args)
|
|
.output()
|
|
.ok()
|
|
.filter(|o| o.status.success())
|
|
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
|
|
}
|
|
|
|
/// Show `path` in the file manager: selected in Finder and Explorer, its folder elsewhere.
|
|
fn reveal(path: &Path) -> std::io::Result<()> {
|
|
#[cfg(target_os = "macos")]
|
|
let mut command = {
|
|
let mut c = std::process::Command::new("open");
|
|
c.arg("-R").arg(path);
|
|
c
|
|
};
|
|
#[cfg(windows)]
|
|
let mut command = {
|
|
let mut arg = std::ffi::OsString::from("/select,");
|
|
arg.push(path);
|
|
let mut c = std::process::Command::new("explorer");
|
|
c.arg(arg);
|
|
c
|
|
};
|
|
#[cfg(not(any(target_os = "macos", windows)))]
|
|
let mut command = {
|
|
let mut c = std::process::Command::new("xdg-open");
|
|
c.arg(path.parent().unwrap_or(path));
|
|
c
|
|
};
|
|
// The file manager keeps running; reap it off this thread so it leaves no zombie.
|
|
let mut child = command.spawn()?;
|
|
let _ = std::thread::Builder::new().name("figcraft-reveal".into()).spawn(move || child.wait());
|
|
Ok(())
|
|
}
|
|
|
|
/// The preferences' file in the config folder.
|
|
const STATE: &str = "state.json";
|
|
|
|
/// Everything quitting keeps, all done when it returns (macOS then ends the process): the
|
|
/// preferences in `dir`, this run's recovery copies ([`Recovery::quit`](crate::recovery::Recovery::quit))
|
|
/// and the live sessions' ends (one this app hosts tells everyone and stops; a joined one leaves).
|
|
pub fn shutdown(app: &mut FigApp, dir: Option<&Path>, recovery: Option<crate::recovery::Recovery>) {
|
|
if let Some(dir) = dir {
|
|
write_prefs(dir, &app.session.prefs);
|
|
}
|
|
// Before the sessions end: a recovered online file's copy waits for its session's outcome.
|
|
if let Some(r) = recovery {
|
|
r.quit(&app.session, app.ui.quit);
|
|
}
|
|
drop(std::mem::take(&mut app.session.live));
|
|
}
|
|
|
|
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
|
|
/// either version whole). They hold online owner tokens, so on Unix only the owner can read them.
|
|
pub(crate) fn write_prefs(dir: &Path, prefs: &Prefs) {
|
|
let tmp = dir.join("state.json.tmp");
|
|
let written = std::fs::create_dir_all(dir)
|
|
.and_then(|()| write_private(&tmp, json!({"prefs": prefs}).to_string().as_bytes()))
|
|
.and_then(|()| std::fs::rename(&tmp, dir.join(STATE)));
|
|
if let Err(e) = written {
|
|
log::warn!("preferences: couldn't save them in {}: {e}", dir.display());
|
|
}
|
|
}
|
|
|
|
/// Write `bytes` to `path`, owner read/write only on Unix from before the first byte (a leftover
|
|
/// file from a crash is narrowed too: the mode given at open only applies to new files).
|
|
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
|
use std::io::Write;
|
|
let mut o = std::fs::OpenOptions::new();
|
|
o.write(true).create(true).truncate(true);
|
|
#[cfg(unix)]
|
|
std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
|
|
let mut f = o.open(path)?;
|
|
#[cfg(unix)]
|
|
f.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
|
|
f.write_all(bytes)
|
|
}
|
|
|
|
thread_local! {
|
|
/// While the main window draws: a dialog parented to it, so a dialog opened from it stays in
|
|
/// front of it (a sheet on macOS). eframe gives other windows no handle: theirs have no parent.
|
|
static PARENT: RefCell<Option<rfd::AsyncFileDialog>> = const { RefCell::new(None) };
|
|
}
|
|
|
|
/// Run the main window's frame `f` with the dialogs it opens parented to `frame`'s window.
|
|
pub fn parented<R>(frame: &eframe::Frame, f: impl FnOnce() -> R) -> R {
|
|
PARENT.with(|p| p.replace(Some(rfd::AsyncFileDialog::new().set_parent(frame))));
|
|
let r = f();
|
|
PARENT.with(|p| p.replace(None));
|
|
r
|
|
}
|
|
|
|
/// A dialog with `title`, parented to the main window while it draws.
|
|
fn dialog(title: &str) -> rfd::AsyncFileDialog {
|
|
PARENT.with(|p| p.borrow().clone()).unwrap_or_default().set_title(title)
|
|
}
|
|
|
|
/// The dialog opens now; its future gives the path picked.
|
|
fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> DialogFuture {
|
|
Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
|
|
}
|
|
|
|
/// How long a clipboard read may wait on the app that copied (Linux and the BSDs).
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
const CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
|
|
|
/// Read the clipboard. On Linux and the BSDs the read runs on a helper thread with its own handle
|
|
/// and gives up after [`CLIPBOARD_TIMEOUT`]: a Wayland read waits on the app that copied with no
|
|
/// timeout of its own, so an app that hangs must not hang the window too.
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
fn read_clipboard<T: Send + 'static>(_: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option<T>) -> Option<T> {
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
std::thread::spawn(move || {
|
|
let _ = tx.send(arboard::Clipboard::new().ok().and_then(|mut c| read(&mut c)));
|
|
});
|
|
rx.recv_timeout(CLIPBOARD_TIMEOUT).ok().flatten()
|
|
}
|
|
|
|
/// Read the clipboard (the system's own reads don't wait on the app that copied).
|
|
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
|
fn read_clipboard<T>(c: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option<T>) -> Option<T> {
|
|
read(c)
|
|
}
|
|
|
|
/// Native Save as filters; other callers keep their existing combined filter.
|
|
fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> {
|
|
if exts == ["figcraft", "fig"] {
|
|
vec![("FigCraft (.figcraft)", &exts[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &exts[1..])]
|
|
} else {
|
|
vec![("File", exts)]
|
|
}
|
|
}
|
|
|
|
impl Services for Desktop {
|
|
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
|
|
picked(dialog(title).add_filter("Supported files", exts).pick_file())
|
|
}
|
|
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
|
|
let mut dialog = dialog(title).set_file_name(name);
|
|
for (label, extensions) in save_filters(exts) {
|
|
dialog = dialog.add_filter(label, extensions);
|
|
}
|
|
picked(dialog.save_file())
|
|
}
|
|
fn pick_folder(&mut self, title: &str) -> DialogFuture {
|
|
picked(dialog(title).pick_folder())
|
|
}
|
|
fn clipboard_set_text(&mut self, s: &str) {
|
|
if let Some(ctx) = &self.egui_ctx {
|
|
ctx.copy_text(s.to_string());
|
|
}
|
|
}
|
|
fn set_egui_context(&mut self, ctx: &egui::Context) {
|
|
self.egui_ctx = Some(ctx.clone());
|
|
}
|
|
fn clipboard_get_text(&mut self) -> Option<String> {
|
|
read_clipboard(self.clipboard.as_mut()?, |c| c.get_text().ok())
|
|
}
|
|
fn clipboard_get_html(&mut self) -> Option<String> {
|
|
read_clipboard(self.clipboard.as_mut()?, |c| c.get().html().ok())
|
|
}
|
|
fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) {
|
|
if let Some(c) = self.clipboard.as_mut() {
|
|
let _ = c.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(rgba) });
|
|
}
|
|
}
|
|
fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec<u8>)> {
|
|
let img = read_clipboard(self.clipboard.as_mut()?, |c| c.get_image().ok())?;
|
|
let (w, h) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?);
|
|
// Refuse absurd sizes (and mismatched buffers) rather than allocate.
|
|
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > 100_000_000 || img.bytes.len() != (w as usize) * (h as usize) * 4 {
|
|
return None;
|
|
}
|
|
Some((w, h, img.bytes.into_owned()))
|
|
}
|
|
fn open_url(&mut self, url: &str) {
|
|
let _ = webbrowser::open(url);
|
|
}
|
|
fn reveal_path(&mut self, path: &Path) {
|
|
if let Err(e) = reveal(path) {
|
|
log::warn!("show in folder: {}: {e}", path.display());
|
|
}
|
|
}
|
|
fn save_prefs(&mut self, prefs: &Prefs) {
|
|
if let Some(dir) = &self.dir {
|
|
write_prefs(dir, prefs);
|
|
}
|
|
}
|
|
fn emergency_save(&mut self, session: &Session) {
|
|
if let Some(r) = crate::recovery::lock(&self.recovery).as_mut() {
|
|
r.write(session, true);
|
|
// Earlier runs' copies now covered go, so the next start doesn't open them twice.
|
|
r.settle(session);
|
|
}
|
|
}
|
|
fn reduce_motion(&self) -> bool {
|
|
self.reduce_motion.load(Ordering::Relaxed)
|
|
}
|
|
fn gamepad_held(&mut self) -> u32 {
|
|
crate::gamepad::held()
|
|
}
|
|
fn system_language(&self) -> Option<String> {
|
|
self.language.clone()
|
|
}
|
|
}
|
|
|
|
/// The window icon from the bundled app icon (if present).
|
|
pub fn window_icon() -> Option<egui::IconData> {
|
|
// macOS wants Apple's padded grid; the other platforms the rounded tile edge to edge.
|
|
#[cfg(target_os = "macos")]
|
|
let bytes = include_bytes!("../../../assets/app-icon/figcraft-256.png");
|
|
#[cfg(not(target_os = "macos"))]
|
|
let bytes = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.figcraft.png");
|
|
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
|
|
let (w, h) = img.dimensions();
|
|
Some(egui::IconData { rgba: img.into_raw(), width: w, height: h })
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod save_filter_tests {
|
|
use super::save_filters;
|
|
|
|
#[test]
|
|
fn native_and_fig_save_filters_are_separate_with_native_first() {
|
|
let extensions = ["figcraft", "fig"];
|
|
assert_eq!(
|
|
save_filters(&extensions),
|
|
vec![("FigCraft (.figcraft)", &extensions[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &extensions[1..])]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn other_save_filters_keep_one_combined_group() {
|
|
for extensions in [&["figcraft"][..], &["fig"][..], &["png"][..], &["png", "jpg", "webp"][..], &[][..]] {
|
|
assert_eq!(save_filters(extensions), vec![("File", extensions)]);
|
|
}
|
|
}
|
|
}
|