Compare commits

...
11 changed files with 541 additions and 29 deletions

No files matched your search

+30
View File
@@ -68,3 +68,33 @@ opt-level = 1
[profile.release]
lto = "thin"
codegen-units = 1
# The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the
# size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every
# visit, so this profile optimizes for size: fat LTO, and opt-level "s" for everything except the
# pixel-processing crates, which keep opt-level 3 so filters and compositing stay fast. panic
# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for
# the native app shell). packaging/web/package.sh fails the build above 24 MiB. Issue #198:
# this took the wasm from 26.3 MiB (release profile) to 18.8 MiB.
[profile.wasm-release]
inherits = "release"
opt-level = "s"
lto = "fat"
codegen-units = 1
strip = "debuginfo"
[profile.wasm-release.package."*"]
opt-level = "s"
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]
opt-level = 3
[profile.wasm-release.package.photocraft-cms]
opt-level = 3
[profile.wasm-release.package.photocraft-compose]
opt-level = 3
[profile.wasm-release.package.photocraft-algo]
opt-level = 3
[profile.wasm-release.package.photocraft-paint]
opt-level = 3
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>PhotoCraft</title>
<link data-trunk rel="rust" data-bin="photocraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-wasm-opt="z" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
+413
View File
@@ -0,0 +1,413 @@
//! Linux start-up check for the shared libraries winit and wgpu load at runtime (issue #201).
//!
//! The windowing (X11/Wayland, xkbcommon) and GPU (Vulkan, EGL) libraries aren't ELF `NEEDED`
//! entries: winit and wgpu `dlopen` them when the window opens, and some of those crates panic
//! when a library is missing (`xkbcommon-dl`: "libxkbcommon-x11.so could not be loaded"). So,
//! before eframe starts, we look for the libraries the current session type needs and, if one is
//! missing, print the package to install and exit non-zero instead of crashing (AGENTS.md, Never
//! crash).
//!
//! No `dlopen` here (that needs `unsafe`): we read the dynamic linker's cache (`ldconfig -p`) and
//! look in `LD_LIBRARY_PATH` and the usual library directories. Only the cache is conclusive: if
//! it can't be read (NixOS, some containers) and a library isn't found in the directories, we
//! warn and start anyway. `PHOTOCRAFT_SKIP_LIB_CHECK=1` skips the check.
//!
//! The parsing and decision logic is pure and tested on every platform; only [`preflight`]
//! touches the system, and it is only called on Linux.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::path::{Path, PathBuf};
/// The display server winit will connect to, chosen the way winit 0.30 does: Wayland when
/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set (non-empty), otherwise X11 when `DISPLAY` is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplaySession {
Wayland,
X11,
/// Neither is set: winit reports that itself (an error, not a panic).
None,
}
pub fn session_from_env(var: impl Fn(&str) -> Option<String>) -> DisplaySession {
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
DisplaySession::Wayland
} else if set("DISPLAY") {
DisplaySession::X11
} else {
DisplaySession::None
}
}
/// A runtime-loaded library and the package that provides it on each distro family.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Lib {
pub soname: &'static str,
pub debian: &'static str,
pub fedora: &'static str,
pub arch: &'static str,
pub suse: &'static str,
}
const fn lib(soname: &'static str, debian: &'static str, fedora: &'static str, arch: &'static str, suse: &'static str) -> Lib {
Lib { soname, debian, fedora, arch, suse }
}
const XKBCOMMON: Lib = lib("libxkbcommon.so.0", "libxkbcommon0", "libxkbcommon", "libxkbcommon", "libxkbcommon0");
const XKBCOMMON_X11: Lib = lib("libxkbcommon-x11.so.0", "libxkbcommon-x11-0", "libxkbcommon-x11", "libxkbcommon-x11", "libxkbcommon-x11-0");
const X11: Lib = lib("libX11.so.6", "libx11-6", "libX11", "libx11", "libX11-6");
const X11_XCB: Lib = lib("libX11-xcb.so.1", "libx11-xcb1", "libX11-xcb", "libx11", "libX11-xcb1");
const XCB: Lib = lib("libxcb.so.1", "libxcb1", "libxcb", "libxcb", "libxcb1");
const XCURSOR: Lib = lib("libXcursor.so.1", "libxcursor1", "libXcursor", "libxcursor", "libXcursor1");
const XI: Lib = lib("libXi.so.6", "libxi6", "libXi", "libxi", "libXi6");
const WAYLAND_CLIENT: Lib = lib("libwayland-client.so.0", "libwayland-client0", "libwayland-client", "wayland", "libwayland-client0");
const VULKAN: Lib = lib("libvulkan.so.1", "libvulkan1", "vulkan-loader", "vulkan-icd-loader", "libvulkan1");
const EGL: Lib = lib("libEGL.so.1", "libegl1", "libglvnd-egl", "libglvnd", "libEGL1");
/// One requirement: at least one of these libraries must be present.
pub type AnyOf = &'static [Lib];
/// What winit 0.30 and wgpu `dlopen` for a session. X11: x11-dl opens Xlib, Xcursor, Xlib-xcb
/// and XInput2, x11rb opens libxcb, and xkbcommon-dl opens xkbcommon and xkbcommon-x11 (RandR
/// goes through x11rb's protocol code, so libXrandr isn't needed). Wayland: wayland-sys opens
/// libwayland-client (wayland-cursor is pure Rust) plus xkbcommon. wgpu needs Vulkan or EGL.
pub fn requirements(session: DisplaySession) -> Vec<AnyOf> {
const GPU: AnyOf = &[VULKAN, EGL];
match session {
DisplaySession::Wayland => vec![&[WAYLAND_CLIENT], &[XKBCOMMON], GPU],
DisplaySession::X11 => vec![&[X11], &[X11_XCB], &[XCB], &[XCURSOR], &[XI], &[XKBCOMMON], &[XKBCOMMON_X11], GPU],
DisplaySession::None => Vec::new(),
}
}
/// The `ldconfig -p` architecture tag for this build, when it's one we can tell apart from a
/// 32-bit library of the same name (`libc6,x86-64` vs plain `libc6` for i386).
fn ldconfig_arch_tag() -> Option<&'static str> {
if cfg!(target_arch = "x86_64") {
Some("x86-64")
} else if cfg!(target_arch = "aarch64") {
Some("AArch64")
} else {
None
}
}
/// Does `ldconfig -p` output list `soname` for our architecture? Lines look like
/// `\tlibxkbcommon-x11.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon-x11.so.0`.
pub fn ldconfig_has(output: &str, soname: &str, arch_tag: Option<&str>) -> bool {
output.lines().any(|line| {
let line = line.trim_start();
let Some((name, rest)) = line.split_once(' ') else { return false };
if name != soname {
return false;
}
match arch_tag {
None => true,
Some(tag) => {
let flags = rest.split_once('(').and_then(|(_, r)| r.split_once(')')).map_or("", |(f, _)| f);
flags.split(',').any(|f| f.trim() == tag)
}
}
})
}
/// A real `ldconfig -p` listing starts with "N libs found in cache".
fn ldconfig_output_is_valid(output: &str) -> bool {
output.lines().next().is_some_and(|l| l.contains("libs found in cache"))
}
/// Where we look for libraries, and what we found there.
pub struct Probe {
/// `ldconfig -p` output, when it ran and looked like a real cache listing.
pub ldconfig: Option<String>,
/// Directories to check for the soname (`LD_LIBRARY_PATH` first).
pub dirs: Vec<PathBuf>,
}
impl Probe {
fn has(&self, soname: &str, exists: &impl Fn(&Path) -> bool) -> bool {
self.ldconfig.as_deref().is_some_and(|out| ldconfig_has(out, soname, ldconfig_arch_tag())) || self.dirs.iter().any(|d| exists(&d.join(soname)))
}
}
/// The outcome of the check.
#[derive(Debug, PartialEq, Eq)]
pub enum Verdict {
Ok,
/// Requirements with none of their alternatives found. `conclusive` is true when the linker
/// cache was read, so the libraries really are absent (and winit would fail or panic).
Missing {
missing: Vec<AnyOf>,
conclusive: bool,
},
}
pub fn check(session: DisplaySession, probe: &Probe, exists: impl Fn(&Path) -> bool) -> Verdict {
let missing: Vec<AnyOf> = requirements(session).into_iter().filter(|any| !any.iter().any(|l| probe.has(l.soname, &exists))).collect();
if missing.is_empty() { Verdict::Ok } else { Verdict::Missing { missing, conclusive: probe.ldconfig.is_some() } }
}
/// Distro family, from `/etc/os-release` `ID` and `ID_LIKE`, to name the right packages.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Distro {
Debian,
Fedora,
Arch,
Suse,
Unknown,
}
pub fn distro_from_os_release(text: &str) -> Distro {
let mut ids = Vec::new();
for line in text.lines() {
let Some((key, value)) = line.split_once('=') else { continue };
if key.trim() == "ID" || key.trim() == "ID_LIKE" {
ids.extend(value.trim().trim_matches(|c| c == '"' || c == '\'').split_whitespace().map(str::to_ascii_lowercase));
}
}
let any = |names: &[&str]| ids.iter().any(|id| names.contains(&id.as_str()));
if any(&["debian", "ubuntu"]) {
Distro::Debian
} else if any(&["fedora", "rhel", "centos"]) {
Distro::Fedora
} else if any(&["arch"]) {
Distro::Arch
} else if any(&["suse", "opensuse"]) || ids.iter().any(|id| id.starts_with("opensuse")) {
Distro::Suse
} else {
Distro::Unknown
}
}
fn package(lib: &Lib, distro: Distro) -> &'static str {
match distro {
Distro::Debian | Distro::Unknown => lib.debian,
Distro::Fedora => lib.fedora,
Distro::Arch => lib.arch,
Distro::Suse => lib.suse,
}
}
fn install_command(packages: &[&str], distro: Distro) -> String {
let list = packages.join(" ");
match distro {
Distro::Debian => format!("sudo apt install {list}"),
Distro::Fedora => format!("sudo dnf install {list}"),
Distro::Arch => format!("sudo pacman -S {list}"),
Distro::Suse => format!("sudo zypper install {list}"),
Distro::Unknown => format!("Debian/Ubuntu: sudo apt install {list}"),
}
}
/// The message for missing libraries: what's missing, and the command that installs it.
pub fn missing_message(session: DisplaySession, missing: &[AnyOf], distro: Distro, conclusive: bool) -> String {
let session_name = match session {
DisplaySession::Wayland => "Wayland",
DisplaySession::X11 => "X11",
DisplaySession::None => "display",
};
let names: Vec<String> = missing.iter().map(|any| any.iter().map(|l| l.soname).collect::<Vec<_>>().join(" or ")).collect();
// For an either/or requirement, suggest its first alternative.
let mut packages: Vec<&str> = Vec::new();
for p in missing.iter().filter_map(|any| any.first()).map(|l| package(l, distro)) {
if !packages.contains(&p) {
packages.push(p);
}
}
let mut msg = if conclusive {
format!("photocraft: cannot start: the {session_name} session needs libraries that are not installed: {}.\n", names.join(", "))
} else {
format!("photocraft: warning: could not find these libraries the {session_name} session needs: {}. Starting anyway.\n", names.join(", "))
};
msg.push_str(&format!("Install them with:\n {}\n", install_command(&packages, distro)));
if distro == Distro::Unknown {
let fedora: Vec<&str> = missing.iter().filter_map(|any| any.first()).map(|l| l.fedora).collect();
msg.push_str(&format!(" Fedora: sudo dnf install {}\n", fedora.join(" ")));
}
if conclusive {
msg.push_str("(Set PHOTOCRAFT_SKIP_LIB_CHECK=1 to start anyway.)\n");
}
msg
}
/// Library directories to search: `LD_LIBRARY_PATH`, then the usual system and Flatpak paths.
fn search_dirs(ld_library_path: Option<&str>) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = ld_library_path.unwrap_or("").split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect();
let triplet = if cfg!(target_arch = "aarch64") { "aarch64-linux-gnu" } else { "x86_64-linux-gnu" };
for d in ["/app/lib", "/usr/local/lib", "/usr/lib64", "/lib64", "/usr/lib", "/lib"] {
dirs.push(PathBuf::from(d));
}
dirs.push(Path::new("/usr/lib").join(triplet));
dirs.push(Path::new("/lib").join(triplet));
dirs
}
/// `ldconfig -p`, from the usual locations (`/sbin` isn't on a normal user's PATH on Debian).
fn read_ldconfig() -> Option<String> {
for cmd in ["/sbin/ldconfig", "/usr/sbin/ldconfig", "ldconfig"] {
if let Ok(out) = std::process::Command::new(cmd).arg("-p").output()
&& out.status.success()
{
let text = String::from_utf8_lossy(&out.stdout).into_owned();
if ldconfig_output_is_valid(&text) {
return Some(text);
}
}
}
None
}
/// Run the check against this system. `Err` carries the message to print before exiting with a
/// non-zero status; a warning is printed here and the app starts.
pub fn preflight() -> Result<(), String> {
if std::env::var_os("PHOTOCRAFT_SKIP_LIB_CHECK").is_some_and(|v| !v.is_empty() && v != "0") {
return Ok(());
}
let session = session_from_env(|k| std::env::var(k).ok());
if session == DisplaySession::None {
return Ok(());
}
let probe = Probe { ldconfig: read_ldconfig(), dirs: search_dirs(std::env::var("LD_LIBRARY_PATH").ok().as_deref()) };
match check(session, &probe, |p| p.exists()) {
Verdict::Ok => Ok(()),
Verdict::Missing { missing, conclusive } => {
let distro = std::fs::read_to_string("/etc/os-release").map(|t| distro_from_os_release(&t)).unwrap_or(Distro::Unknown);
let msg = missing_message(session, &missing, distro, conclusive);
if conclusive {
Err(msg)
} else {
eprint!("{msg}");
Ok(())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
move |k| map.get(k).cloned()
}
#[test]
fn session_follows_winit_order() {
assert_eq!(session_from_env(env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")])), DisplaySession::Wayland);
assert_eq!(session_from_env(env(&[("WAYLAND_SOCKET", "3")])), DisplaySession::Wayland);
assert_eq!(session_from_env(env(&[("WAYLAND_DISPLAY", ""), ("DISPLAY", ":99")])), DisplaySession::X11);
assert_eq!(session_from_env(env(&[("DISPLAY", "")])), DisplaySession::None);
assert_eq!(session_from_env(env(&[])), DisplaySession::None);
}
const CACHE: &str = "1234 libs found in cache `/etc/ld.so.cache'\n\
\tlibxkbcommon.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon.so.0\n\
\tlibxkbcommon-x11.so.0 (libc6) => /lib/i386-linux-gnu/libxkbcommon-x11.so.0\n\
\tlibX11.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11.so.6\n\
\tlibX11-xcb.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11-xcb.so.1\n\
\tlibxcb.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxcb.so.1\n\
\tlibXcursor.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libXcursor.so.1\n\
\tlibXi.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libXi.so.6\n\
\tlibEGL.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libEGL.so.1\n\
\tlibvulkan.so.1 (libc6,AArch64) => /lib/aarch64-linux-gnu/libvulkan.so.1\n";
#[test]
fn ldconfig_parsing_matches_name_and_architecture() {
assert!(ldconfig_output_is_valid(CACHE));
assert!(!ldconfig_output_is_valid("ldconfig: command not found"));
assert!(ldconfig_has(CACHE, "libxkbcommon.so.0", Some("x86-64")));
// Only the 32-bit build is installed: doesn't count for a 64-bit app.
assert!(!ldconfig_has(CACHE, "libxkbcommon-x11.so.0", Some("x86-64")));
assert!(ldconfig_has(CACHE, "libxkbcommon-x11.so.0", None));
assert!(ldconfig_has(CACHE, "libvulkan.so.1", Some("AArch64")));
assert!(!ldconfig_has(CACHE, "libvulkan.so.1", Some("x86-64")));
// Prefix of another name, or a missing name, is not a match.
assert!(!ldconfig_has(CACHE, "libX11.so", None));
assert!(!ldconfig_has(CACHE, "libwayland-client.so.0", None));
assert!(!ldconfig_has("", "libX11.so.6", None));
assert!(!ldconfig_has("garbage\n\t(\n)) =>", "libX11.so.6", Some("x86-64")));
}
fn cache_for_this_arch(names: &[&str]) -> String {
let tag = ldconfig_arch_tag().map(|t| format!("libc6,{t}")).unwrap_or_else(|| "libc6".into());
let mut s = format!("{} libs found in cache `/etc/ld.so.cache'\n", names.len());
for n in names {
s.push_str(&format!("\t{n} ({tag}) => /usr/lib/{n}\n"));
}
s
}
const ALL_X11: &[&str] =
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0", "libEGL.so.1"];
#[test]
fn x11_without_xkbcommon_x11_is_reported() {
// Issue #201: Ubuntu server + Xvfb with only libxkbcommon0 installed.
let names: Vec<&str> = ALL_X11.iter().copied().filter(|n| *n != "libxkbcommon-x11.so.0").collect();
let probe = Probe { ldconfig: Some(cache_for_this_arch(&names)), dirs: vec![] };
let v = check(DisplaySession::X11, &probe, |_| false);
let Verdict::Missing { missing, conclusive } = v else { panic!("expected missing, got {v:?}") };
assert!(conclusive);
assert_eq!(missing.len(), 1);
assert_eq!(missing[0][0].soname, "libxkbcommon-x11.so.0");
let msg = missing_message(DisplaySession::X11, &missing, Distro::Debian, conclusive);
assert!(msg.contains("libxkbcommon-x11.so.0"), "{msg}");
assert!(msg.contains("sudo apt install libxkbcommon-x11-0"), "{msg}");
assert!(msg.contains("PHOTOCRAFT_SKIP_LIB_CHECK"), "{msg}");
}
#[test]
fn complete_systems_pass() {
let probe = Probe { ldconfig: Some(cache_for_this_arch(ALL_X11)), dirs: vec![] };
assert_eq!(check(DisplaySession::X11, &probe, |_| false), Verdict::Ok);
let wayland = cache_for_this_arch(&["libwayland-client.so.0", "libxkbcommon.so.0", "libvulkan.so.1"]);
let probe = Probe { ldconfig: Some(wayland), dirs: vec![] };
assert_eq!(check(DisplaySession::Wayland, &probe, |_| false), Verdict::Ok);
// Wayland doesn't need the X11 libraries; no session needs nothing.
assert_eq!(check(DisplaySession::None, &Probe { ldconfig: None, dirs: vec![] }, |_| false), Verdict::Ok);
}
#[test]
fn gpu_needs_vulkan_or_egl() {
let probe = Probe { ldconfig: Some(cache_for_this_arch(&["libwayland-client.so.0", "libxkbcommon.so.0"])), dirs: vec![] };
let Verdict::Missing { missing, .. } = check(DisplaySession::Wayland, &probe, |_| false) else { panic!("expected missing") };
assert_eq!(missing.len(), 1);
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Fedora, true);
assert!(msg.contains("libvulkan.so.1 or libEGL.so.1"), "{msg}");
assert!(msg.contains("sudo dnf install vulkan-loader"), "{msg}");
}
#[test]
fn library_dirs_count_and_unread_cache_only_warns() {
let dirs = search_dirs(Some("/opt/x/lib::/nix/store/abc/lib"));
assert_eq!(dirs[0], PathBuf::from("/opt/x/lib"));
assert_eq!(dirs[1], PathBuf::from("/nix/store/abc/lib"));
// Found by file in a directory (no ldconfig, as on NixOS).
let probe = Probe { ldconfig: None, dirs: vec![PathBuf::from("/nix/lib")] };
let present = ["/nix/lib/libwayland-client.so.0", "/nix/lib/libxkbcommon.so.0", "/nix/lib/libEGL.so.1"];
assert_eq!(check(DisplaySession::Wayland, &probe, |p| present.iter().any(|q| Path::new(q) == p)), Verdict::Ok);
// Not found and no cache to confirm it: inconclusive, so a warning rather than an exit.
let v = check(DisplaySession::Wayland, &probe, |_| false);
let Verdict::Missing { missing, conclusive } = v else { panic!("expected missing") };
assert!(!conclusive);
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Unknown, conclusive);
assert!(msg.contains("Starting anyway"), "{msg}");
assert!(msg.contains("apt install libwayland-client0 libxkbcommon0 libvulkan1"), "{msg}");
assert!(msg.contains("dnf install libwayland-client libxkbcommon vulkan-loader"), "{msg}");
}
#[test]
fn distro_detection() {
assert_eq!(distro_from_os_release("NAME=\"Ubuntu\"\nID=ubuntu\nID_LIKE=debian\n"), Distro::Debian);
assert_eq!(distro_from_os_release("ID=linuxmint\nID_LIKE=\"ubuntu debian\"\n"), Distro::Debian);
assert_eq!(distro_from_os_release("ID=fedora\n"), Distro::Fedora);
assert_eq!(distro_from_os_release("ID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"\n"), Distro::Fedora);
assert_eq!(distro_from_os_release("ID=endeavouros\nID_LIKE=arch\n"), Distro::Arch);
assert_eq!(distro_from_os_release("ID=\"opensuse-tumbleweed\"\nID_LIKE=\"opensuse suse\"\n"), Distro::Suse);
assert_eq!(distro_from_os_release("ID=nixos\n"), Distro::Unknown);
assert_eq!(distro_from_os_release(""), Distro::Unknown);
assert_eq!(distro_from_os_release("=\n==\nID\n"), Distro::Unknown);
}
}
+11
View File
@@ -17,6 +17,9 @@
mod apple_events;
mod control_server;
mod crash_guard;
// Pure logic is tested on every platform; only Linux runs the check.
#[cfg(any(target_os = "linux", test))]
mod linux_libs;
mod monitor_profile;
mod services;
@@ -62,6 +65,14 @@ fn main() -> eframe::Result {
}
}
// winit and wgpu dlopen the windowing and GPU libraries, and some of those crates panic when
// one is missing (issue #201). Name the package to install and exit instead.
#[cfg(target_os = "linux")]
if let Err(message) = linux_libs::preflight() {
eprint!("{message}");
std::process::exit(1);
}
let control = if let Some(port) = control_port {
let (supplied, token_file) = photocraft_automation::security::token_inputs(control_token, control_token_file);
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
+12 -5
View File
@@ -15,12 +15,19 @@ pub const DEFAULT_FAMILY: &str = "Inter";
/// Bundled monospace family.
pub const MONO_FAMILY: &str = "JetBrains Mono";
// The font files are embedded once, here. The UI (`ui-egui` theme) uses these same statics:
// a second `include_bytes!` of the same file elsewhere put a second 1.5 MB copy into the wasm.
pub static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
pub static INTER_MEDIUM: &[u8] = include_bytes!("../../../assets/fonts/Inter-Medium.ttf");
pub static INTER_SEMIBOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
pub static JETBRAINS_MONO_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf");
/// Fonts shipped with Photocraft (OFL; licences in `assets/fonts`).
pub const BUNDLED: &[(&str, &[u8])] = &[
("Inter-Regular.ttf", include_bytes!("../../../assets/fonts/Inter-Regular.ttf")),
("Inter-Medium.ttf", include_bytes!("../../../assets/fonts/Inter-Medium.ttf")),
("Inter-SemiBold.ttf", include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")),
("JetBrainsMono-Regular.ttf", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf")),
pub static BUNDLED: &[(&str, &[u8])] = &[
("Inter-Regular.ttf", INTER_REGULAR),
("Inter-Medium.ttf", INTER_MEDIUM),
("Inter-SemiBold.ttf", INTER_SEMIBOLD),
("JetBrainsMono-Regular.ttf", JETBRAINS_MONO_REGULAR),
];
/// Families tried (if installed) after the requested one, for missing glyphs. The CJK families
+4 -4
View File
@@ -277,10 +277,10 @@ pub fn install_fonts(ctx: &egui::Context) {
let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes)));
};
add(&mut fonts, "Inter", include_bytes!("../../../assets/fonts/Inter-Regular.ttf"));
add(&mut fonts, "Inter-Medium", include_bytes!("../../../assets/fonts/Inter-Medium.ttf"));
add(&mut fonts, "Inter-SemiBold", include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"));
add(&mut fonts, "JetBrainsMono", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"));
add(&mut fonts, "Inter", photocraft_text::fonts::INTER_REGULAR);
add(&mut fonts, "Inter-Medium", photocraft_text::fonts::INTER_MEDIUM);
add(&mut fonts, "Inter-SemiBold", photocraft_text::fonts::INTER_SEMIBOLD);
add(&mut fonts, "JetBrainsMono", photocraft_text::fonts::JETBRAINS_MONO_REGULAR);
fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "Inter".to_owned());
fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "JetBrainsMono".to_owned());
// Named weights fall back to the default stack for missing glyphs.
+1 -1
View File
@@ -196,7 +196,7 @@ trunk build --release # writes ../../dist/web (index.html, .js glue
trunk serve --release # dev server on http://127.0.0.1:8765
```
Any static file server works for `dist/web`, for example `python3 -m http.server 8765` run inside that directory. Trunk downloads the matching `wasm-bindgen` and `wasm-opt` itself. The release `.wasm` is about 12.7 MB, or 5.0 MB gzipped. Serve it with compression.
Any static file server works for `dist/web`, for example `python3 -m http.server 8765` run inside that directory. Trunk downloads the matching `wasm-bindgen` and `wasm-opt` itself. `trunk build --release` uses the `wasm-release` Cargo profile (`data-cargo-profile` in `index.html`: fat LTO, opt-level "s" except the pixel crates). The `.wasm` is about 18.8 MiB raw, 7.8 MiB gzipped and 5.6 MiB with Brotli; serve it with compression. Keep it under 24 MiB (`packaging/web/package.sh` enforces this; Cloudflare's per-file cap is 25 MiB). To see where the bytes go, run `twiggy top -n 40` on `target/wasm32-unknown-unknown/wasm-release/photocraft-web.wasm` (before wasm-opt strips the names).
URL flags: `?webgl` forces the WebGL2 backend, and `?cpu` forces the CPU canvas path.
+10 -3
View File
@@ -160,8 +160,13 @@ Why these formats:
All Linux binaries are built on Ubuntu 22.04 and need **glibc ≥ 2.35**: Ubuntu 22.04+,
Debian 12+, Fedora 36+, RHEL 10, openSUSE Tumbleweed. They link only glibc and libgcc_s. X11,
Wayland, xkbcommon, Vulkan and EGL are loaded at runtime from the system, which is also where
the GPU driver has to come from. That's why the packages declare them as dependencies and the
AppImage doesn't bundle them.
the GPU driver has to come from. That's why the .deb and .rpm declare them as dependencies
(the full list, and why each is there, is in `packaging/linux/nfpm.yaml`) and the AppImage
doesn't bundle them. The Flatpak gets them from the freedesktop runtime. Because the AppImage
and the tarball can't declare dependencies, `photocraft` checks for the libraries its session
(X11 or Wayland) needs before it opens a window (`apps/photocraft/src/linux_libs.rs`) and, if
one is missing, prints the package to install and exits with status 1 instead of crashing.
`PHOTOCRAFT_SKIP_LIB_CHECK=1` skips the check.
Locally (on Linux): install [nfpm](https://nfpm.goreleaser.com/install/), then
`packaging/linux/package.sh` (or `--formats "deb tar"`).
@@ -170,7 +175,9 @@ Locally (on Linux): install [nfpm](https://nfpm.goreleaser.com/install/), then
`packaging/web/package.sh` runs `trunk build --release` (see `apps/photocraft-web/Trunk.toml`)
and zips `dist/web` together with sample `_headers` and `.htaccess` files and the hosting guide.
The site only uses relative URLs, so it works under any path and in an iframe.
The site only uses relative URLs, so it works under any path and in an iframe. The wasm builds
with the size-optimized `wasm-release` Cargo profile (set in `apps/photocraft-web/index.html`),
and the script fails if any `.wasm` exceeds 24 MiB, below Cloudflare's 25 MiB per-file limit.
[`packaging/web/README.md`](../packaging/web/README.md) covers MIME types, compression,
caching, the iframe snippet and the `?webgl` / `?cpu` flags.
+23 -12
View File
@@ -23,34 +23,45 @@ description: |-
Includes photocraft-cli, a headless converter and command runner.
# The binaries link only glibc and libgcc_s directly. Windowing (X11/Wayland, xkbcommon) and the
# GPU (Vulkan, EGL/GL) are loaded at runtime, so they are dependencies/recommendations here
# rather than ELF NEEDED entries.
# GPU (Vulkan, EGL/GL) are loaded at runtime with dlopen, so they aren't ELF NEEDED entries and
# the package tools can't find them: list them here. winit 0.30 loads, for X11: libX11,
# libX11-xcb, libxcb, libXcursor, libXi, libxkbcommon and libxkbcommon-x11 (missing
# libxkbcommon-x11 panicked in #201); for Wayland: libwayland-client and libxkbcommon. wgpu needs
# libvulkan or libEGL. RandR goes through x11rb's protocol code and fonts are found by scanning
# directories, so libXrandr and fontconfig aren't loaded. The app also checks for these at start
# (apps/photocraft/src/linux_libs.rs) and names the missing package instead of crashing.
overrides:
deb:
depends:
- libc6 (>= 2.35)
- libgcc-s1
- libxkbcommon0
- libvulkan1 | libegl1
recommends:
- libxkbcommon-x11-0
- libwayland-client0
- libx11-6
- libx11-xcb1
- libxcb1
- libxcursor1
- libxi6
- libxrandr2
- libvulkan1 | libegl1
recommends:
- mesa-vulkan-drivers
- libegl-mesa0
- xdg-desktop-portal
rpm:
# Soname provides rather than package names, so the same requirements resolve on Fedora/RHEL
# (libxkbcommon-x11, libX11-xcb, ...) and openSUSE (libxkbcommon-x11-0, libX11-xcb1, ...).
depends:
- libxkbcommon
- (vulkan-loader or libglvnd-egl)
- libxkbcommon.so.0()(64bit)
- libxkbcommon-x11.so.0()(64bit)
- libwayland-client.so.0()(64bit)
- libX11.so.6()(64bit)
- libX11-xcb.so.1()(64bit)
- libxcb.so.1()(64bit)
- libXcursor.so.1()(64bit)
- libXi.so.6()(64bit)
- (libvulkan.so.1()(64bit) or libEGL.so.1()(64bit))
recommends:
- libwayland-client
- libX11
- libXcursor
- libXi
- libXrandr
- mesa-vulkan-drivers
- xdg-desktop-portal
+19 -3
View File
@@ -7,11 +7,27 @@ static site in `photocraft-web-<version>/`:
|---|---|
| `index.html` | The page. It loads everything through relative URLs. |
| `photocraft-web-<hash>.js` | wasm-bindgen glue (generated, ES module) |
| `photocraft-web-<hash>_bg.wasm` | The app, about 13 MB, or 5 MB with compression |
| `photocraft-web-<hash>_bg.wasm` | The app: about 19 MiB raw, 8 MiB with gzip, 5.6 MiB with Brotli (see [Sizes](#sizes)) |
| `_headers`, `.htaccess` | Sample header rules for Netlify/Cloudflare Pages and Apache |
There is no server-side code. Upload the folder's contents anywhere that serves static files.
## Sizes
Measured on the 0.2.x build (`packaging/web/package.sh`; gzip `-9`, Brotli quality 11):
| File | Raw | gzip | Brotli |
|---|---|---|---|
| `photocraft-web-<hash>_bg.wasm` | 19,680,726 bytes (18.8 MiB) | 8,177,260 (7.8 MiB) | 5,868,731 (5.6 MiB) |
| `photocraft-web-<hash>.js` | about 160 KB | about 23 KB | about 20 KB |
Per-file upload limits: Cloudflare Pages and Workers static assets reject any file over
**25 MiB** (26,214,400 bytes). The wasm fits, and `packaging/web/package.sh` fails the build if
it ever grows past 24 MiB, so a release can't ship a file a common host refuses. (Releases up to
0.2.0 shipped a 25.8 MiB wasm, which Cloudflare rejected; issue #198.) The size comes from the
`wasm-release` Cargo profile (fat LTO, size-optimized code with the pixel crates kept at full
speed) plus `wasm-opt -Oz`.
## Any path works
All URLs in `index.html` are relative (`public_url = "./"` in `apps/photocraft-web/Trunk.toml`),
@@ -25,8 +41,8 @@ hash, so they can be cached forever. Only `index.html` needs revalidation.
any other type, and the app then loads slowly or not at all. Serve `.js` as `text/javascript`.
Most hosts already do both. For nginx, check that `mime.types` has `application/wasm wasm;`.
- **Compression:** turn on gzip or Brotli for `.wasm`, `.js` and `.html`. That takes the
download from about 13 MB to about 5 MB. You can also precompress (`brotli -k *.wasm`) and let
the server send `Content-Encoding: br`.
download from about 19 MiB to about 8 MiB (gzip) or 5.6 MiB (Brotli). You can also
precompress (`brotli -k *.wasm`) and let the server send `Content-Encoding: br`.
- **Caching:** `Cache-Control: public, max-age=31536000, immutable` on the hashed `.wasm` and
`.js` files, and `no-cache` on `index.html`.
- **HTTPS:** WebGPU (and the clipboard) only work in a secure context, which means `https://`
+17
View File
@@ -24,6 +24,23 @@ if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then
exit 1
fi
# Size gate (issue #198): Cloudflare Pages/Workers reject any single file over 25 MiB, and other
# hosts and CDNs have similar caps. Fail well before that so a regression shows up here, not at
# upload time. Override with PHOTOCRAFT_WASM_MAX_BYTES only to investigate.
MAX_WASM_BYTES="${PHOTOCRAFT_WASM_MAX_BYTES:-25165824}" # 24 MiB
WASM_COUNT=0
for wasm in "$SITE"/*.wasm; do
[ -f "$wasm" ] || continue
WASM_COUNT=$((WASM_COUNT + 1))
size=$(wc -c <"$wasm" | tr -d ' ')
echo "$(basename "$wasm"): $size bytes ($((size / 1048576)) MiB; limit $MAX_WASM_BYTES)"
if [ "$size" -gt "$MAX_WASM_BYTES" ]; then
echo "error: $(basename "$wasm") is $size bytes, over the $MAX_WASM_BYTES-byte limit (Cloudflare's per-file cap is 25 MiB)" >&2
exit 1
fi
done
[ "$WASM_COUNT" -gt 0 ] || { echo "error: no .wasm in $SITE" >&2; exit 1; }
NAME="photocraft-web-$VERSION"
WORK="$CARGO_TARGET_DIR/web-package"
rm -rf "$WORK"