Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ jobs:
- platform: macos-latest # universal (Apple Silicon + Intel)
args: "--target universal-apple-darwin"
- platform: ubuntu-22.04 # AppImage
args: ""
# Adds the bundled emoji font to the AppImage only (issue #45).
args: "--config src-tauri/tauri.appimage.conf.json"
- platform: windows-latest # NSIS .exe
args: ""
runs-on: ${{ matrix.platform }}
Expand Down Expand Up @@ -94,6 +95,10 @@ jobs:
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile

- name: Fetch the AppImage's emoji font (Linux only)
if: matrix.platform == 'ubuntu-22.04'
run: ./scripts/fetch-emoji-font.sh

- name: Link clang's runtime for ggml's Metal @available checks (macOS only)
if: matrix.platform == 'macos-latest'
# rustc links with -nodefaultlibs, so libclang_rt.osx.a (which provides
Expand Down Expand Up @@ -158,6 +163,12 @@ jobs:
deb=$(ls src-tauri/target/release/bundle/deb/*.deb)
dpkg-deb -f "$deb" Depends | tee /dev/stderr | grep -q libopenblas0
dpkg-deb --fsys-tarfile "$deb" | tar -xO --wildcards '*usr/share/applications/*.desktop' | tee /dev/stderr | grep -q '^MimeType=text/markdown'
# The AppImage must carry the bitmap emoji font its WebKit falls back
# to once colour fonts are hidden (issue #45).
appimage=$(ls src-tauri/target/release/bundle/appimage/*.AppImage)
(cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/$appimage" --appimage-extract 'usr/share/sarala/fonts/*' > /dev/null)
test -s "$RUNNER_TEMP/squashfs-root/usr/share/sarala/fonts/NotoColorEmoji.ttf" \
|| { echo "::error::the AppImage is missing its bundled emoji font"; exit 1; }

# The updater endpoint in tauri.conf.json points at a gist, so mirror the
# latest.json that tauri-action attached to the release into that gist. Runs
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ web/wasm/target/
web/public/pandoc/
web/public/ort/
web/public/licenses/

# AppImage emoji font, fetched by scripts/fetch-emoji-font.sh
src-tauri/linux/fonts/
28 changes: 28 additions & 0 deletions scripts/fetch-emoji-font.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
#
# Fetch the bitmap (CBDT) Noto Color Emoji that the Linux AppImage bundles
# (issue #45; see src-tauri/src/appimage_fonts.rs for why). Pinned to a release
# tag and checked by SHA-256. Used by src-tauri/tauri.appimage.conf.json, which
# only the AppImage build passes, so other builds never need this.
#
set -euo pipefail

TAG="v2.051"
BASE="https://raw.githubusercontent.com/googlefonts/noto-emoji/$TAG/fonts"
DEST="$(cd "$(dirname "$0")/.." && pwd)/src-tauri/linux/fonts"
mkdir -p "$DEST"

fetch() { # <remote name> <local name> <sha256>
local out="$DEST/$2"
if [ -f "$out" ] && echo "$3 $out" | shasum -a 256 -c - > /dev/null 2>&1; then
return
fi
curl -sSfL --retry 3 -o "$out.part" "$BASE/$1"
echo "$3 $out.part" | shasum -a 256 -c - > /dev/null \
|| { echo "checksum mismatch for $1" >&2; rm -f "$out.part"; exit 1; }
mv "$out.part" "$out"
}

fetch NotoColorEmoji.ttf NotoColorEmoji.ttf 72a635cb3d2f3524c51620cdde406b217204e8a6a06c6a096ff8ed4b5fd6e27b
fetch LICENSE NotoColorEmoji-OFL.txt 6a73f9541c2de74158c0e7cf6b0a58ef774f5a780bf191f2d7ec9cc53efe2bf2
echo "emoji font ready in $DEST"
142 changes: 142 additions & 0 deletions src-tauri/src/appimage_fonts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//! Keep COLRv1 colour fonts away from the AppImage's bundled WebKit (issue #45).
//!
//! The AppImage ships its own WebKitGTK, and the Skia inside it was built with
//! libstdc++ assertions on. Its COLRv1 code indexes a colour-stop vector out of
//! bounds when it paints a glyph that uses a gradient (for example U+1F50D in
//! Fedora's COLRv1 Noto Color Emoji), the assertion aborts the web process, and
//! the window freezes while the main process lives on. Native packages, the
//! Flatpak and the snap use the system WebKit and are not affected.
//!
//! The fix, applied at the bundle instead of asking users to change fonts: hide
//! every colour font from fontconfig and allow back only the bitmap (CBDT) Noto
//! Color Emoji the AppImage carries, which never reaches that Skia path.
//!
//! How it works: fontconfig reads the file named by `FONTCONFIG_FILE`. We write a
//! small config that includes the host's normal one, adds our font folder, and
//! adds a `<rejectfont>` for colour fonts with an `<acceptfont>` for ours
//! (fontconfig lets an accepted pattern override a rejected one; a `<glob>`
//! accept would not, so ours matches on `file`). The variable is set before
//! Tauri starts, so the WebKit web processes, which are child processes, inherit
//! it.

use std::path::{Path, PathBuf};

/// Where the AppImage bundle puts the emoji font (see `tauri.appimage.conf.json`).
const BUNDLED_FONT: &str = "usr/share/sarala/fonts/NotoColorEmoji.ttf";

/// Run first thing in `main`, before any thread exists: `set_var` is only safe
/// while the process is single-threaded.
pub fn apply() {
// Both are exported by the AppImage runtime; outside an AppImage, do nothing.
if std::env::var_os("APPIMAGE").is_none() {
return;
}
let Some(appdir) = std::env::var_os("APPDIR").map(PathBuf::from) else {
return;
};
let font = appdir.join(BUNDLED_FONT);
let font = font.is_file().then_some(font);
// Respect a config the user already points fontconfig at.
let base = std::env::var_os("FONTCONFIG_FILE")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/etc/fonts/fonts.conf"));

let dir = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.filter(|d| d.is_dir())
.unwrap_or_else(std::env::temp_dir);
remove_stale_configs(&dir);
// One file per process: the AppImage mounts at a new path on every launch,
// so two running copies can't share a config.
let path = dir.join(format!("sarala-fonts-{}.conf", std::process::id()));
if std::fs::write(&path, config(&base, font.as_deref())).is_ok() {
std::env::set_var("FONTCONFIG_FILE", &path);
}
}

/// The fontconfig document. No DOCTYPE on purpose: fontconfig silently ignores a
/// config whose `fonts.dtd` it can't resolve, and this file lives in a temp dir.
fn config(base: &Path, font: Option<&Path>) -> String {
let mut out = String::from("<?xml version=\"1.0\"?>\n<fontconfig>\n");
out += &format!(
" <include ignore_missing=\"yes\">{}</include>\n",
xml_escape(&base.to_string_lossy())
);
if let Some(font) = font {
if let Some(dir) = font.parent() {
out += &format!(" <dir>{}</dir>\n", xml_escape(&dir.to_string_lossy()));
}
// Prefer it for emoji even if the host config ranks another family first.
out += " <alias binding=\"strong\">\n <family>emoji</family>\n <prefer><family>Noto Color Emoji</family></prefer>\n </alias>\n";
}
out += " <selectfont>\n";
if let Some(font) = font {
out += &format!(
" <acceptfont><pattern><patelt name=\"file\"><string>{}</string></patelt></pattern></acceptfont>\n",
xml_escape(&font.to_string_lossy())
);
}
out += " <rejectfont><pattern><patelt name=\"color\"><bool>true</bool></patelt></pattern></rejectfont>\n";
out += " </selectfont>\n</fontconfig>\n";
out
}

fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}

/// Delete configs left by earlier runs whose process is gone (a crash or a
/// killed process never cleans up after itself).
fn remove_stale_configs(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(pid) = name
.to_str()
.and_then(|n| n.strip_prefix("sarala-fonts-"))
.and_then(|n| n.strip_suffix(".conf"))
else {
continue;
};
if pid.parse::<u32>().is_ok() && !Path::new("/proc").join(pid).exists() {
let _ = std::fs::remove_file(entry.path());
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn config_rejects_colour_fonts_and_accepts_the_bundled_one() {
let font = Path::new("/tmp/.mount_Sarala1/usr/share/sarala/fonts/NotoColorEmoji.ttf");
let conf = config(Path::new("/etc/fonts/fonts.conf"), Some(font));
assert!(conf.contains("<include ignore_missing=\"yes\">/etc/fonts/fonts.conf</include>"));
assert!(conf.contains("<dir>/tmp/.mount_Sarala1/usr/share/sarala/fonts</dir>"));
assert!(conf.contains(&format!(
"<acceptfont><pattern><patelt name=\"file\"><string>{}</string></patelt></pattern></acceptfont>",
font.display()
)));
assert!(conf.contains("<patelt name=\"color\"><bool>true</bool></patelt>"));
assert!(!conf.contains("DOCTYPE"));
}

#[test]
fn config_without_bundled_font_still_rejects_colour_fonts() {
let conf = config(Path::new("/etc/fonts/fonts.conf"), None);
assert!(!conf.contains("<acceptfont>"));
assert!(!conf.contains("<dir>"));
assert!(conf.contains("<rejectfont>"));
}

#[test]
fn paths_are_xml_escaped() {
let conf = config(Path::new("/home/a&b/<fonts>.conf"), None);
assert!(conf.contains("/home/a&amp;b/&lt;fonts&gt;.conf"));
}
}
46 changes: 45 additions & 1 deletion src-tauri/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

mod ai;
// Pure std, so its tests run on every OS; only Linux calls it.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
mod appimage_fonts;
mod file_open;
mod menu;
mod voice;
Expand Down Expand Up @@ -610,7 +613,39 @@ fn new_window(app: AppHandle) -> Result<(), String> {
.map_err(|e| e.to_string())
}

/// File name that switches on portable settings (issue #40): when a file with
/// this name sits next to the app, settings are read from and written to it
/// instead of the per-user config folder.
const PORTABLE_SETTINGS: &str = "sarala-settings.json";

/// The folder a user thinks of as "where Sarala is": next to `Sarala.exe` on
/// Windows, next to the `.AppImage` file (not its temporary mount), and next to
/// `Sarala.app` on macOS (not `Sarala.app/Contents/MacOS`).
fn app_location() -> Option<PathBuf> {
if let Some(appimage) = std::env::var_os("APPIMAGE") {
return PathBuf::from(appimage).parent().map(Path::to_path_buf);
}
let exe = std::env::current_exe().ok()?;
let dir = exe.parent()?;
if cfg!(target_os = "macos") {
if let Some(bundle) = dir
.ancestors()
.find(|a| a.extension().is_some_and(|e| e == "app"))
{
return bundle.parent().map(Path::to_path_buf);
}
}
Some(dir.to_path_buf())
}

/// `<app location>/sarala-settings.json` if it exists, else
/// `<config dir>/settings.json`.
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
if let Some(portable) = app_location().map(|d| d.join(PORTABLE_SETTINGS)) {
if portable.is_file() {
return Ok(portable);
}
}
let dir = app.path().app_config_dir().map_err(|e| e.to_string())?;
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir.join("settings.json"))
Expand All @@ -620,6 +655,8 @@ fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
fn load_settings(app: AppHandle) -> Result<serde_json::Value, String> {
let path = settings_path(&app)?;
match fs::read_to_string(&path) {
// A freshly created, empty portable file means "start from defaults".
Ok(text) if text.trim().is_empty() => Ok(serde_json::json!({})),
Ok(text) => serde_json::from_str(&text).map_err(|e| e.to_string()),
Err(_) => Ok(serde_json::json!({})),
}
Expand All @@ -629,7 +666,10 @@ fn load_settings(app: AppHandle) -> Result<serde_json::Value, String> {
fn save_settings(app: AppHandle, value: serde_json::Value) -> Result<(), String> {
let path = settings_path(&app)?;
let text = serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?;
fs::write(&path, text).map_err(|e| e.to_string())
// Temp file + rename, so a crash mid-write never leaves a truncated file.
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, text).map_err(|e| e.to_string())?;
fs::rename(&tmp, &path).map_err(|e| e.to_string())
}

#[tauri::command]
Expand Down Expand Up @@ -1318,6 +1358,10 @@ fn apply_gnome_button_layout() {
}

fn main() {
// Before anything starts a thread (see the module for why).
#[cfg(target_os = "linux")]
appimage_fonts::apply();

tauri::Builder::default()
.manage({
#[cfg(target_os = "macos")]
Expand Down
13 changes: 13 additions & 0 deletions src-tauri/tauri.appimage.conf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"linux": {
"appimage": {
"files": {
"/usr/share/sarala/fonts/NotoColorEmoji.ttf": "linux/fonts/NotoColorEmoji.ttf",
"/usr/share/sarala/fonts/NotoColorEmoji-OFL.txt": "linux/fonts/NotoColorEmoji-OFL.txt"
}
}
}
}
}
32 changes: 22 additions & 10 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,15 @@ import {
onExternalChange, onTreeChange, onTreeTouch,
} from "./platform";
import {
executeCommand, openFile, openFolder, insertImageFromPath,
executeCommand, openFile, openFolder, insertImageFromPath, confirmCloseDirty,
} from "./commands";
import { BLOCK_TARGETED_IDS } from "./menudata";
import { makeMenuKeyHandler } from "./shortcuts";
import { showSource } from "./viewswitch";
import { startAutosave, findRecoverable, restoreSession, shadowBaseName, discardShadows, discardShadowFor } from "./autosave";
import { startAutosave, findRecoverable, restoreSession, shadowBaseName, declineShadows, discardShadowFor } from "./autosave";

/** sessionStorage flag: the startup recovery prompt has run in this session. */
const RECOVERY_ASKED = "sarala.recoveryAsked";

export default function App() {
let editorEl: HTMLDivElement | undefined;
Expand Down Expand Up @@ -196,10 +199,10 @@ export default function App() {
const dirty = openTabs().filter((tab) => tab.dirty);
if (!dirty.length) return;
event.preventDefault();
if (await confirmDialog(`Close the window and discard unsaved changes to ${dirty.map((tab) => tab.name).join(", ")}?`)) {
// Deliberately discarding — drop the autosave shadow so the next
// launch doesn't offer to "recover" these changes. (A crash skips
// this handler, so genuine crash recovery still works.)
if (await confirmCloseDirty(dirty.map((tab) => tab.id), "window")) {
// Saved or deliberately discarded: drop the autosave shadow so the
// next launch doesn't offer to "recover" these changes. (A crash
// skips this handler, so genuine crash recovery still works.)
for (const tab of dirty) removeTab(tab.id);
await Promise.all(dirty.map((tab) => discardShadowFor(tab.filePath)));
await win.destroy();
Expand All @@ -209,7 +212,15 @@ export default function App() {
// Crash recovery runs once, in the initial window (new windows carry a
// "main-<ts>" label), so user-opened windows don't re-prompt.
if (win.label === "main") {
const cands = await findRecoverable();
// Once per app session, not once per page load: a reload of the
// webview must not open a second prompt over the first (the first,
// orphaned by the reload, then ignores its buttons).
let asked = false;
try {
asked = sessionStorage.getItem(RECOVERY_ASKED) === "1";
sessionStorage.setItem(RECOVERY_ASKED, "1");
} catch { /* storage unavailable: ask, as before */ }
const cands = asked ? [] : await findRecoverable();
if (cands.length) {
const newest = cands[0];
const extra = cands.length > 1
Expand All @@ -218,10 +229,11 @@ export default function App() {
const ok = await confirmDialog(
`Unsaved changes from a previous session were found for ${shadowBaseName(newest.path)}${extra}. Restore them now?`,
);
// Restore the newest and keep the rest for per-file recovery; on
// decline, discard every candidate so this never re-prompts.
// Restore the newest and keep the rest for per-file recovery. On
// decline, keep them all but marked declined, so this prompt
// doesn't return while opening the file still offers recovery.
if (ok) await restoreSession(newest);
else await discardShadows(cands);
else await declineShadows(cands);
}

// Once per launch (main window only, after any recovery prompt),
Expand Down
Loading
Loading