Merge current main and complete retained Pen messages in Nordic catalogs

This commit is contained in:
FredApps
2026-10-30 00:48:24 +01:00
27 changed files with 19575 additions and 83 deletions
+4
View File
@@ -27,6 +27,10 @@ next to them.
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/sv.tsv` | Swedish UI translations | @kler, @davvarn and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish UI translations | @Janiz-zzz, @metuuu and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/da.tsv` | Danish UI translations | @pechnikov2026-star and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål UI translations | @nikolainyegaard, @Bolla76 and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
+87 -3
View File
@@ -250,8 +250,8 @@ enum SpoolError {
/// Sends the job and says where it went and through what. A named print server takes the IPP
/// route. Otherwise: the app's spooler when it is preferred; else `craft-print` (`lp`, or the
/// Windows spooler) when it is there; else CUPS over IPP; and the app's spooler when that had
/// no route either. A route that exists but fails (an unknown printer, a refused job) is an
/// Windows spooler) when it is there, or the app's spooler when CUPS lists no printer for `lp`;
/// else CUPS over IPP; and the app's spooler when that had no route either. A route that exists but fails (an unknown printer, a refused job) is an
/// error, never a fall-through.
fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -> Result<(String, String)> {
let app = |sp: &Spooler| (sp.run)(job).map(|m| (m, sp.name.to_string())).map_err(other);
@@ -261,7 +261,19 @@ fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -
}
if let Some(name) = system_spooler() {
require_windows_pdf()?;
return with_spooler(|| craft_print::submit(&job.pdf, lp)).map(|m| (m, name.to_string())).map_err(other);
// lpstat is CUPS, not a Windows driver, so it runs outside SPOOLER. A listing that
// can't finish in time can't tell, so lp gets the job as it did before the check.
let route = match name {
"lp" => lp_printers(LP_LISTING_TIMEOUT).map_or_else(|| Some(lp.clone()), |list| lp_route(lp, &list)),
_ => Some(lp.clone()),
};
return match (route, spooler) {
(Some(lp), _) => with_spooler(|| craft_print::submit(&job.pdf, &lp)).map(|m| (m, name.to_string())).map_err(other),
// CUPS lists no printer. IPP would ask the same CUPS, or (it ignores client.conf)
// another server whose printer the user never chose, so only the portal is left.
(None, Some(sp)) => app(sp),
(None, None) => Err(other("no printers found; set one up in the system's printer settings, or print to PDF")),
};
}
}
match cups_spool(job) {
@@ -274,6 +286,78 @@ fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -
}
}
/// How long a print job waits for CUPS' printer list (`lpstat`, whose `-e` browses the network).
const LP_LISTING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// The most a printer listing may print; a longer one counts as no answer.
const LP_LISTING_CAP: u64 = 1 << 20;
/// CUPS' printers as `craft_print::printers` lists them, or `None` when that can't be told: an
/// `lpstat` can't start, outlasts `timeout`, prints more than `LP_LISTING_CAP`, or `lpstat -e`
/// fails (CUPS unreachable, or listing forbidden by policy). `lpstat -p -d`'s own status is not
/// a failure: it exits 1 whenever there are no queues, as on a host with only driverless
/// printers. Each `lpstat` is killed and reaped at the deadline, so a hung one never outlives
/// the job.
fn lp_printers(timeout: std::time::Duration) -> Option<Vec<craft_print::Printer>> {
let deadline = std::time::Instant::now() + timeout;
let (_, queues) = output_by(craft_print::lpstat_command(), deadline)?;
// `lpstat -e`, as craft-print runs it (its builder is private): the driverless printers.
let mut driverless = std::process::Command::new("lpstat");
driverless.arg("-e").env("LC_ALL", "C").env("LANG", "C").env("SOFTWARE", "Craft");
let (listed, available) = output_by(driverless, deadline)?;
listed.then(|| craft_print::printers_parsed(&queues, Some(&available)))
}
/// Whether `command` succeeded, and its standard output; `None` when it can't start, prints more
/// than `LP_LISTING_CAP`, or is still running at `deadline`, when it is killed and reaped. The
/// output is read on its own thread, so a full pipe can't stall the wait, and that thread ends
/// when the process does.
fn output_by(mut command: std::process::Command, deadline: std::time::Instant) -> Option<(bool, String)> {
use std::io::Read as _;
use std::process::Stdio;
let mut child = command.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?;
let mut stdout = child.stdout.take()?;
let (sender, receiver) = std::sync::mpsc::channel();
let reader = std::thread::Builder::new().name("print-listing".into()).spawn(move || {
let mut out = Vec::new();
// One byte over the cap tells an oversized listing apart; stopping closes the pipe.
let _ = sender.send((&mut stdout).take(LP_LISTING_CAP + 1).read_to_end(&mut out).map(|_| out));
});
let status = reader.ok().and_then(|_| {
loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(20)),
_ => break None,
}
}
});
let Some(status) = status else {
let _ = child.kill();
let _ = child.wait();
return None;
};
let out = receiver.recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())).ok()?.ok()?;
let fits = u64::try_from(out.len()).is_ok_and(|n| n <= LP_LISTING_CAP);
fits.then(|| (status.success(), String::from_utf8_lossy(&out).into_owned()))
}
/// The job for `lp`, or `None` when no printer was asked for and CUPS lists none: `lp` has
/// nowhere to send it, and the portal still may (a Flatpak bundles `lp`). A printer asked for by
/// name always goes to `lp`, which knows it even where CUPS won't list it, and the portal could
/// not honour the name. With no printer asked for, no default and one printer, that printer, as
/// `resolve_printer` picks for IPP.
fn lp_route(lp: &craft_print::Job, printers: &[craft_print::Printer]) -> Option<craft_print::Job> {
let mut lp = lp.clone();
if lp.printer.is_none()
&& !printers.iter().any(|p| p.default)
&& let [only] = printers
{
lp.printer = Some(only.name.clone());
}
(lp.printer.is_some() || !printers.is_empty()).then_some(lp)
}
/// The spooler `craft-print` can talk to, as the job's `backend` name: `lp` when CUPS' command
/// is on the path, the Windows spooler on Windows. `None` in a sandbox without `lp` and in the
/// browser.
+52
View File
@@ -678,6 +678,14 @@ fn spool_transport_streams_pdf_and_propagates_refusal() {
b"#!/bin/sh\nif [ \"$2\" = Refuse ]; then echo 'queue refused' >&2; exit 1; fi\nprintf '%s\\n' \"$@\" > \"$PHOTOCRAFT_TEST_PRINT_TRANSPORT/args\"\n/bin/cat > \"$PHOTOCRAFT_TEST_PRINT_TRANSPORT/received.pdf\"\necho 'request id is Office-42 (0 file(s))'\n",
).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
// `lp` takes a job only when CUPS lists a printer (`lp_route`), so the fake CUPS lists its queues.
let lpstat = dir.path().join("lpstat");
std::fs::write(
&lpstat,
b"#!/bin/sh\nif [ \"$1\" = -e ]; then printf 'Office\\nRefuse\\n'; else printf 'printer Office is idle.\\nprinter Refuse is idle.\\n'; fi\n",
)
.unwrap();
std::fs::set_permissions(&lpstat, std::fs::Permissions::from_mode(0o700)).unwrap();
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "cmd::print_cmds::tests::spool_transport_streams_pdf_and_propagates_refusal", "--nocapture"])
.env(CHILD, dir.path())
@@ -972,6 +980,50 @@ fn a_preferred_app_spooler_takes_the_job() {
assert!(std::path::Path::new(&out).exists());
}
#[cfg(unix)]
#[test]
fn a_printer_listing_past_its_deadline_is_killed_and_reaped() {
let soon = || std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut echo = std::process::Command::new("sh");
echo.args(["-c", "echo printer Office is idle."]);
assert_eq!(output_by(echo, soon()), Some((true, "printer Office is idle.\n".to_string())));
let mut failed = std::process::Command::new("sh");
failed.args(["-c", "exit 1"]);
assert_eq!(output_by(failed, soon()), Some((false, String::new())), "a failed listing is told apart from an empty one");
let mut flood = std::process::Command::new("sh");
flood.args(["-c", "head -c 2000000 /dev/zero"]);
assert_eq!(output_by(flood, soon()), None, "an oversized listing is no answer");
// A hung lpstat: given up at the deadline, and killed, so nothing is left waiting on it.
let dir = tempfile::tempdir().unwrap();
let pid_file = dir.path().join("pid");
let mut hung = std::process::Command::new("sh");
hung.arg("-c").arg(format!("echo $$ > '{}'; exec sleep 30", pid_file.display()));
let started = std::time::Instant::now();
assert_eq!(output_by(hung, started + std::time::Duration::from_millis(300)), None);
assert!(started.elapsed() < std::time::Duration::from_secs(5), "the deadline must cut the wait short");
let pid = std::fs::read_to_string(&pid_file).unwrap();
// `kill -0` fails for a process that no longer exists, on every Unix.
let alive = std::process::Command::new("kill").args(["-0", pid.trim()]).stderr(std::process::Stdio::null()).status().unwrap().success();
assert!(!alive, "the hung process must be killed and reaped");
assert_eq!(output_by(std::process::Command::new("/nonexistent/lpstat"), soon()), None);
}
#[test]
fn lp_takes_the_job_only_when_cups_lists_a_printer_and_picks_the_only_one() {
let p = |name: &str, default: bool| craft_print::Printer { name: name.into(), default, two_sided: None, description: String::new() };
let job = craft_print::Job::default();
// No printers: no lp route, so the portal still gets the job.
assert!(lp_route(&job, &[]).is_none());
assert_eq!(lp_route(&job, &[p("Office", false)]).and_then(|j| j.printer), Some("Office".into()));
assert_eq!(lp_route(&job, &[p("Office", false), p("Lab", true)]).and_then(|j| j.printer), None, "lp picks the default itself");
assert_eq!(lp_route(&job, &[p("Office", false), p("Lab", false)]).and_then(|j| j.printer), None);
let asked = craft_print::Job { printer: Some("Lab".into()), ..Default::default() };
assert_eq!(lp_route(&asked, &[p("Office", false)]).and_then(|j| j.printer), Some("Lab".into()));
// A named printer goes to lp even when CUPS lists none: listing can be forbidden while
// printing is not, and the portal can't honour a name.
assert_eq!(lp_route(&asked, &[]).and_then(|j| j.printer), Some("Lab".into()));
}
#[cfg(not(target_os = "windows"))]
mod cups {
use super::*;
+11 -4
View File
@@ -34,19 +34,26 @@ fn background_save_outcomes_report_failure_then_recovery() {
let root = temp_dir("retry-failed-write");
let blocked = root.join("recovery");
std::fs::write(&blocked, b"not a directory").unwrap();
let saver = Autosaver::new(&blocked, "doc");
// Not dropped on a failure: dropping joins the worker, which waits for a write in progress,
// so a stuck write would hold the test past its own bound until nextest kills it.
let saver = std::mem::ManuallyDrop::new(Autosaver::new(&blocked, "doc"));
let doc = Arc::new(rich_doc(ColorMode::Rgb, SampleType::U8));
saver.request_checked(doc.clone(), 7, None, SaveOptions::default()).unwrap();
// The bound only turns a worker that never reports into a failure instead of a hang. A real
// write flushes every tile to disk and took over 30 s on a busy Windows CI runner (normally
// 2-3 s), so it is generous, and still under the 10 minutes after which nextest's `ci`
// profile kills a test.
const PATIENCE: std::time::Duration = std::time::Duration::from_secs(5 * 60);
let receive = |saver: &Autosaver| {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let deadline = std::time::Instant::now() + PATIENCE;
while std::time::Instant::now() < deadline {
if let Some(outcome) = saver.take_completed().into_iter().next() {
return outcome;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
panic!("autosave worker did not report its completed write within 30 seconds");
panic!("autosave worker did not report its completed write within {PATIENCE:?}");
};
let (revision, result) = receive(&saver);
assert_eq!(revision, 7);
@@ -60,7 +67,7 @@ fn background_save_outcomes_report_failure_then_recovery() {
result.unwrap();
assert_eq!(list_recovery(&blocked).len(), 1);
assert_eq!(recover(&list_recovery(&blocked)[0]).unwrap(), *doc);
drop(saver);
drop(std::mem::ManuallyDrop::into_inner(saver));
std::fs::remove_dir_all(root).unwrap();
}
+1 -1
View File
@@ -632,7 +632,7 @@ impl History {
continue;
}
total = total.saturating_add(tile_bytes(&s.doc, &mut seen));
if total > self.max_bytes && keep >= 1 {
if total > self.max_bytes && (keep >= 1 || source == Some(undo.len() - 1)) {
break;
}
keep += 1;
+22
View File
@@ -222,6 +222,28 @@ fn byte_budget_keeps_the_brush_source_of_the_cleared_states() {
assert!(Arc::ptr_eq(&h.row_doc(0, &cur).unwrap(), &source), "the History Brush paints from the same state");
}
/// When the History Brush's source is the newest past state, it is the state the byte budget
/// always keeps, and no older state is kept beside it.
#[test]
fn byte_budget_counts_a_newest_brush_source_as_the_kept_state() {
let mut h = History::new(50);
let mut cur = Arc::new(base());
for i in 0..5 {
edit(&mut h, &mut cur, &format!("paint {i}"), |d| {
let id = d.layers[0].id;
d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]);
});
}
assert!(h.set_brush_source(4));
let source = h.state(4).unwrap();
h.max_bytes = 1;
assert_eq!(h.trim(&cur), 4);
assert_counter(&h, &cur);
assert_eq!(h.past_len(), 1);
assert_eq!(h.brush_source(), Some(0));
assert!(Arc::ptr_eq(&h.row_doc(0, &cur).unwrap(), &source));
}
#[test]
fn new_edit_clears_redo() {
let mut h = History::default();
@@ -124,13 +124,3 @@ fn typing_replaces_the_selected_name_and_enter_confirms() {
assert!(h.state().ui.dialogs.is_empty());
assert_eq!(groups(h.state())[0].name, "Clouds");
}
#[test]
fn new_group_dialog_labels_are_translated_in_every_catalog() {
const LABELS: &[&str] = &["New Group…", "New Group from Layers…", "Name:", "Color:", "Mode:", "Opacity:", "Pass Through"];
for lang in crate::i18n::Lang::all().filter(|lang| lang.complete_menus()) {
for label in LABELS {
assert!(crate::i18n::has(lang, label), "{} is missing New Group dialog label: {label}", lang.code());
}
}
}
@@ -748,32 +748,25 @@ fn ok_after_a_layer_switch_leaves_the_new_layer_alone() {
assert!(app.distort.select_mask.is_none());
}
/// The reason a stale workspace was closed is user-visible text: it is shown in the status bar and
/// returned to the control channel in the language in use, and every catalogue has it (#8896).
/// A stale workspace is closed with a reason that is shown in the status bar and returned to the
/// control channel (#8896).
#[test]
fn the_reason_a_stale_workspace_closed_is_translated() {
fn the_reason_a_stale_workspace_closed_is_reported() {
let document = "Select and Mask was closed: its document is no longer the active one";
let layer = "Select and Mask was closed: another layer was selected";
for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") {
for (reason, english) in [("document", document), ("layer", layer)] {
assert!(crate::i18n::has(lang, english), "{}: no row for the {reason} reason", lang.code());
let shown = crate::i18n::tr(lang, english);
let mut app = if reason == "document" { two_documents() } else { app_with_selection() };
let first = app.session.active().unwrap().active_layer.unwrap();
let other = (reason == "layer").then(|| app.run("layer.new.layer", json!({"name": "Other"})).unwrap()["layer"].as_u64().map(LayerId).unwrap());
app.run("layer.select", json!({"layer": first.0})).unwrap();
open(&mut app).unwrap();
crate::i18n::with_language(lang, || {
match other {
Some(other) => drop(app.session.execute("layer.select", json!({"layer": other.0}))),
None => assert!(app.session.set_active(1)),
}
let err = commit(&mut app).expect_err(&format!("{} / {reason}: the stale OK is refused", lang.code()));
assert_eq!(err, shown, "{}: the error is translated", lang.code());
assert_eq!(app.ui.status, shown, "{}: the status is translated", lang.code());
});
assert_ne!(shown, english, "{}: the {reason} reason reads differently from English", lang.code());
for (reason, english) in [("document", document), ("layer", layer)] {
let mut app = if reason == "document" { two_documents() } else { app_with_selection() };
let first = app.session.active().unwrap().active_layer.unwrap();
let other = (reason == "layer").then(|| app.run("layer.new.layer", json!({"name": "Other"})).unwrap()["layer"].as_u64().map(LayerId).unwrap());
app.run("layer.select", json!({"layer": first.0})).unwrap();
open(&mut app).unwrap();
match other {
Some(other) => drop(app.session.execute("layer.select", json!({"layer": other.0}))),
None => assert!(app.session.set_active(1)),
}
let err = commit(&mut app).expect_err(&format!("{reason}: the stale OK is refused"));
assert_eq!(err, english, "{reason}: the error");
assert_eq!(app.ui.status, english, "{reason}: the status");
}
}
+4 -1
View File
@@ -844,7 +844,10 @@ fn open_paths_reports_in_the_interface_language_before_the_first_frame() {
app.session.edit_prefs(|p| p.interface.language = "ar".into());
crate::i18n::set_current(crate::i18n::Lang::EN);
assert_eq!(app.open_paths(&paths), 1);
assert_eq!(app.ui.status, "تم استيراد 3 نقشات من launch");
let ar = crate::i18n::Lang::from_code("ar").unwrap();
let want =
crate::i18n::fmt(&crate::i18n::trn(ar, 3, "Imported {n} pattern from {file}", "Imported {n} patterns from {file}"), &[("n", "3"), ("file", "launch")]);
assert_eq!(app.ui.status, want);
crate::i18n::set_current(crate::i18n::Lang::EN);
let _ = std::fs::remove_dir_all(&dir);
}
+16 -1
View File
@@ -199,7 +199,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result<String, String> {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 23] = [
pub static LANGUAGES: [LangInfo; 27] = [
LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
@@ -237,6 +237,16 @@ pub static LANGUAGES: [LangInfo; 23] = [
// Arabic (Modern Standard); `ar-SA`, `ar-EG` and the other regional locales resolve here. The
// chrome mirrors right to left (`dir_for_code`), and egui lays each label out right to left.
LangInfo { code: "ar", name: "العربية", source: catalog!("ar"), plural: plural_arabic, complete_menus: true, catalog: OnceLock::new() },
// Swedish; `sv-SE` and `sv-FI` locales resolve here.
LangInfo { code: "sv", name: "Svenska", source: catalog!("sv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
// Finnish; `fi-FI` resolves here (Swedish in Finland, `sv-FI`, stays Swedish). One/other: the
// partitive singular follows any number but 1 ("1 taso", "2 tasoa").
LangInfo { code: "fi", name: "Suomi", source: catalog!("fi"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
// Danish; `da-DK` and the other `da-*` locales resolve here.
LangInfo { code: "da", name: "Dansk", source: catalog!("da"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
// Norwegian Bokmål; `nb-NO`, and the macrolanguage `no` and Nynorsk `nn` from POSIX locales,
// resolve here (see `candidates`).
LangInfo { code: "nb", name: "Norsk bokmål", source: catalog!("nb"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
];
impl LangInfo {
@@ -366,6 +376,11 @@ fn candidates(tag: &str) -> Vec<String> {
// The only Portuguese catalog is Brazilian; other regions use it rather than English.
out.insert(out.len() - 1, "pt-br".to_string());
}
if matches!(primary, "no" | "nn") && !out.iter().any(|c| c == "nb") {
// The only Norwegian catalog is Bokmål; the macrolanguage code and Nynorsk use it rather
// than English.
out.insert(out.len() - 1, "nb".to_string());
}
out
}
@@ -1,6 +1,6 @@
PhotoCraft's Korean, German, Spanish, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech,
Turkish, Hungarian, Serbian and Russian UI translation catalogs are licensed under MIT OR
Apache-2.0, at your option.
Turkish, Hungarian, Serbian, Russian, Swedish, Finnish, Danish and Norwegian Bokmål UI translation
catalogs are licensed under MIT OR Apache-2.0, at your option.
The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE.
Korean translations: PhotoCraft contributors, original translations of
@@ -45,3 +45,15 @@ Arabic translations: PhotoCraft contributors, combined from the catalogs contrib
@hicbest, @hasan-sdaif, @NibrasAz7, @vfggf95565, @syrrevshin4202-prog and @test-bit-sys and
reviewed with Claude (Anthropic) as an AI contributor; original translations of PhotoCraft's
English labels, and no proprietary translation resources were used.
Swedish translations: PhotoCraft contributors, combined from the catalogs contributed by @kler and
@davvarn and reviewed with Claude (Anthropic) as an AI contributor; original translations of
PhotoCraft's English labels, and no proprietary translation resources were used.
Finnish translations: PhotoCraft contributors, combined from the catalogs contributed by @Janiz-zzz
and @metuuu and reviewed with Claude (Anthropic) as an AI contributor; original translations of
PhotoCraft's English labels, and no proprietary translation resources were used.
Danish translations: PhotoCraft contributors, based on the catalog contributed by
@pechnikov2026-star, completed and reviewed with Claude (Anthropic) as an AI contributor; original
translations of PhotoCraft's English labels, and no proprietary translation resources were used.
Norwegian Bokmål translations: PhotoCraft contributors, combined from the catalogs contributed by
@nikolainyegaard and @Bolla76 and reviewed with Claude (Anthropic) as an AI contributor; original
translations of PhotoCraft's English labels, and no proprietary translation resources were used.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -230,7 +230,19 @@ fn harness_with_services(services: Services) -> egui_kittest::Harness<'static, P
#[test]
fn first_launch_renders_the_system_language_without_overwriting_auto() {
for (tag, code) in [("en-US", "en"), ("zh-CN", "zh-hans"), ("ja-JP", "ja"), ("ko-KR", "ko"), ("ru-RU", "ru"), ("uk-UA", "uk"), ("fr-CA", "fr")] {
for (tag, code) in [
("en-US", "en"),
("zh-CN", "zh-hans"),
("ja-JP", "ja"),
("ko-KR", "ko"),
("ru-RU", "ru"),
("uk-UA", "uk"),
("fr-CA", "fr"),
("sv-SE", "sv"),
("fi-FI", "fi"),
("da-DK", "da"),
("nb-NO", "nb"),
] {
system::with_system_tags(&[tag], || {
with_language(Lang::EN, || {
let h = harness();
@@ -244,7 +256,7 @@ fn first_launch_renders_the_system_language_without_overwriting_auto() {
#[test]
fn missing_or_unsupported_system_languages_render_english() {
for tags in [vec![], vec!["sv-SE", "sw-KE"]] {
for tags in [vec![], vec!["sw-KE", "is-IS"]] {
system::with_system_tags(&tags, || {
with_language(Lang::EN, || {
let h = harness();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12 -5
View File
@@ -86,13 +86,13 @@ mod tests {
(vec!["ja-JP", "en-US"], "ja"),
(vec!["fr-CA", "en-US"], "fr"),
(vec!["uk-UA", "en-US"], "uk"),
(vec!["sv-SE", "uk_UA.UTF-8", "ru-RU"], "uk"),
(vec!["sv-SE", "ko-KR", "fr-FR"], "ko"),
(vec!["sw-KE", "uk_UA.UTF-8", "ru-RU"], "uk"),
(vec!["sw-KE", "ko-KR", "fr-FR"], "ko"),
(vec!["zh-Hant-HK", "zh-CN"], "zh-hant"),
(vec!["zh-Hans-CN", "zh-TW"], "zh-hans"),
(vec!["en-US", "ru-RU"], "en"),
(vec!["sv-SE", "ar-SA"], "ar"),
(vec!["sv-SE", "sw-KE"], "en"),
(vec!["is-IS", "ar-SA"], "ar"),
(vec!["sw-KE", "is-IS"], "en"),
(vec!["de-AT", "en-US"], "de"),
(vec!["pt-PT"], "pt-br"),
(vec!["it-IT", "en-US"], "it"),
@@ -100,8 +100,15 @@ mod tests {
(vec!["hu-HU", "en-US"], "hu"),
(vec!["tr-TR", "en-US"], "tr"),
(vec!["sr-Latn-RS", "en-US"], "sr"),
(vec!["sv-SE", "sr_RS@latin", "ru-RU"], "sr"),
(vec!["sw-KE", "sr_RS@latin", "ru-RU"], "sr"),
(vec!["hi-IN", "en-US"], "hi"),
(vec!["sv-SE", "en-US"], "sv"),
(vec!["sv_FI.UTF-8", "fi-FI", "en-US"], "sv"),
(vec!["fi-FI", "sv-FI", "en-US"], "fi"),
(vec!["da-DK", "en-US"], "da"),
(vec!["nb-NO", "en-US"], "nb"),
(vec!["nn_NO.UTF-8", "en-US"], "nb"),
(vec!["is-IS", "no-NO"], "nb"),
(vec![], "en"),
] {
with_system_tags(&tags, || assert_eq!(system_lang().code(), expected));
+61 -13
View File
@@ -104,6 +104,8 @@ fn candidates_walk_from_specific_to_general() {
assert_eq!(candidates("zh_TW"), ["zh-tw", "zh-hant", "zh"]);
assert_eq!(candidates("zh-CN"), ["zh-cn", "zh-hans", "zh"]);
assert_eq!(candidates("zh-Hant-HK"), ["zh-hant-hk", "zh-hant", "zh"]);
assert_eq!(candidates("nn_NO.UTF-8"), ["nn-no", "nb", "nn"]);
assert_eq!(candidates("no"), ["nb", "no"]);
}
#[test]
@@ -135,6 +137,65 @@ fn spanish_resolves_and_pluralises() {
assert_eq!(forms, [1, 0, 1]);
}
#[test]
fn swedish_resolves_and_pluralises() {
let sv = Lang::from_code("sv").expect("sv registered");
assert_eq!(sv.name(), "Svenska");
for tag in ["sv", "SV", "sv-SE", "sv_SE.UTF-8", "sv-FI", "sv_FI.UTF-8", "sv-AX"] {
assert_eq!(lang_from_tag(tag), Some(sv), "{tag}");
assert_eq!(Lang::from_pref(tag), sv, "{tag}");
}
let forms: Vec<usize> = [0, 1, 2, 21].into_iter().map(sv.0.plural).collect();
assert_eq!(forms, [1, 0, 1, 1]);
assert_eq!(tr(sv, "File"), "Arkiv");
assert_eq!(trn(sv, 1, "{n} layer", "{n} layers"), "1 lager");
assert_eq!(trn(sv, 3, "{n} layer", "{n} layers"), "3 lager");
}
#[test]
fn finnish_resolves_and_pluralises() {
let fi = Lang::from_code("fi").expect("fi registered");
assert_eq!(fi.name(), "Suomi");
for tag in ["fi", "FI", "fi-FI", "fi_FI.UTF-8"] {
assert_eq!(lang_from_tag(tag), Some(fi), "{tag}");
assert_eq!(Lang::from_pref(tag), fi, "{tag}");
}
// Filipino and Swedish in Finland don't pick Finnish.
assert_ne!(lang_from_tag("fil"), Some(fi));
assert_ne!(lang_from_tag("sv-FI"), Some(fi));
assert_eq!(tr(fi, "File"), "Tiedosto");
// The partitive singular follows any number but 1.
for (n, layer) in [(0, "tasoa"), (1, "taso"), (2, "tasoa"), (21, "tasoa")] {
assert_eq!(trn(fi, n, "{n} layer", "{n} layers"), format!("{n} {layer}"));
}
}
#[test]
fn danish_resolves_and_pluralises() {
let da = Lang::from_code("da").expect("da registered");
assert_eq!(da.name(), "Dansk");
for tag in ["da", "DA", "da-DK", "da_DK.UTF-8", "da-GL"] {
assert_eq!(lang_from_tag(tag), Some(da), "{tag}");
assert_eq!(Lang::from_pref(tag), da, "{tag}");
}
assert_eq!(tr(da, "File"), "Filer");
assert_eq!(trn(da, 1, "{n} document", "{n} documents"), "1 dokument");
assert_eq!(trn(da, 3, "{n} document", "{n} documents"), "3 dokumenter");
}
#[test]
fn norwegian_resolves_and_pluralises() {
let nb = Lang::from_code("nb").expect("nb registered");
assert_eq!(nb.name(), "Norsk bokmål");
for tag in ["nb", "NB", "nb-NO", "nb_NO.UTF-8", "no", "no-NO", "no_NO.UTF-8", "nn", "nn-NO", "nn_NO.UTF-8"] {
assert_eq!(lang_from_tag(tag), Some(nb), "{tag}");
assert_eq!(Lang::from_pref(tag), nb, "{tag}");
}
assert_eq!(tr(nb, "File"), "Fil");
assert_eq!(trn(nb, 1, "{n} item", "{n} items"), "1 element");
assert_eq!(trn(nb, 3, "{n} item", "{n} items"), "3 elementer");
}
#[test]
fn hungarian_resolves_and_keeps_the_singular_after_counts() {
let hu = Lang::from_code("hu").expect("hu registered");
@@ -203,7 +264,6 @@ fn arabic_resolves_locales_and_preferences() {
assert_eq!(Lang::from_pref(tag), ar, "{tag}");
}
assert_eq!(dir_for_code("ar"), Dir::Rtl);
assert_eq!(tr(ar, "File"), "ملف");
}
#[test]
@@ -212,18 +272,6 @@ fn arabic_plural_rule_has_six_forms() {
let counts = [0, 1, 2, 3, 10, 11, 99, 100, 101, 102, 103, 111, 199, 1000, u64::MAX];
let forms: Vec<usize> = counts.into_iter().map(ar.0.plural).collect();
assert_eq!(forms, [0, 1, 2, 3, 3, 4, 4, 5, 5, 5, 3, 4, 4, 5, 4]);
assert_eq!(trn(ar, 2, "{n} layer", "{n} layers"), "2 طبقتان");
assert_eq!(trn(ar, 5, "{n} layer", "{n} layers"), "5 طبقات");
}
/// The preset-import result (`preset_files_ui::open`) picks its plural pair at run time, so the
/// literal-key coverage check can't see these rows.
#[test]
fn arabic_preset_import_results_are_translated() {
let ar = Lang::from_code("ar").expect("ar registered");
assert_eq!(trn(ar, 3, "Imported {n} pattern from {file}", "Imported {n} patterns from {file}"), "تم استيراد 3 نقشات من {file}");
assert_eq!(trn(ar, 2, "Imported {n} gradient from {file}", "Imported {n} gradients from {file}"), "تم استيراد 2 تدرجين من {file}");
assert_eq!(trn(ar, 1, "Imported {n} brush from {file}", "Imported {n} brushes from {file}"), "تم استيراد 1 فرشاة من {file}");
}
#[test]
+2 -2
View File
@@ -9,8 +9,8 @@
# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns.
# lines starting with # and blank lines are ignored.
#
# Status: complete. Every source key is translated (`complete_menus`), and the coverage test in
# xtask/src/i18n_coverage.rs keeps it that way: new `tl!` strings need a Vietnamese row.
# Status: complete when last measured. `cargo xtask i18n-coverage` lists any `tl!` string without a
# Vietnamese row; a missing row shows in English.
#
# Clean-room: every entry was written from the plain meaning of the English label using ordinary
# Vietnamese computing and image-editing vocabulary. No proprietary localisation resource, string
Can't render this file because it contains an unexpected character in line 6 and column 47.
@@ -28,8 +28,6 @@ fn the_generator_checkbox_reads_enable_generator() {
h.get_by_label("Enable Generator").click();
h.run_steps(2);
assert_eq!(h.state()["generatorEnabled"], true);
let de = crate::i18n::Lang::from_code("de").unwrap();
assert_eq!(crate::i18n::tr(de, "Enable Generator"), "Generator aktivieren");
}
#[test]
+10 -2
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic UI catalog; previously minor: Arabic paragraph direction buttons, #6793) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-29 · **Change:** minor (Swedish, Finnish, Danish and Norwegian Bokmål UI catalogs; previously minor: Arabic UI catalog) · **Target:** Adobe Photoshop 2026
Per-language status of PhotoCraft's UI. How catalogs work and how to add one:
[`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md),
@@ -54,7 +54,13 @@ Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,164
other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Turkish `tr`
(3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every
measured key and the union of the other catalogs' dynamic labels; its one/few/many plurals reuse
the Russian rule.
the Russian rule. Swedish `sv` (4,552 rows, added 2026-10-28) covers every measured key and the
other catalogs' dynamic labels; combined from the community catalogs in #5985 and #9536 with one
term per concept; the contested terms follow a native speaker's questionnaire answers. Finnish
`fi` (4,552 rows, combined from #5666 and #6961), Danish `da` (4,552 rows, based on #7267 and
completed for the current UI) and Norwegian Bokmål `nb` (4,552 rows, combined from #8072 and #9025;
`no` and `nn` locales resolve to it), all added 2026-10-29, cover the same keys with one term per
concept; none has had a native-speaker review yet.
Czech UI follow-up (2026-10-11): status and notice wrappers, hardware labels, brush and
transform accessibility names, rasterization prompts, profile mismatch messages and preset
@@ -93,3 +99,5 @@ engine localization or native review is complete.
| 2026-10-18 | minor | Russian: 1,229 dynamic labels added (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, metadata, System Info, status templates, dialogs) with a terminology note ([localization-ru](localization-ru.md)) |
| 2026-10-22 | minor | Arabic row: paragraph direction has Auto / Left-to-right / Right-to-left buttons in the Paragraph panel and Type options bar (#6793); the visual RTL caret is still missing (TYPE-218-2 partial) |
| 2026-10-28 | minor | Arabic (`ar`) UI catalog: every measured key, six plural forms; layer names and the title bar no longer reversed on egui 0.37 |
| 2026-10-29 | minor | Swedish (`sv`) UI catalog: every measured key plus the dynamic labels, combined from #5985 and #9536 |
| 2026-10-29 | minor | Finnish (`fi`), Danish (`da`) and Norwegian Bokmål (`nb`) UI catalogs: every measured key plus the dynamic labels, combined from #5666, #6961, #7267, #8072 and #9025 |
+7
View File
@@ -29,6 +29,10 @@ contextual, command-ID and plural translations; missing entries fall back to Eng
| Tiếng Việt | `vi` | one form |
| Srpski | `sr` | one / few / many (teens use many) |
| हिन्दी | `hi` | one / other |
| Svenska | `sv` | one / other |
| Suomi | `fi` | one / other (partitive after every count but 1) |
| Dansk | `da` | one / other |
| Norsk bokmål | `nb` | one / other |
Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend
mode names, and generated preference labels. A string a catalog lacks shows in English:
@@ -53,6 +57,9 @@ native system locale; an unsupported code follows the same fallback. Regional ta
Simplified Chinese remain distinct. Every Portuguese locale (`pt`, `pt-BR`, `pt-PT`) uses the
Brazilian Portuguese catalog. The Serbian catalog is written in Latin script (Ekavian); every
Serbian locale (`sr-RS`, `sr-Latn-RS`, `sr_RS@latin`, and Cyrillic `sr-Cyrl-RS`) resolves to it.
Swedish locales from Sweden and Finland (`sv-SE`, `sv-FI`, `sv_FI.UTF-8`) use the Swedish catalog;
Finnish (`fi-FI`) uses the Finnish one. Every Norwegian locale (`nb-NO`, the macrolanguage `no`,
and Nynorsk `nn-NO` from POSIX systems) uses the Bokmål catalog.
### First launch and system language
+12 -8
View File
@@ -25,19 +25,23 @@ the preview and generated PDF continue to share `print_cmds::layout`.
| Route | When | What the user sees |
|---|---|---|
| **`craft-print`** (`lp` on CUPS, the Windows spooler) | Whenever `lp` is on the path (macOS, Linux outside a sandbox) or on Windows | `Sent to the printer (request id is Office-42)` |
| **CUPS over IPP** (`photocraft-ipp`) | No `lp` on the path (a Flatpak) and a print server answers: `CUPS_SERVER`, then `/run/cups/cups.sock`, `/var/run/cups/cups.sock`, `/private/var/run/cupsd`, then `localhost:631`; always for a `printServer` named in the request | `Sent to the printer (job 42 on Office)` |
| **Print portal** (`org.freedesktop.portal.Print`, desktop app on Linux) | No print server reachable or no printer set up, or always with `PHOTOCRAFT_PRINT_PORTAL=1` | The system print dialog, seeded with the copies and paper; the outcome goes to the log |
| **`craft-print`** (`lp` on CUPS, the Windows spooler) | `lp` is on the path (macOS, Linux, the Flatpak, which bundles it) and CUPS lists a printer, or on Windows | `Sent to the printer (request id is Office-42)` |
| **CUPS over IPP** (`photocraft-ipp`) | No `lp` on the path and a print server answers: `CUPS_SERVER`, then `/run/cups/cups.sock`, `/var/run/cups/cups.sock`, `/private/var/run/cupsd`, then `localhost:631`; always for a `printServer` named in the request | `Sent to the printer (job 42 on Office)` |
| **Print portal** (`org.freedesktop.portal.Print`, desktop app on Linux) | Linux only (the Flatpak included): no print server reachable or no printer set up (including when `lp` is there but CUPS lists no printer and none was named), or always with `PHOTOCRAFT_PRINT_PORTAL=1`. Elsewhere a missing route is an error | The system print dialog, seeded with the copies and paper; the outcome goes to the log |
| **Print to PDF** | `output` given (the Output field of the dialog) | The file; nothing is spooled unless `send` is true |
A route that exists but fails (an unknown printer, a job the queue refuses) is an error; only a
missing route (no `lp`, no print server, no printer) moves on to the next one.
missing route (no `lp`, no print server, no printer) moves on to the next one. With no printer
named and no default, the only printer takes the job on the `lp` and IPP routes; Windows needs
a named or default printer.
The IPP client is in-process, pure Rust (RFC 8010/8011: `CUPS-Get-Printers`, `CUPS-Get-Classes`,
`CUPS-Get-Default`, `Print-Job` with the PDF as the document), so no `lp` binary is needed. That
is what makes printing work in the Flatpak: the manifest grants `--socket=cups`, which mounts the
host's CUPS socket, and the sandbox has no `lp`. On a system without CUPS, or in a sandbox
without the socket, the app falls back to the print portal, whose dialog runs outside the sandbox.
`CUPS-Get-Default`, `Print-Job` with the PDF as the document), so no `lp` binary is needed. It
only sees queues the server has, not driverless network printers, which is why the Flatpak
bundles `lp`/`lpstat` (the manifest's `cups-client` module) and grants `--socket=cups` (the
host's CUPS socket) and `org.freedesktop.Avahi` (`lpstat -e` finds driverless printers). On a
system without CUPS, or in a sandbox without the socket, the app falls back to the print portal,
whose dialog runs outside the sandbox.
## Which printer
+2 -1
View File
@@ -278,7 +278,8 @@ Why these formats:
```
Sandbox permissions (justified in the manifest): Wayland with X11 fallback, IPC (X11
shared memory), `dri` for the GPU, and read/write access to Pictures and Documents. Every
shared memory), `dri` for the GPU, the CUPS socket, Avahi and `~/.cups` (read-only) for
printing, and read/write access to Pictures and Documents. Every
other file goes through the file-chooser and document portals. Network access enables update
checks and makes the optional loopback `--control` server reachable from the host. Host fonts
are read from `/run/host/fonts` and `/run/host/user-fonts`. Locally (on Linux, with `flatpak` and
@@ -27,9 +27,14 @@ finish-args:
- --share=network
# GPU access for the wgpu compositor (Vulkan, or GL as a fallback).
- --device=dri
# Printing: the host's CUPS socket, so File › Print reaches the print server directly (IPP,
# in process). Without it the app shows the system print dialog through the print portal.
# Printing: the host's CUPS socket, for the bundled lp/lpstat (the cups-client module) and the
# in-process IPP client. Without it the app shows the system print dialog through the print portal.
- --socket=cups
# Driverless network printers are found through Avahi (`lpstat -e`).
- --system-talk-name=org.freedesktop.Avahi
# The user's CUPS settings, read-only: their default printer and saved options (lpoptions)
# and a print server named in their client.conf, which lp reaches over the network.
- --filesystem=~/.cups:ro
# Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and
# drag-and-drop through the document portal, so picked files work anywhere without broad
# filesystem access. Pictures and Documents are granted so files passed by path (the command
@@ -38,6 +43,50 @@ finish-args:
- --filesystem=xdg-documents
# Deliberately not granted: home/host filesystem access.
modules:
# CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions.
# Linked statically against this release's libcups, so a runtime update can't break them;
# the socket path matches the host's, which --socket=cups mounts.
- name: cups-client
buildsystem: simple
build-commands:
- ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups
--with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups
--with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus
- make -C cups libs
- make -C systemv lp lpoptions lpstat
- install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat
- for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done
- install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE
sources:
- type: archive
url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz
sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff
# Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing
# it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled
# exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing
# when that socket is missing. CUPS reads the keyword in any case and takes the last
# ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at
# the mount; when it isn't, the tool fails rather than reach a different server. A
# CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted.
- type: script
dest-filename: cups-socket.sh
commands:
- 'conf="$HOME/.cups/client.conf"'
- 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then'
- ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)'
- ' case "$server" in'
- ' /*)'
- ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')'
- ' if [ "$mounted" = "$server" ]; then'
- ' export CUPS_SERVER=/var/run/cups/cups.sock'
- ' else'
- ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2'
- ' exit 1'
- ' fi'
- ' ;;'
- ' esac'
- 'fi'
- 'exec "/app/libexec/cups/${0##*/}" "$@"'
- name: photocraft
buildsystem: simple
build-commands:
@@ -28,9 +28,14 @@ finish-args:
- --share=network
# GPU access for the wgpu compositor (Vulkan, or GL as a fallback).
- --device=dri
# Printing: the host's CUPS socket, so File › Print reaches the print server directly (IPP,
# in process). Without it the app shows the system print dialog through the print portal.
# Printing: the host's CUPS socket, for the bundled lp/lpstat (the cups-client module) and the
# in-process IPP client. Without it the app shows the system print dialog through the print portal.
- --socket=cups
# Driverless network printers are found through Avahi (`lpstat -e`).
- --system-talk-name=org.freedesktop.Avahi
# The user's CUPS settings, read-only: their default printer and saved options (lpoptions)
# and a print server named in their client.conf, which lp reaches over the network.
- --filesystem=~/.cups:ro
# Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and
# drag-and-drop through the document portal, so picked files work anywhere without broad
# filesystem access. Pictures and Documents are granted so files passed by path (the command
@@ -41,6 +46,50 @@ finish-args:
build-options:
append-path: /usr/lib/sdk/rust-stable/bin
modules:
# CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions.
# Linked statically against this release's libcups, so a runtime update can't break them;
# the socket path matches the host's, which --socket=cups mounts.
- name: cups-client
buildsystem: simple
build-commands:
- ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups
--with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups
--with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus
- make -C cups libs
- make -C systemv lp lpoptions lpstat
- install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat
- for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done
- install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE
sources:
- type: archive
url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz
sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff
# Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing
# it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled
# exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing
# when that socket is missing. CUPS reads the keyword in any case and takes the last
# ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at
# the mount; when it isn't, the tool fails rather than reach a different server. A
# CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted.
- type: script
dest-filename: cups-socket.sh
commands:
- 'conf="$HOME/.cups/client.conf"'
- 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then'
- ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)'
- ' case "$server" in'
- ' /*)'
- ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')'
- ' if [ "$mounted" = "$server" ]; then'
- ' export CUPS_SERVER=/var/run/cups/cups.sock'
- ' else'
- ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2'
- ' exit 1'
- ' fi'
- ' ;;'
- ' esac'
- 'fi'
- 'exec "/app/libexec/cups/${0##*/}" "$@"'
- name: photocraft
buildsystem: simple
build-options:
+4
View File
@@ -31,6 +31,10 @@
<string>hu</string>
<string>tr</string>
<string>vi</string>
<string>sv</string>
<string>fi</string>
<string>da</string>
<string>nb</string>
</array>
<key>CFBundleDisplayName</key>
<string>PhotoCraft</string>