Files
vectorcraft/apps/vectorcraft/src/main.rs
T

1383 lines
70 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! VectorCraft desktop app.
//!
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>]... [--automation-write-root <dir>]...]
//! [--in-window-menus] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
//!
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
//! macOS instead of the macOS menu bar (`mac_menu`).
//!
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`; a port from 1 to 65535, or the app doesn't
//! start) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `vectorcraft_ui_egui::control` for the methods. `--automation-read-root` and
//! `--automation-write-root` (or `VECTORCRAFT_AUTOMATION_READ_ROOT` / `_WRITE_ROOT`) confine the
//! files its requests read and write (`vectorcraft_engine::file_access`); the person at the
//! keyboard isn't confined.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#[cfg(all(feature = "windows7", any(feature = "wgpu", feature = "accessibility")))]
compile_error!("windows7 requires --no-default-features (wgpu and accessibility must be disabled)");
#[cfg(all(windows, feature = "windows7", not(target_vendor = "win7")))]
compile_error!("windows7 requires --target x86_64-win7-windows-msvc; the ordinary Windows target still imports newer APIs");
#[cfg(all(target_vendor = "win7", not(feature = "windows7")))]
compile_error!("the win7 target requires --no-default-features --features windows7");
mod automation_args;
mod clipboard;
#[cfg(feature = "wgpu")]
mod compositor_gpu;
mod control_server;
mod cursor_theme;
#[cfg(feature = "wgpu")]
mod gpu;
#[cfg(target_os = "linux")]
mod linux_stylus;
mod logging;
#[cfg(target_os = "macos")]
mod mac_fonts;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
mod mac_save_format;
#[cfg(target_os = "macos")]
mod mac_screen_color;
#[cfg(target_os = "macos")]
mod mac_window;
#[cfg(target_os = "macos")]
mod open_documents;
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd", test))]
mod portal_save;
mod prefs_dir;
mod printing;
#[cfg(all(windows, not(target_vendor = "win7")))]
mod system_fonts;
mod tablet;
mod window;
mod window_frame;
mod windows_stylus;
mod xwayland;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_engine::file_access::{self, AutomationRoots};
use vectorcraft_ui_egui::graphics::GraphicsLoss;
use vectorcraft_ui_egui::picks::PickRequest;
use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp};
struct App {
app: VectorcraftApp,
/// Reported by wgpu when the window's graphics device is lost (a driver reset).
graphics_loss: GraphicsLoss,
/// The graphics device was lost and the unsaved changes are kept for Data Recovery.
graphics_lost: bool,
/// Frames the UI has run (see [`end_before_teardown`]).
frames: u64,
/// Keeps the custom title bar's window borderless (Windows, #1758).
window_frame: Option<window_frame::WindowFrameFn>,
/// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635).
winit_window: Option<std::sync::Arc<winit::window::Window>>,
/// A saved or fitted geometry asked for maximize once the window is mapped (Linux).
pending_maximize: bool,
/// Frames left to retry [`window::apply_pending_maximize`].
maximize_tries: u8,
}
impl eframe::App for App {
// Each frame runs unconfined: it is the person at the keyboard's. The app confines what the
// control channel asks for, and the frames carrying input it injected, to the automation roots
// (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process.
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
file_access::unconfined(|| self.logic_frame(ctx, frame));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
// winit gives the window its caption back when it changes the window's flags (maximize,
// restore, …): take it off again before the frame is laid out (#1758).
if let Some(window_frame) = self.window_frame.as_mut() {
window_frame();
}
#[cfg(target_os = "macos")]
if self.app.services.native_menu.is_some() {
mac_menu::raw_input_hook(raw);
}
// A pen contact with the barrel button held presses the secondary button (Windows Ink).
#[cfg(all(windows, not(target_vendor = "win7")))]
windows_stylus::raw_input_hook(raw);
file_access::unconfined(|| self.app.raw_input_hook(raw));
}
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
{
let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT;
let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar);
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
}
file_access::unconfined(|| self.app.ui(ui));
// Preferences are written when confirmed, not only at exit: a session ended any other way
// (the system shutting down, the process ended or crashing) used to lose them (#1747).
if self.app.take_prefs_changed() {
file_access::unconfined(|| save_prefs(&self.app));
}
// Not while a file dialog's sheet has the keyboard: its name field's composition would go.
#[cfg(target_os = "macos")]
if !self.app.file_dialog_open() && self.app.take_ime_discard() {
discard_marked_text();
}
}
#[cfg(not(feature = "windows7"))]
fn on_exit(&mut self) {
file_access::unconfined(|| save_prefs(&self.app));
end_before_teardown(self.frames);
}
#[cfg(feature = "windows7")]
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
file_access::unconfined(|| save_prefs(&self.app));
}
}
impl App {
fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.frames = ctx.cumulative_frame_nr();
if let Some(why) = self.graphics_loss.take() {
// eframe can't give a window a new device: the user saves and starts again.
self.graphics_lost = self.app.graphics_lost(&why);
self.app.status(if self.graphics_lost {
"The graphics device was lost: unsaved changes are kept for Data Recovery. Save your documents and restart VectorCraft"
} else {
"The graphics device was lost: save your documents and restart VectorCraft"
});
}
if self.graphics_lost && ctx.input(|i| i.viewport().close_requested()) {
// The window can't show the Save Changes question: it closes, and the next launch
// offers the changes back.
vectorcraft_ui_egui::background::wait_all(&mut self.app);
return;
}
// Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs
// again on its document when it answers.
#[cfg(target_os = "macos")]
if !self.app.file_dialog_open() {
let files = open_documents::take();
if !files.is_empty() {
bring_window_forward(ctx);
}
open_files(&mut self.app, files);
}
self.app.logic(ctx);
if self.pending_maximize {
if let Some(w) = &self.winit_window {
self.pending_maximize = window::apply_pending_maximize(w, true, &mut self.maximize_tries);
} else {
self.pending_maximize = false;
}
}
let host_maximized = self.winit_window.as_ref().map(|w| w.is_maximized());
window::track(ctx, &mut self.app.ui.window, host_maximized);
if self.app.ui.status == "quit" {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}
/// On macOS with accessibility, end the process once the app has saved what it keeps on quitting,
/// before eframe tears the window down (#661). AccessKit then gives the window's content view back
/// its own class, which AppKit's Touch Bar support was observing it under, and AppKit aborts the
/// app as it quits on Macs with a Touch Bar. Nothing after the window is needed on a normal quit.
/// Not while the window is still starting up (`frames` below [`gpu::STARTUP_FRAMES`]): a graphics
/// failure then starts the app again on another adapter (#651).
#[allow(unused_variables)]
fn end_before_teardown(frames: u64) {
#[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))]
if frames >= gpu::STARTUP_FRAMES {
log::info!("quitting");
log::logger().flush();
std::process::exit(0);
}
}
/// Show the window when files are opened from the Finder or the Dock: a minimized window comes back
/// out of the Dock and the app takes focus, as a document-based Mac app does (#3769).
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn bring_window_forward(ctx: &egui::Context) {
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
}
/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that
/// can't be opened is reported to the user and on stderr; the others still open.
fn open_files(app: &mut VectorcraftApp, files: Vec<String>) {
for f in files {
if let Err(e) = vectorcraft_ui_egui::io::open_reporting(app, &f) {
eprintln!("vectorcraft: {f}: {e}");
}
}
}
/// Tell the macOS input method to drop its composition (the Type tool kept the marked text as
/// typed). winit's IME toggle only clears its own copy, so the IME would type it again.
#[cfg(target_os = "macos")]
fn discard_marked_text() {
if let Some(mtm) = objc2::MainThreadMarker::new()
&& let Some(ic) = objc2_app_kit::NSTextInputContext::currentInputContext(mtm)
{
ic.discardMarkedText();
}
}
/// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS),
/// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux).
fn prefs_path() -> Option<std::path::PathBuf> {
prefs_dir::prefs_path_for("VectorCraft", "vectorcraft")
}
/// The same place under the project's former name (DrawCraft): read once if there are no
/// VectorCraft preferences yet, so settings survive the rename.
fn legacy_prefs_path() -> Option<std::path::PathBuf> {
prefs_dir::prefs_path_for("DrawCraft", "drawcraft")
}
/// Where the log files live: `logs` in the preferences folder (see `logging`).
fn log_dir() -> Option<std::path::PathBuf> {
Some(prefs_path()?.parent()?.join("logs"))
}
/// Runs without preferences (`VECTORCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
fn prefs_enabled() -> bool {
std::env::var_os("VECTORCRAFT_NO_PREFS").is_none()
}
/// The saved UI preferences, read before the window opens (they hold its size and position).
fn read_prefs() -> Option<vectorcraft_ui_egui::UiState> {
if !prefs_enabled() {
return None;
}
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?;
decode_saved_prefs(&bytes)
}
/// Recover only optional persisted docking metadata. Runtime UiState and command decoding
/// remains strict, and an unreadable workspace layout cannot discard unrelated preferences.
fn decode_saved_prefs(bytes: &[u8]) -> Option<vectorcraft_ui_egui::UiState> {
use serde_json::{Map, Value, json};
fn recover(object: &mut Map<String, Value>, hidden_key: &str) {
if let Some(layout) = object.get("docking")
&& serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking": layout})).is_err()
{
object.remove("docking");
}
if let Some(hidden) = object.get_mut(hidden_key) {
if let Value::Object(entries) = hidden {
entries.retain(|panel, location| {
serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking_hidden": {panel: location}})).is_ok()
});
} else {
object.remove(hidden_key);
}
}
}
let mut value: Value = serde_json::from_slice(bytes).ok()?;
let object = value.as_object_mut()?;
recover(object, "docking_hidden");
// Saved workspaces and the layout changes kept for each workspace carry the same layouts.
for key in ["custom_workspaces", "workspace_changes"] {
if let Some(Value::Array(workspaces)) = object.get_mut(key) {
for workspace in workspaces {
if let Some(object) = workspace.as_object_mut() {
recover(object, "dockingHidden");
}
}
}
}
serde_json::from_value(value).ok()
}
fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiState>) {
if !prefs_enabled() {
return;
}
if let Some(ui) = saved {
app.ui = ui.sanitized();
}
vectorcraft_ui_egui::prefs_dialog::restore(app);
}
fn save_prefs(app: &VectorcraftApp) {
if !prefs_enabled() {
return;
}
if let Some(p) = prefs_path() {
let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));
let mut ui = app.ui.clone();
ui.engine_prefs = app.session.prefs.to_json();
if let Ok(bytes) = serde_json::to_vec_pretty(&ui) {
// Preferences are best effort: a failed write keeps the previous file.
let _ = fileio::write_atomic(&p, &bytes);
}
}
}
/// The window file dialogs belong to.
type Parent = Option<std::sync::Arc<winit::window::Window>>;
/// What rfd's blocking and asynchronous file dialogs are both set up with.
trait DialogSettings: Sized {
fn parent(self, window: &winit::window::Window) -> Self;
fn title(self, title: &str) -> Self;
fn filter(self, name: &str, exts: &[impl ToString]) -> Self;
fn folder(self, folder: &str) -> Self;
fn file_name(self, name: &str) -> Self;
}
macro_rules! dialog_settings {
($($dialog:ty),*) => {$(
impl DialogSettings for $dialog {
fn parent(self, window: &winit::window::Window) -> Self {
self.set_parent(window)
}
fn title(self, title: &str) -> Self {
self.set_title(title)
}
fn filter(self, name: &str, exts: &[impl ToString]) -> Self {
self.add_filter(name, exts)
}
fn folder(self, folder: &str) -> Self {
self.set_directory(folder)
}
fn file_name(self, name: &str) -> Self {
self.set_file_name(name)
}
}
)*};
}
dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog);
/// `d` set up for `request`: its title, file types, folder and suggested name.
fn dialog_for<D: DialogSettings>(d: D, request: &PickRequest) -> D {
let pick = match request {
PickRequest::Open(pick) | PickRequest::Save(pick) => pick,
PickRequest::OpenMany => {
return fileio::place_filters().fold(d.title(vectorcraft_ui_egui::i18n::t("Place")), |d, (name, exts)| {
d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts)
});
}
PickRequest::Folder => return d,
};
let d = pick.filters.iter().fold(d, |d, (name, exts)| {
d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), exts)
});
let d = match &pick.folder {
Some(folder) => d.folder(folder),
None => d,
};
if pick.name.is_empty() { d } else { d.file_name(&pick.name) }
}
/// A native file dialog for `request` over `parent` (on Linux the portal then shows it over the
/// window and keeps it in front).
#[cfg(not(target_os = "macos"))]
fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog {
match parent {
Some(w) => dialog_for(rfd::FileDialog::new().parent(w), request),
None => dialog_for(rfd::FileDialog::new(), request),
}
}
/// Before `request`'s dialog shows: a save dialog's folder (the Templates folder) may not exist yet.
fn make_folder(request: &PickRequest) {
if let PickRequest::Save(FilePick { folder: Some(folder), .. }) = request {
let _ = std::fs::create_dir_all(folder);
}
}
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked. On
/// Linux and the BSDs a save dialog with two or more file types is the desktop portal's, over
/// `parent`, and its answer also names the file type picked ([`portal_save`]); `dialog` shows when
/// the portal can't.
#[cfg(not(target_os = "macos"))]
#[cfg_attr(windows, allow(unused_variables))]
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec<String> {
make_folder(request);
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
if let PickRequest::Save(pick) = request
&& let Some(answer) = portal_save::pick(pick, parent.as_deref())
{
return answer;
}
let paths = match request {
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
PickRequest::Save(_) => dialog.save_file().into_iter().collect(),
PickRequest::Folder => dialog.pick_folder().into_iter().collect(),
};
paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect()
}
/// Show the dialog for `request` over `parent` now → the paths picked.
#[cfg(not(target_os = "macos"))]
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
show_dialog(file_dialog(&request, parent), &request, parent)
}
/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an
/// event winit held back meanwhile aborts the app (#867). Dialogs are sheets there ([`start_pick`]);
/// one asked for any other way (a bug: outside a command, a dialog's OK or a button) picks nothing.
#[cfg(target_os = "macos")]
fn pick_now(request: PickRequest, _parent: &Parent) -> Vec<String> {
log::error!("no file dialog shown for {request:?}: macOS shows them as sheets, from commands, dialogs' OK and buttons only");
Vec::new()
}
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
fn reveal(path: &str) -> Result<(), String> {
explorer_safe(path)?;
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
}
#[cfg(target_os = "macos")]
fn reveal_command(path: &str) -> std::process::Command {
let mut c = std::process::Command::new("open");
c.args(["-R", path]);
c
}
#[cfg(windows)]
fn reveal_command(path: &str) -> std::process::Command {
use std::os::windows::process::CommandExt as _;
// Explorer reads `/select,"path"` itself (the usual argument quoting breaks paths with spaces)
// and needs backslashes.
let mut c = std::process::Command::new("explorer");
c.raw_arg(format!("/select,\"{}\"", path.replace('/', "\\")));
c
}
#[cfg(not(any(target_os = "macos", windows)))]
fn reveal_command(path: &str) -> std::process::Command {
let folder = std::path::Path::new(path).parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
let mut c = std::process::Command::new("xdg-open");
c.arg(folder);
c
}
/// Write a file the safe way: a failed write keeps the old file ([`fileio::write_atomic`]).
fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
}
/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the
/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then
/// offers to kill the window as not responding (#592).
#[cfg(not(target_os = "macos"))]
fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
let dialog = file_dialog(&request, parent);
let (tx, rx) = std::sync::mpsc::channel();
let ui = ui.clone();
let parent = parent.clone();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
// The app gone meanwhile has no use for the answer.
let _ = tx.send(show_dialog(dialog, &request, &parent));
ui.request_repaint();
})
.ok()?;
Some(rx)
}
/// The paths a dialog shown as a sheet picks, once it closes.
#[cfg(target_os = "macos")]
type SheetAnswer = std::pin::Pin<Box<dyn std::future::Future<Output = Vec<String>> + Send>>;
/// macOS: show the dialog for `request` as a sheet on `parent`'s window, answering on the receiver
/// (#867). Shown in line, the panel ran AppKit's modal event loop inside winit's event handler: an
/// event winit had held back meanwhile (one came as a resize of the panel ended) then re-entered
/// the handler, and winit's panic there, inside an AppKit callback, aborted the app. The sheet
/// returns at once and the window keeps running; a thread of its own waits for the answer.
#[cfg(target_os = "macos")]
fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
use winit::raw_window_handle::HasWindowHandle as _;
// rfd runs the panel modally after all unless it has the running app's window to hold the
// sheet (and from another thread it can't reach the window): no dialog then (`pick_now`).
let mtm = objc2::MainThreadMarker::new();
let running = mtm.is_some_and(|mtm| objc2_app_kit::NSApplication::sharedApplication(mtm).isRunning());
let Some(window) = parent.as_ref().filter(|w| running && w.window_handle().is_ok()) else {
log::error!("no window to show the file dialog on (main thread: {}, running: {running})", mtm.is_some());
return None;
};
// The thread first, so the answer of a sheet once shown always has somewhere to go.
let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::<SheetAnswer>();
let (tx, rx) = std::sync::mpsc::channel();
let ui = ui.clone();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
if let Ok(answer) = sheet_rx.recv() {
// The app gone meanwhile has no use for the answer.
let _ = tx.send(pollster::block_on(answer));
ui.request_repaint();
}
})
.ok()?;
make_folder(&request);
// The panel is built and the sheet is begun below, on the main thread (as it must).
let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request);
let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string();
let answer: SheetAnswer = match request {
PickRequest::Open(_) => {
let picked = dialog.pick_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
PickRequest::OpenMany => {
let picked = dialog.pick_files();
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
}
// A save panel with two or more named file types is built by `mac_save_format`, which sets
// up its Format menu before the sheet begins (#866); rfd shows the others.
PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) {
// The dialog thread blocks here until the sheet answers.
Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }),
None => {
// Nothing is added to the sheet once rfd has begun it: an accessory view set then
// made recent macOS versions dismiss the sheet at once (#1800).
let picked = dialog.save_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
},
PickRequest::Folder => {
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
};
// The thread can't be gone: it waits for this. If it were, its receiver answers as cancelled.
let _ = sheet_tx.send(answer);
Some(rx)
}
/// The host's services; `ui` is woken when a file dialog answers.
fn services(parent: Parent, ui: egui::Context) -> Services {
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
let wayland = parent.as_ref().and_then(|w| w.window_handle().ok()).is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Wayland(_)));
// A window on X11 (XWayland included) reads a tablet pen's pressure itself (#764).
#[cfg(target_os = "linux")]
let x11 = parent
.as_ref()
.and_then(|w| w.window_handle().ok())
.is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)));
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
#[cfg(target_os = "macos")]
let sampled_ui = ui.clone();
Services {
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())),
// Linux shows dialogs on a thread of their own (#592), macOS as sheets (#867); Windows
// dialogs run the window's events while they are open.
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick),
dialogs_are_sheets: cfg!(target_os = "macos"),
wayland,
no_cursor_theme: wayland && cursor_theme::theme_missing(),
#[cfg(target_os = "linux")]
stylus: if x11 { tablet::start() } else { None },
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
// Background Save and Export write from a worker thread.
write_shared: Some(std::sync::Arc::new(write_file)),
// Native saves stream into the file as they are encoded.
stream_shared: Some(std::sync::Arc::new(|p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| {
fileio::write_file_with(p, fill).map_err(|e| e.to_string())
})),
// Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event).
system_clipboard: Some(clipboard::system_clipboard()),
// Linux checks whether Paste has something to take on a background thread: an X11 clipboard
// owner that never answers holds a read for up to 4 s. Windows only asks which formats the
// clipboard holds and macOS asks the pasteboard server, so they check in line.
clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(clipboard::system_clipboard) as ClipboardProbeFactory),
// Help → Discord / website / GitHub, the Discord button, About and Home links.
open_url: Some(Box::new(|url: &str| {
let _ = webbrowser::open(url);
})),
reveal: Some(Box::new(reveal)),
// Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders.
open_file: Some(Box::new(open_file)),
pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())),
// Sample Color from Screen: the system's color sampler.
#[cfg(target_os = "macos")]
screen_color: Some(mac_screen_color::sampler(sampled_ui)),
// File → Print: the system's printers and print queue.
print: Some(Box::new(printing::SystemPrint)),
..Default::default()
}
}
/// Windows passes `path` to Explorer inside quotes it adds itself, so a path holding a quote (which
/// no Windows file name has) is refused rather than split into other arguments.
fn explorer_safe(path: &str) -> Result<(), String> {
if cfg!(windows) && path.contains('"') { Err(format!("can't open {path}: the path contains a quotation mark")) } else { Ok(()) }
}
/// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it.
fn open_file(path: &str) -> Result<(), String> {
explorer_safe(path)?;
#[cfg(windows)]
let mut c = {
use std::os::windows::process::CommandExt as _;
let mut c = std::process::Command::new("explorer");
c.raw_arg(format!("\"{}\"", path.replace('/', "\\")));
c
};
#[cfg(target_os = "macos")]
let mut c = std::process::Command::new("open");
#[cfg(not(any(target_os = "macos", windows)))]
let mut c = std::process::Command::new("xdg-open");
#[cfg(not(windows))]
c.arg(path);
c.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}"))
}
/// The window, Dock, taskbar and app-switcher icon (`assets/app-icon/`, see its README). macOS gets
/// the version with Apple's transparent margin; elsewhere the full-bleed tile. The app ID matches
/// `packaging/linux/ai.storyteller.vectorcraft.desktop` so Wayland docks find the launcher icon.
fn app_icon() -> egui::IconData {
#[cfg(target_os = "macos")]
let png: &[u8] = include_bytes!("../../../assets/app-icon/vectorcraft-macos-512.png");
#[cfg(not(target_os = "macos"))]
let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.vectorcraft.png");
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
}
/// Windows shows a window's big icon (`ICON_BIG`) in the taskbar and Alt+Tab; eframe sets only the
/// small one (the title bar's), so the taskbar showed a generic icon. Set the big one too.
#[cfg(windows)]
fn set_taskbar_icon(w: &winit::window::Window) {
use winit::platform::windows::WindowExtWindows as _;
let icon = app_icon();
// An icon that can't be made leaves the generic one: nothing else depends on it.
if let Ok(i) = winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) {
w.set_taskbar_icon(Some(i));
}
}
/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports.
#[cfg(feature = "wgpu")]
fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type)
}
/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
/// macOS keeps its traffic lights over the integrated title strip.
///
/// Used on macOS and Windows (where VectorCraft keeps today's frame regardless of the
/// `craft-window` probe) and by the tests; Linux and the BSDs take the decision from the probe.
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
/// Whether the user asked for the system title bar in Preferences › User Interface › System Title
/// Bar. Read from the saved preferences before the window opens; a missing or unreadable value is
/// `false` (the default). This preference forces the desktop/native frame back on.
fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
}
/// Whether this start draws its own title bar, given the frame the shared `craft-window` crate
/// decided and the user's System Title Bar preference.
///
/// The suite-wide rule (`craft_window::Choice`) is: the desktop draws the frame
/// ([`craft_window::Frame::Desktop`]) on macOS, Windows, X11 and any Wayland compositor with
/// server-side decorations (KDE, Sway, Hyprland, COSMIC); the app draws it
/// ([`craft_window::Frame::App`]) only where the compositor leaves it to the app (GNOME's Mutter),
/// or when `VECTORCRAFT_TITLEBAR` overrides the probe. VectorCraft draws its own title bar exactly
/// when `craft-window` says the app owns the frame **and** the user has not opted into the system
/// title bar; the preference always forces the native frame back on.
///
/// macOS and Windows keep today's behavior and are decided by [`CUSTOM_TITLEBAR`] rather than the
/// probe: `craft-window` returns `Frame::Desktop` for both (the OS always draws a frame), but
/// VectorCraft insets its content under the macOS traffic lights and draws its own caption buttons
/// on Windows — an overdraw on an OS-decorated window that is VectorCraft's choice, not
/// `craft-window`'s. Only Linux and the BSDs take the decision from the probe.
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn custom_titlebar_from(frame: &craft_window::Frame, opted_into_system: bool) -> bool {
matches!(frame, craft_window::Frame::App(_)) && !opted_into_system
}
/// macOS and Windows: today's behavior, independent of the `craft-window` probe (see
/// [`custom_titlebar_from`]). macOS never draws its own frame (`CUSTOM_TITLEBAR` is `false`);
/// Windows draws its own caption unless the user opted into the system title bar.
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_system: bool) -> bool {
CUSTOM_TITLEBAR && !opted_into_system
}
/// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its
/// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System
/// Title Bar preference. Call once at startup, before [`native_options`].
///
/// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through
/// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs
/// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native
/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins.
fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT"));
let opted_into_system = opted_into_system_title_bar(saved);
#[cfg(target_os = "linux")]
{
let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
// `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window
// will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are
// present, the same two conditions the launch path checks below — that run is effectively
// X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so
// the probe's own decision (app-drawn on GNOME) must stand.
let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found();
if choice.overridden().is_none() && forced_to_x11 {
return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system);
}
}
custom_titlebar_from(choice.frame(), opted_into_system)
}
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("VectorCraft")
.with_inner_size(window::DEFAULT_SIZE)
.with_min_inner_size(window::MIN_SIZE)
.with_drag_and_drop(true)
.with_decorations(!custom_titlebar)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_icon(app_icon())
.with_app_id("ai.storyteller.vectorcraft"),
#[cfg(feature = "windows7")]
renderer: eframe::Renderer::Glow,
..Default::default()
}
}
/// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s.
const USAGE: &str = "\
Usage: vectorcraft [options] [files…]
Opens the VectorCraft desktop app with the given files.
Options:
--control <port> Start the localhost JSON-lines control server on <port>
(or VECTORCRAFT_CONTROL_PORT)
--automation-read-root <dir> Confine the files control requests read to <dir>
(may be given more than once)
--automation-write-root <dir> Confine the files control requests write to <dir>
(may be given more than once)
--in-window-menus Keep the menus inside the window on macOS
(or VECTORCRAFT_IN_WINDOW_MENUS=1)
--version Print the version and exit
-h, --help Print this help and exit
Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
vectorcraft-cli --help
";
fn main() -> std::process::ExitCode {
// Before any thread starts: the pointer must find a cursor theme on Wayland (#2450).
#[cfg(target_os = "linux")]
cursor_theme::apply();
// Validate roots before logging, font scans, preferences or the window: a missing value
// must not clear an environment root and start the control channel without confinement.
let mut control = automation_args::ControlArg::default();
let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
let mut roots_args =
automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT"));
let mut files = Vec::new();
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) {
Ok(true) => continue,
Ok(false) => {}
Err(e) => {
eprintln!("vectorcraft: {e}");
return std::process::ExitCode::FAILURE;
}
}
match a.as_str() {
"--in-window-menus" => in_window_menus = true,
"--version" => {
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
return std::process::ExitCode::SUCCESS;
}
// Before anything starts, like `--version`: from a terminal or an agent's shell the app
// used to open (or hang without a display) instead (#2110).
"--help" | "-h" => {
// Not `print!`, which panics when standard output is closed (`| head -0`):
// there is nothing left to tell then.
let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());
return std::process::ExitCode::SUCCESS;
}
_ => files.push(a),
}
}
let automation_args::AutomationArgs { read_roots, write_roots, .. } = roots_args;
// After the arguments: `--control` and `--version` win over a bad environment value.
let control_port = match control.port {
Some(port) => Some(port),
None => match automation_args::env_control_port(std::env::var_os("VECTORCRAFT_CONTROL_PORT")) {
Ok(port) => port,
Err(e) => {
eprintln!("vectorcraft: {e}");
return std::process::ExitCode::FAILURE;
}
},
};
// Before desktop services, so every start-up warning is recorded (`logging`).
let logger = logging::install();
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
// Before the first font scan (the app's start): the fonts font services load (#579).
#[cfg(all(windows, not(target_vendor = "win7")))]
system_fonts::install();
#[cfg(target_os = "macos")]
mac_fonts::install();
// VectorCraft's own Fonts folder, which text.addFontFiles copies fonts into.
prefs_dir::install_fonts();
// The log file lives in the settings directory, next to the preferences; opened after the
// arguments, so `--version` leaves no file behind. Records logged until now are written to it
// first. Runs without preferences (agents' test runs) log to standard error only, so they
// don't rotate away the user's own logs.
if let Some(logger) = logger {
match log_dir().filter(|_| prefs_enabled()) {
Some(dir) => match logger.attach_dir(&dir) {
Ok(path) => log::info!("VectorCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
Err(e) => log::warn!("no log file: {e}"),
},
None => logger.no_file(),
}
}
// The roots confine the control channel: a bad one keeps the app from starting rather than
// leaving agents unconfined. Without a control port there is nothing to confine.
let roots = if control_port.is_none() {
if !read_roots.is_empty() || !write_roots.is_empty() {
log::warn!("--automation-read-root and --automation-write-root confine the control channel: ignored without --control");
}
None
} else {
let read: Vec<&std::path::Path> = read_roots.iter().map(std::path::PathBuf::as_path).collect();
let write: Vec<&std::path::Path> = write_roots.iter().map(std::path::PathBuf::as_path).collect();
match AutomationRoots::new(&read, &write) {
Ok(roots) => roots,
Err(e) => {
log::error!("{e}");
eprintln!("vectorcraft: {e}");
return std::process::ExitCode::FAILURE;
}
}
};
let saved = read_prefs();
let saved_window = saved.as_ref().and_then(|ui| ui.window);
#[cfg(feature = "wgpu")]
let gpu_pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("gpuPreference")).and_then(serde_json::Value::as_str);
#[cfg(feature = "wgpu")]
let power_env = eframe::wgpu::PowerPreference::from_env();
#[cfg(feature = "wgpu")]
let power = gpu::power_preference(gpu_pref, power_env);
// Automatic draws on the GPU that drives the (primary) display: a GPU without a monitor reset
// its driver and took every monitor down (pdfcraft#378). Logged first: the first question in
// every black-window report.
#[cfg(feature = "wgpu")]
let displays = gpu::preferred_displays(gpu_pref, power_env);
// Under Wayland, the GPU the compositor draws with comes even before those (src/compositor_gpu.rs).
#[cfg(feature = "wgpu")]
let compositor = gpu::preferred_compositor(gpu_pref, power_env);
#[cfg(feature = "wgpu")]
let chosen_gpu = !gpu::automatic(gpu_pref, power_env);
#[cfg(feature = "wgpu")]
if !chosen_gpu {
let listed: Vec<String> = displays.iter().map(ToString::to_string).collect();
log::info!("display GPUs (PCI vendor:device): {}", if listed.is_empty() { "unknown".to_string() } else { listed.join(", ") });
} else {
log::info!("graphics processor chosen by the user ({power:?}): which GPU drives the display isn't considered");
}
#[cfg(feature = "wgpu")]
let startup = std::sync::Arc::new(gpu::Startup::default());
// A driver that crashes the process while the window starts leaves this start recorded in the
// log folder; the next start leaves that adapter out (#2243). Runs without preferences, which
// write no log file, don't record their starts.
#[cfg(feature = "wgpu")]
let left_out = match log_dir().filter(|_| prefs_enabled()) {
Some(dir) => startup.track(&dir, gpu_pref, gpu::explicit_choice()),
None => gpu::LeftOut::default(),
};
#[cfg(feature = "wgpu")]
let crash = left_out.crash.clone();
let custom_titlebar = custom_titlebar(saved.as_ref());
let options = native_options(custom_titlebar);
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151),
// and X11 (XWayland) When a Pen Is Attached does so when a pen is attached, where the pen draws (#2028).
#[cfg(target_os = "linux")]
let options = {
let mut options = options;
let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
if xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) {
if xwayland::x11_libraries_found() {
if pref == Some("x11WithPen") {
log::info!("pen found: opening the window through XWayland (Preferences › Performance › Linux Display Server)");
} else {
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
}
use winit::platform::x11::EventLoopBuilderExtX11 as _;
options.event_loop_builder = Some(Box::new(|builder| {
builder.with_x11();
}));
} else {
log::warn!("Linux Display Server is X11, but the X11 libraries aren't all installed: opening the window on Wayland");
}
}
options
};
// With Windows Ink, winit reports a pen as a touch and drops its barrel button; the message
// hook notes each pointer contact and whether it starts with the barrel button held
// (`windows_stylus`).
#[cfg(all(windows, not(target_vendor = "win7")))]
let options = {
use winit::platform::windows::EventLoopBuilderExtWindows as _;
let mut options = options;
options.event_loop_builder = Some(Box::new(|builder| {
builder.with_msg_hook(windows_stylus::msg_hook);
}));
options
};
#[cfg(feature = "wgpu")]
let options = {
let mut options = options;
// One frame queued, not two: the canvas is rasterized on the CPU and the GPU only
// composites it, so the window answers the pointer a frame sooner (#444).
options.wgpu_options.surface = eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY;
// Only adapters that can show the window, in the order `gpu` gives (#306, #502).
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
create.native_adapter_selector = Some(gpu::selector(power, displays, compositor, left_out.adapters.clone(), startup.clone()));
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only
// costs a compute shader at start-up, one some drivers can't compile (OCLP-patched
// Metal on an Iris Pro, #651). `WGPU_VALIDATION_INDIRECT_CALL=1` turns it back on.
create.instance_descriptor.flags =
(eframe::wgpu::InstanceFlags::from_build_config() - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
create.instance_descriptor.backends =
left_out.backends(gpu::backends(create.instance_descriptor.backends, std::env::var_os("WGPU_BACKEND").is_some()));
// DX12 shaders compile with FXC, part of Windows: wgpu's default loads whichever
// dxcompiler.dll the search path finds, and one without its dxil.dll fails every shader,
// losing the device before the first frame (#1194). `WGPU_DX12_COMPILER` picks another.
create.instance_descriptor.backend_options.dx12.shader_compiler =
eframe::wgpu::Dx12Compiler::from_env().unwrap_or(eframe::wgpu::Dx12Compiler::Fxc);
// The window's surface may be as large as the adapter allows, not eframe's fixed
// 8192 px: a window stretched over several monitors, or grown by a display scale
// change, otherwise fails to configure its surface and the app closes (#1459).
let device = create.device_descriptor.clone();
create.device_descriptor = std::sync::Arc::new(move |adapter: &eframe::wgpu::Adapter| {
let mut descriptor = device(adapter);
let limit = &mut descriptor.required_limits.max_texture_dimension_2d;
*limit = gpu::surface_limit(*limit, adapter.limits().max_texture_dimension_2d);
descriptor
});
}
options
};
#[cfg(feature = "wgpu")]
gpu::watch_panics();
#[cfg(feature = "wgpu")]
gpu::watch_first_frame(startup.clone());
// Files opened from Finder and the Dock arrive as events, not arguments.
#[cfg(target_os = "macos")]
open_documents::install();
#[cfg(feature = "wgpu")]
let created = startup.clone();
// A panic that ends the window (egui-wgpu's own, #502) is caught here: never a crash.
let run = |options: eframe::NativeOptions, app_creator: eframe::AppCreator<'_>| {
#[cfg(target_os = "linux")]
{
linux_stylus::run_native("VectorCraft", options, app_creator)
}
#[cfg(not(target_os = "linux"))]
{
eframe::run_native("VectorCraft", options, app_creator)
}
};
let outcome = vectorcraft_engine::guard::catch_panic(|| {
run(
options,
Box::new(move |cc| {
let mut session = Session::new();
// The profiles loaded before come back before the preferences name any of them.
prefs_dir::attach_profile_store(&mut session);
prefs_dir::attach_plugin_store(&mut session);
let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
load_prefs(&mut app, saved);
// The saved PDF presets live in their own folder (also read by vectorcraft-cli),
// which takes over from the copy the preferences file used to hold.
prefs_dir::attach_pdf_preset_store(&mut app.session);
// Fit the window to its monitor, or put it back where it was (still hidden).
let winit_window = cc.winit_window().cloned();
if let Some(w) = &winit_window {
let g = window::restore(w, saved_window);
app.ui.window = Some(g);
#[cfg(windows)]
set_taskbar_icon(w);
}
// Linux may not map the window before the first frames, so a saved maximize is retried then (#3635).
let pending_maximize = cfg!(target_os = "linux") && app.ui.window.as_ref().is_some_and(|g| g.maximized);
// User Defined swatch and graphic style libraries live next to the preferences.
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
app.session.swatch_libraries.set_user_dir(swatches);
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
app.session.style_libraries.set_user_dir(styles);
// With the User Defined libraries listed, the persistent library panels reopen.
vectorcraft_ui_egui::panels::library_panel::restore(&mut app);
// So do the Libraries panel's libraries; runs without preferences (agents' test
// runs) keep theirs for the session only, never touching the user's.
if prefs_enabled() {
let libraries = prefs_path().and_then(|p| Some(p.parent()?.join("Libraries").to_string_lossy().to_string()));
app.session.libraries.set_dir(libraries);
}
// Data Recovery copies live next to the preferences too (none for runs without
// preferences, such as agents' test runs, unless the recoveryFolder preference is set).
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() {
let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string()));
app.session.recovery.set_default_folder(recovery);
}
let graphics_loss = GraphicsLoss::default();
#[cfg(feature = "wgpu")]
if let Some(rs) = &cc.wgpu_render_state {
let summary = adapter_summary(&rs.adapter.get_info());
log::info!("rendering with {summary} (power preference {power:?})");
created.created(&cc.egui_ctx);
if !gpu::skipped().is_empty() {
// A processor chosen in Settings can be the one that can't show the window.
let hint = if chosen_gpu {
" (Settings › Performance › Graphics Processor › Automatic uses the one that drives your display)"
} else {
""
};
app.status(format!(
"The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}{hint}"
));
} else if let Some(crash) = &crash {
app.status(match crash {
gpu::Crash::Adapter { name, .. } => format!(
"The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set"
),
gpu::Crash::Listing => format!(
"The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set"
),
});
}
app.graphics_adapter = Some(summary);
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
}
#[cfg(feature = "windows7")]
{
app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into());
}
app.custom_titlebar = custom_titlebar;
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx).with_automation_roots(roots.clone());
if let Some(roots) = &roots {
// Everywhere the app doesn't say whose work it is (worker threads), the
// roots hold: unconfined is only what the person at the keyboard does.
file_access::confine_process(roots.clone());
log::info!("control channel confined to the automation roots: {}", roots.describe());
}
}
#[cfg(target_os = "macos")]
{
open_documents::set_ui(&cc.egui_ctx);
// The macOS menu bar, installed now so winit's default menu doesn't stay up.
if !in_window_menus {
app.services.native_menu = mac_menu::install(&cc.egui_ctx, &app);
}
}
#[cfg(not(target_os = "macos"))]
let _ = in_window_menus;
file_access::unconfined(|| open_files(&mut app, files));
// The first frame is due from now (`gpu::watch_first_frame`).
#[cfg(feature = "wgpu")]
created.ready();
// The content and the pointer stay aligned on Windows (#1758).
let window_frame = window_frame::service(cc, custom_titlebar);
Ok(Box::new(App {
app,
graphics_loss,
graphics_lost: false,
frames: 0,
window_frame,
winit_window,
pending_maximize,
maximize_tries: 8,
}))
}),
)
});
#[cfg(feature = "wgpu")]
let outcome = gpu::finish(outcome, &startup);
#[cfg(not(feature = "wgpu"))]
let outcome = outcome.and_then(|r| r.map_err(|e| e.to_string()));
match outcome {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(e) => {
log::error!("VectorCraft stopped: {e}");
std::process::ExitCode::FAILURE
}
}
}
#[cfg(all(test, feature = "wgpu"))]
mod tests {
use super::*;
fn ui_state_with_prefs(engine_prefs: serde_json::Value) -> vectorcraft_ui_egui::UiState {
vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() }
}
#[test]
fn opening_files_unminimizes_and_focuses_the_window() {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| bring_window_forward(ui.ctx()));
// Nothing paints in the test: drop the font atlas upload egui expects to be applied.
out.textures_delta.clear();
let cmds = &out.viewport_output[&egui::ViewportId::ROOT].commands;
assert!(cmds.contains(&egui::ViewportCommand::Minimized(false)));
assert!(cmds.contains(&egui::ViewportCommand::Focus));
}
#[test]
fn unreadable_saved_docking_keeps_preferences_and_every_workspace() {
use serde_json::json;
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
app.ui.toolbar = false;
app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"});
app.ui.custom_workspaces =
vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")];
app.ui.workspace_changes = vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future")];
let original = serde_json::to_value(&app.ui).unwrap();
let valid_location = original["docking_hidden"]["layers"].clone();
assert!(valid_location.is_object());
for invalid_layout in [json!({"root":{"FutureNode":{}},"floating":[]}), json!(false)] {
for invalid_hidden in [json!({"layers":{"placement":{"FuturePlacement":{}}},"swatches":valid_location}), json!(["malformed"])] {
let mut raw = original.clone();
raw["docking"] = invalid_layout.clone();
raw["docking_hidden"] = invalid_hidden.clone();
raw["custom_workspaces"][0]["docking"] = invalid_layout.clone();
raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone();
raw["workspace_changes"][0]["docking"] = invalid_layout.clone();
raw["workspace_changes"][0]["dockingHidden"] = invalid_hidden.clone();
assert!(serde_json::from_value::<vectorcraft_ui_egui::UiState>(raw.clone()).is_err(), "runtime serde stays strict");
let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap();
assert_eq!(loaded.engine_prefs, app.ui.engine_prefs);
assert!(!loaded.toolbar);
assert!(loaded.docking.is_none());
assert!(!loaded.docking_hidden.contains_key("layers"));
if invalid_hidden.is_object() {
assert_eq!(serde_json::to_value(loaded.docking_hidden.get("swatches")).unwrap(), valid_location);
} else {
assert!(loaded.docking_hidden.is_empty());
}
assert_eq!(loaded.custom_workspaces.len(), 2);
assert_eq!(loaded.custom_workspaces[0].name, "Future");
assert!(loaded.custom_workspaces[0].docking.is_none());
assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers"));
assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]);
assert_eq!(loaded.workspace_changes.len(), 1, "a remembered layout is recovered, not the whole file lost");
assert!(loaded.workspace_changes[0].docking.is_none());
}
}
let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap();
assert_eq!(serde_json::to_value(roundtrip).unwrap(), original);
assert!(
decode_saved_prefs(&serde_json::to_vec(&json!({"toolbar":"invalid"})).unwrap()).is_none(),
"unrelated fields retain normal strict parsing"
);
}
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
use craft_window::{Frame, Layout};
// The System Title Bar preference (Preferences › User Interface) is read the same way on
// every platform; a missing or unreadable value is `false`.
assert!(!super::opted_into_system_title_bar(None), "no preferences: the default");
let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false}));
assert!(!super::opted_into_system_title_bar(Some(&off)));
let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true}));
assert!(super::opted_into_system_title_bar(Some(&on)));
// The suite-wide mapping (Frame + preference -> whether VectorCraft draws its own title
// bar). The desktop frame (macOS, Windows, X11, KDE/Wayland, …) always keeps native
// decorations; the app frame (GNOME/Wayland, or a `VECTORCRAFT_TITLEBAR` override) draws
// our own title bar — unless the user opted into the system title bar, which forces the
// native frame back on.
let app = Frame::App(Layout::default());
let desktop = Frame::Desktop;
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
assert!(super::custom_titlebar_from(&app, false), "GNOME/Wayland: VectorCraft draws its own");
assert!(!super::custom_titlebar_from(&app, true), "System Title Bar preference forces native");
assert!(!super::custom_titlebar_from(&desktop, false), "KDE/X11/…: keep the compositor's frame");
assert!(!super::custom_titlebar_from(&desktop, true));
}
// macOS and Windows keep today's behavior, independent of the probed frame: the pref still
// forces the native frame back on where a custom one would otherwise be drawn.
#[cfg(any(target_os = "macos", target_os = "windows"))]
{
assert_eq!(super::custom_titlebar_from(&desktop, false), super::CUSTOM_TITLEBAR);
assert_eq!(super::custom_titlebar_from(&app, false), super::CUSTOM_TITLEBAR, "the probe does not change macOS/Windows");
assert!(!super::custom_titlebar_from(&desktop, true));
}
assert_eq!(super::native_options(false).viewport.decorations, Some(true));
assert_eq!(super::native_options(true).viewport.decorations, Some(false));
}
/// The file extensions the macOS bundle declares: its document types and its own exported type.
fn plist_extensions(plist: &str) -> Vec<&str> {
["<key>CFBundleTypeExtensions</key>", "<key>public.filename-extension</key>"]
.iter()
.flat_map(|key| plist.split(key).skip(1))
.filter_map(|rest| rest.split("</array>").next())
.flat_map(|array| array.split("<string>").skip(1))
.filter_map(|s| s.split("</string>").next())
.collect()
}
/// Finder offers the app for every file File › Open reads (#295, #354) but those with an
/// extension in `UNASSOCIATED_EXTS`, takes over no other app's files, and hands them to the app
/// rather than to AppKit's document machinery.
#[test]
fn the_macos_bundle_opens_every_readable_format() {
let plist = include_str!("../../../packaging/macos/Info.plist.in");
let declared = plist_extensions(plist);
for e in fileio::OPEN_EXTS.iter().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)) {
assert!(declared.contains(e), "Info.plist.in doesn't declare .{e}");
}
for e in &declared {
assert!(fileio::OPEN_EXTS.contains(e), "Info.plist.in declares .{e}, which the app doesn't open");
}
assert!(!plist.contains("<key>NSDocumentClass</key>"), "not an NSDocument app: AppKit would refuse the files");
let types = plist.matches("<key>CFBundleTypeName</key>").count();
assert!(types > 1 && plist.matches("<key>LSHandlerRank</key>").count() == types, "every document type has a rank");
assert_eq!(plist.matches("<string>Owner</string>").count(), 2, "only VectorCraft documents and templates are owned");
}
/// The MIME types a Linux desktop gives each extension in `OPEN_EXTS` but `UNASSOCIATED_EXTS`
/// (one row per type): shared-mime-info's, or one that `ai.storyteller.vectorcraft.mime.xml`
/// declares.
const LINUX_TYPES: &[(&str, &str)] = &[
("vectorcraft", "application/x-vectorcraft"),
("drawcraft", "application/x-vectorcraft"),
("svg", "image/svg+xml"),
("svgz", "image/svg+xml-compressed"),
("pdf", "application/pdf"),
("ai", "application/illustrator"), // brand-ok: shared-mime-info's type
("ait", "application/x-ai-template"),
("png", "image/png"),
("jpg", "image/jpeg"),
("jpeg", "image/jpeg"),
("gif", "image/gif"),
("webp", "image/webp"),
("tif", "image/tiff"),
("tiff", "image/tiff"),
("bmp", "image/bmp"),
("vctemplate", "application/x-vectorcraft-template"),
("dxf", "image/vnd.dxf"),
("emf", "image/emf"),
("wmf", "image/wmf"),
("eps", "image/x-eps"),
("af", "application/x-affinity"),
("afdesign", "application/x-affinity"),
("afpub", "application/x-affinity"),
];
/// The types in `LINUX_TYPES` that shared-mime-info defines. The MIME package declares every
/// other one.
const LINUX_SHARED_TYPES: &[&str] = &[
"image/svg+xml",
"image/svg+xml-compressed",
"application/pdf",
"application/illustrator", // brand-ok: shared-mime-info's type
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/tiff",
"image/bmp",
"image/vnd.dxf",
"image/emf",
"image/wmf",
"image/x-eps",
];
/// Extensions in `LINUX_TYPES` whose type the MIME package matches by file content and not by a
/// glob, with the package's `<magic>` rule for that type (its lines joined, indentation removed).
const LINUX_BY_CONTENT: &[(&str, &str)] = &[(
"af",
// `MAGIC` at offset 0 and `DOCUMENT_KIND` at offset 8 (crates/affinity/src/container.rs).
r#"<magic><match type="string" offset="0" value="\x00\xff\x4b\x41"><match type="string" offset="8" value="nsrP"/></match></magic>"#,
)];
/// The desktop entry lists a MIME type for every extension in `OPEN_EXTS` but
/// `UNASSOCIATED_EXTS`, and the app's MIME package declares the types shared-mime-info doesn't
/// have.
#[test]
fn the_linux_desktop_entry_opens_every_readable_format() {
let desktop = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.desktop");
let mime = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.mime.xml");
let listed: Vec<&str> =
desktop.lines().find_map(|l| l.strip_prefix("MimeType=")).unwrap_or_default().split(';').filter(|t| !t.is_empty()).collect();
// Each type the MIME package declares, with the extensions of its globs and its `<magic>`
// rule (empty when it has none) in the form `LINUX_BY_CONTENT` gives.
let declared: Vec<(&str, Vec<&str>, String)> = mime
.split("<mime-type type=\"")
.skip(1)
.map(|block| {
let body = block.split("</mime-type>").next().unwrap_or_default();
let globs = body.split("<glob pattern=\"*.").skip(1).filter_map(|g| g.split('"').next()).collect();
let magic = match (body.find("<magic"), body.find("</magic>")) {
(Some(start), Some(end)) => body[start..end + "</magic>".len()].lines().map(str::trim).collect(),
_ => String::new(),
};
(block.split('"').next().unwrap_or_default(), globs, magic)
})
.collect();
let mut associated: Vec<&str> = fileio::OPEN_EXTS.iter().copied().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)).collect();
let mut typed: Vec<&str> = LINUX_TYPES.iter().map(|(e, _)| *e).collect();
associated.sort_unstable();
typed.sort_unstable();
typed.dedup();
assert_eq!(typed, associated, "a MIME type for each extension the packaging associates with the app");
for (e, t) in LINUX_TYPES {
assert!(listed.contains(t), "the desktop entry doesn't list {t} (.{e})");
let package = declared.iter().find(|(name, ..)| name == t);
if LINUX_SHARED_TYPES.contains(t) {
assert!(package.is_none(), "{t} is shared-mime-info's, and the MIME package declares it again");
continue;
}
let Some((_, globs, magic)) = package else { panic!("the MIME package doesn't declare {t} (.{e})") };
if let Some((_, rule)) = LINUX_BY_CONTENT.iter().find(|(content, _)| content == e) {
assert!(!globs.contains(e), "{t} matches .{e} files by content only");
assert_eq!(magic, rule, "{t}'s magic rule for .{e} files");
} else {
assert!(globs.contains(e), "{t} has no *.{e} glob");
}
}
for t in &listed {
assert!(LINUX_TYPES.iter().any(|(_, known)| known == t), "the desktop entry lists {t}, which the app doesn't open");
}
for (t, globs, _) in &declared {
assert!(listed.contains(t), "the desktop entry doesn't list the declared {t}");
for g in globs {
assert!(LINUX_TYPES.contains(&(*g, *t)), "{t}'s *.{g} isn't a file the app opens as {t}");
}
}
}
}