diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba1cb2b..bfca059 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,11 +4,19 @@ name: Release # 1. build & GPG-sign the Flatpak, # 2. generate a build-provenance attestation for the bundle, # 3. publish the signed OSTree repo to GitHub Pages (dl.nikableh.moe), -# 4. create a GitHub Release with the .flatpak bundle attached. +# 4. build the standalone macOS .app (build-aux/macos/bundle.sh) and pack +# it into a .dmg, +# 5. create a GitHub Release with the .flatpak and .dmg attached. # # Requires repo secrets FLATPAK_GPG_PRIVATE_KEY and FLATPAK_GPG_KEY_ID, Pages # set to "GitHub Actions" as its source, and (for public reach) a public repo. # See docs/RELEASING.md for the one-time setup. +# +# The .dmg is ad-hoc signed only (no Apple Developer ID/notarization), so +# Gatekeeper will flag it as from an unidentified developer on first launch - +# right-click > Open, or System Settings > Privacy & Security > Open Anyway, +# clears it. Notarizing would need a paid Developer ID and its own secrets; +# out of scope until someone asks for it. on: push: @@ -124,6 +132,45 @@ jobs: path: dist/ if-no-files-found: error + build-macos: + name: Build macOS app (.dmg) + runs-on: macos-14 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install build dependencies + # rust: cargo/rustc (edition 2024). gtk4/libadwaita/libsoup@3: the + # runtime stack bundle.sh links against and then bundles into the + # .app. dylibbundler: copies and relinks every dependent dylib. + # librsvg: gdk-pixbuf's SVG loader, and rsvg-convert for the app icon. + # adwaita-icon-theme: neither gtk4 nor libadwaita depends on it, but + # bundle.sh copies it in for the symbolic icons Koshi's UI uses - + # without it explicitly listed here, a runner with no other GTK app + # ever built on it (unlike a dev's already-populated Homebrew prefix) + # won't have it. + run: brew install rust gtk4 libadwaita libsoup@3 dylibbundler librsvg adwaita-icon-theme + + - name: Build Koshi.app + run: bash build-aux/macos/bundle.sh + + - name: Package as .dmg + run: | + set -eu + STAGE="$(mktemp -d)/Koshi" + mkdir -p "$STAGE" + cp -R target/macos/Koshi.app "$STAGE/" + ln -s /Applications "$STAGE/Applications" + mkdir -p dist + hdiutil create -volname Koshi -srcfolder "$STAGE" -ov -format UDZO dist/koshi.dmg + + - name: Upload release assets + uses: actions/upload-artifact@v4 + with: + name: release-assets-macos + path: dist/ + if-no-files-found: error + deploy-pages: name: Publish to dl.nikableh.moe needs: build @@ -138,16 +185,22 @@ jobs: release: name: Create GitHub Release - needs: build + needs: [build, build-macos] if: github.ref_type == 'tag' runs-on: ubuntu-latest steps: - - name: Download release assets + - name: Download Linux release assets uses: actions/download-artifact@v4 with: name: release-assets path: dist + - name: Download macOS release assets + uses: actions/download-artifact@v4 + with: + name: release-assets-macos + path: dist + - name: Publish release uses: softprops/action-gh-release@v2 with: @@ -156,5 +209,6 @@ jobs: dist/koshi.gpg dist/koshi.flatpakrepo dist/koshi.flatpakref + dist/koshi.dmg generate_release_notes: true fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore index b95e17a..25d1134 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ result-* .direnv/ /.flatpak/ /.vscode/ +.DS_Store diff --git a/Cargo.lock b/Cargo.lock index 7733a62..942565b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "cairo-rs" version = "0.22.0" @@ -90,6 +99,16 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -559,6 +578,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" name = "koshi" version = "0.3.0" dependencies = [ + "block2", "flate2", "glib 0.22.8", "glib-build-tools", @@ -566,6 +586,9 @@ dependencies = [ "libadwaita", "log", "mailparse", + "objc2", + "objc2-foundation", + "objc2-user-notifications", "quick-xml", "serde_json", "soup3", @@ -650,6 +673,57 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", +] + [[package]] name = "pango" version = "0.22.8" diff --git a/Cargo.toml b/Cargo.toml index 54f32bf..eb05172 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,3 +30,9 @@ textwrap = { version = "0.16", default-features = false, features = [ [build-dependencies] glib-build-tools = "0.21" + +[target.'cfg(target_os = "macos")'.dependencies] +block2 = "0.6" +objc2 = "0.6" +objc2-foundation = "0.3" +objc2-user-notifications = { version = "0.3", default-features = false, features = ["UNUserNotificationCenter", "UNNotificationContent", "UNNotificationRequest", "UNNotificationSettings", "std", "alloc", "block2", "UNNotificationTrigger"] } diff --git a/build-aux/macos/bundle.sh b/build-aux/macos/bundle.sh new file mode 100755 index 0000000..eccb321 --- /dev/null +++ b/build-aux/macos/bundle.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# Build a standalone Koshi.app for macOS: compiles the release binary, then +# bundles every Homebrew dylib and GTK runtime resource (icon theme, +# gdk-pixbuf loaders, the GIO TLS module, GSettings schemas) it needs so the +# result runs on a Mac that never had Homebrew's GTK stack installed. See +# otool -L on the output binary/Frameworks - none of it should reference +# /opt/homebrew once this script finishes. +# +# Requires (via `brew install rust libsoup@3 dylibbundler librsvg`): +# cargo, pkg-config-visible gtk4/libadwaita/libsoup3, dylibbundler, +# rsvg-convert. Run from anywhere; paths below are relative to the repo root. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +BREW_PREFIX="$(brew --prefix)" +APP="target/macos/Koshi.app" +VERSION="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + +echo "==> Building release binary" +cargo build -r + +echo "==> Generating app icon" +ICONSET="target/macos/icon.iconset" +rm -rf "$ICONSET" && mkdir -p "$ICONSET" +SRC_SVG="data/icons/scalable/apps/moe.nikableh.Koshi.svg" +for sz in 16 32 64 128 256 512 1024; do + rsvg-convert -w "$sz" -h "$sz" "$SRC_SVG" -o "$ICONSET/tmp_${sz}.png" +done +cp "$ICONSET/tmp_16.png" "$ICONSET/icon_16x16.png" +cp "$ICONSET/tmp_32.png" "$ICONSET/icon_16x16@2x.png" +cp "$ICONSET/tmp_32.png" "$ICONSET/icon_32x32.png" +cp "$ICONSET/tmp_64.png" "$ICONSET/icon_32x32@2x.png" +cp "$ICONSET/tmp_128.png" "$ICONSET/icon_128x128.png" +cp "$ICONSET/tmp_256.png" "$ICONSET/icon_128x128@2x.png" +cp "$ICONSET/tmp_256.png" "$ICONSET/icon_256x256.png" +cp "$ICONSET/tmp_512.png" "$ICONSET/icon_256x256@2x.png" +cp "$ICONSET/tmp_512.png" "$ICONSET/icon_512x512.png" +cp "$ICONSET/tmp_1024.png" "$ICONSET/icon_512x512@2x.png" +rm "$ICONSET"/tmp_*.png +iconutil -c icns "$ICONSET" -o target/macos/koshi.icns + +echo "==> Creating bundle skeleton" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$APP/Contents/Frameworks" +cp target/macos/koshi.icns "$APP/Contents/Resources/koshi.icns" + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleName + Koshi + CFBundleDisplayName + Koshi + CFBundleIdentifier + moe.nikableh.Koshi + CFBundleVersion + $VERSION + CFBundleShortVersionString + $VERSION + CFBundlePackageType + APPL + CFBundleExecutable + koshi + CFBundleIconFile + koshi.icns + CFBundleInfoDictionaryVersion + 6.0 + LSMinimumSystemVersion + 11.0 + NSHighResolutionCapable + + LSApplicationCategoryType + public.app-category.productivity + + +PLIST + +echo "==> Copying binary and dlopen'd modules" +cp target/release/koshi "$APP/Contents/MacOS/koshi-bin" +chmod +x "$APP/Contents/MacOS/koshi-bin" + +mkdir -p "$APP/Contents/Resources/lib/gdk-pixbuf-2.0/2.10.0/loaders" +cp "$BREW_PREFIX/lib/gdk-pixbuf-2.0/2.10.0/loaders/"*.so \ + "$APP/Contents/Resources/lib/gdk-pixbuf-2.0/2.10.0/loaders/" +mkdir -p "$APP/Contents/Resources/lib/gio/modules" +cp "$BREW_PREFIX/lib/gio/modules/libgiognutls.so" "$APP/Contents/Resources/lib/gio/modules/" +cp "$BREW_PREFIX/opt/gdk-pixbuf/bin/gdk-pixbuf-query-loaders" "$APP/Contents/MacOS/gdk-pixbuf-query-loaders" +chmod +w "$APP/Contents/Resources/lib/gdk-pixbuf-2.0/2.10.0/loaders/"*.so \ + "$APP/Contents/Resources/lib/gio/modules/"*.so \ + "$APP/Contents/MacOS/gdk-pixbuf-query-loaders" + +echo "==> Bundling dylibs (dylibbundler)" +# Every Mach-O we're shipping must go through ONE dylibbundler invocation +# with -od: it wipes the destination dir on each run, so a second pass would +# discard whatever the first pass copied for files not in that pass's -x list. +XARGS=(-x "$APP/Contents/MacOS/koshi-bin" -x "$APP/Contents/MacOS/gdk-pixbuf-query-loaders") +for f in "$APP"/Contents/Resources/lib/gdk-pixbuf-2.0/2.10.0/loaders/*.so \ + "$APP"/Contents/Resources/lib/gio/modules/*.so; do + XARGS+=(-x "$f") +done +dylibbundler -od -b "${XARGS[@]}" \ + -d "$APP/Contents/Frameworks" \ + -p @executable_path/../Frameworks/ \ + -s "$BREW_PREFIX/lib" \ + -s "$BREW_PREFIX/lib/gdk-pixbuf-2.0/2.10.0/loaders" \ + < /dev/null + +echo "==> De-duplicating LC_RPATH entries dylibbundler can leave behind" +# dylibbundler occasionally adds the same @executable_path/../Frameworks/ +# rpath to a file twice (seen on the librsvg gdk-pixbuf loader); dyld refuses +# to load a Mach-O with a duplicate LC_RPATH, so strip repeats down to one. +find "$APP" -type f | while read -r f; do + file "$f" 2>/dev/null | grep -q "Mach-O" || continue + dupes=$(otool -l "$f" 2>/dev/null | grep -A2 "cmd LC_RPATH" | grep "path " | sed -E 's/^ *path (.*) \(offset.*/\1/' | sort | uniq -d || true) + [ -z "$dupes" ] && continue + while IFS= read -r rpath; do + chmod +w "$f" + install_name_tool -delete_rpath "$rpath" "$f" + codesign --force --sign - "$f" 2>/dev/null + done <<< "$dupes" +done + +echo "==> Bundling Adwaita/hicolor icon themes and GSettings schemas" +# Neither gtk4 nor libadwaita depends on adwaita-icon-theme, so a prefix that +# has never had another GTK app built against it (a fresh CI runner, most +# often) won't have it unless it was installed explicitly - fail clearly here +# instead of a bare `cp: No such file or directory`. +if [ ! -d "$BREW_PREFIX/share/icons/Adwaita" ]; then + echo "error: $BREW_PREFIX/share/icons/Adwaita not found - run:" >&2 + echo " brew install adwaita-icon-theme" >&2 + exit 1 +fi +mkdir -p "$APP/Contents/Resources/share/icons" +cp -RL "$BREW_PREFIX/share/icons/Adwaita" "$APP/Contents/Resources/share/icons/" +cp -RL "$BREW_PREFIX/share/icons/hicolor" "$APP/Contents/Resources/share/icons/" + +mkdir -p "$APP/Contents/Resources/share/glib-2.0/schemas" +cp "$BREW_PREFIX/share/glib-2.0/schemas/org.gtk.gtk4.Settings."*.gschema.xml \ + "$APP/Contents/Resources/share/glib-2.0/schemas/" +glib-compile-schemas "$APP/Contents/Resources/share/glib-2.0/schemas/" + +echo "==> Writing launcher" +cat > "$APP/Contents/MacOS/koshi" <<'LAUNCHER' +#!/bin/bash +# Points the bundled GTK4/libadwaita stack at the resources shipped +# alongside it instead of Homebrew's /opt/homebrew. +set -e + +HERE="$(cd "$(dirname "$0")" && pwd)" +RES="$HERE/../Resources" +CACHE_DIR="$HOME/Library/Caches/moe.nikableh.Koshi" +mkdir -p "$CACHE_DIR" + +# gdk-pixbuf's loaders.cache embeds absolute paths, which depend on where +# this .app happens to be installed - regenerate it against the bundled +# loaders every launch (a handful of small files, this is instant). +GDK_PIXBUF_MODULEDIR="$RES/lib/gdk-pixbuf-2.0/2.10.0/loaders" \ + "$HERE/gdk-pixbuf-query-loaders" > "$CACHE_DIR/loaders.cache" + +export GDK_PIXBUF_MODULE_FILE="$CACHE_DIR/loaders.cache" +export GIO_EXTRA_MODULES="$RES/lib/gio/modules" +export GSETTINGS_SCHEMA_DIR="$RES/share/glib-2.0/schemas" +export XDG_DATA_DIRS="$RES/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" + +exec "$HERE/koshi-bin" "$@" +LAUNCHER +chmod +x "$APP/Contents/MacOS/koshi" + +echo "==> Done: $APP ($(du -sh "$APP" | cut -f1))" diff --git a/src/composer.rs b/src/composer.rs index 19b032f..b2f29df 100644 --- a/src/composer.rs +++ b/src/composer.rs @@ -29,6 +29,13 @@ pub struct ReplyContext { /// The parent message's `References` header, carried (not edited) so the /// reply can extend the chain. pub references: String, + /// The lore list and a Message-ID of the thread being replied to — any + /// member of the thread works, since Koshi always fetches a whole thread + /// by any one of its messages. `None` for a from-scratch compose, which + /// has no thread to link back to. Carried through only so a successful + /// send can log it in [`crate::sent`] for the Sent page's "view thread". + pub list: Option, + pub thread_message_id: Option, } impl ReplyContext { @@ -42,6 +49,8 @@ impl ReplyContext { subject: String::new(), in_reply_to: String::new(), references: String::new(), + list: None, + thread_message_id: None, } } } @@ -566,12 +575,23 @@ fn build_send_button(state: &ComposerState, surface: &Surface) -> gtk::Button { fn send_now(state: &ComposerState, button: >k::Button, surface: &Surface) { let doc = normalize_document(&state.document_text()); let (headers, _) = message::parse_headers(&doc); + let to = header_owned(&headers, "To"); + let cc = header_owned(&headers, "Cc"); + let subject = header_owned(&headers, "Subject"); let request = send::Request { from: header_owned(&headers, "From"), - to: header_owned(&headers, "To"), - cc: header_owned(&headers, "Cc"), + to: to.clone(), + cc: cc.clone(), eml: doc, }; + // The thread this is a reply to (if any) rides the composer's own + // ReplyContext, not the editable headers - see the doc comment on + // ReplyContext::list. Read now, before the async send, since a fast + // Discard/retarget elsewhere could otherwise change it out from under us. + let (list, thread_message_id) = { + let initial = state.initial.borrow(); + (initial.list.clone(), initial.thread_message_id.clone()) + }; // Resolve the toast surface now, while the button is still in the tree. let overlay = button .ancestor(adw::ToastOverlay::static_type()) @@ -590,6 +610,18 @@ fn send_now(state: &ComposerState, button: >k::Button, surface: &Surface) { button.set_sensitive(true); match outcome { Ok(send::Outcome::Sent) => { + crate::sent::record(crate::sent::SentMessage { + subject, + to, + cc, + sent_at: glib::DateTime::now_local() + .ok() + .and_then(|now| now.format("%a, %d %b %Y %H:%M").ok()) + .map(Into::into) + .unwrap_or_default(), + list, + thread_message_id, + }); if let Some(overlay) = &overlay { overlay.add_toast(adw::Toast::new("Reply sent")); } @@ -971,6 +1003,7 @@ fn build_body_editor(buffer: >k::TextBuffer, compact: bool) -> (gtk::Overlay, .right_margin(8) .top_margin(8) .bottom_margin(8) + .css_classes(["koshi-body-text"]) .build(); strip_extra_context_items(&view); diff --git a/src/highlight.rs b/src/highlight.rs index b3396bb..7267582 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -351,6 +351,80 @@ const ALL_TAGS: [&str; 6] = [ QUOTE_TAG, ADD_TAG, REMOVE_TAG, HUNK_TAG, HEADER_TAG, META_TAG, ]; +/// Fold tags: applied alongside the color tags above to every quote (any +/// depth) and every diff span respectively. Folding a message toggles the +/// tag's own `invisible` property rather than adding/removing it from text, +/// so it stays in step with [`refresh`]/[`refresh_step`] re-tagging the same +/// spans on every highlight pass. +const FOLD_QUOTE_TAG: &str = "koshi-fold-quote"; +const FOLD_DIFF_TAG: &str = "koshi-fold-diff"; +const FOLD_TAGS: [&str; 2] = [FOLD_QUOTE_TAG, FOLD_DIFF_TAG]; + +/// Per line of `text`, whether it is quoted (any depth) and whether it is +/// part of a diff — unlike [`classify`]'s color spans, this covers *every* +/// line a diff touches, including unchanged context lines, which carry no +/// span (and so no color) but still have to fold away for a diff-fold to +/// actually shrink the row instead of leaving a blank gap where they used to +/// be. Reuses [`step`], the same state machine `classify` and `preserve_mask` +/// both drive. +fn fold_membership(text: &str) -> Vec<(bool, bool)> { + let lines: Vec<&str> = text.split('\n').collect(); + let mut membership = Vec::with_capacity(lines.len()); + let mut state = State::None; + let mut state_depth = 0usize; + + for (n, line) in lines.iter().enumerate() { + let (depth, prefix) = split_quote(line); + if depth != state_depth { + state = State::None; + state_depth = depth; + } + let content = line[prefix..].strip_suffix('\r').unwrap_or(&line[prefix..]); + let next_is_plus = lines.get(n + 1).is_some_and(|next| { + let (d, p) = split_quote(next); + d == depth && next[p..].starts_with("+++ ") + }); + let (next_state, outcome) = step(state, content, next_is_plus, depth > 0); + state = next_state; + + membership.push((depth > 0, matches!(outcome, Outcome::InDiff(_)))); + } + membership +} + +/// How many lines of `text` are quoted, and how many are part of a diff — +/// the same per-line membership folding uses, run once up front so a caller +/// can size a row before folding and decide whether to show fold controls at +/// all. A line quoting a diff counts in both. +pub fn foldable_line_counts(text: &str) -> (usize, usize) { + let membership = fold_membership(text); + let quote_lines = membership.iter().filter(|(quote, _)| *quote).count(); + let diff_lines = membership.iter().filter(|(_, diff)| *diff).count(); + (quote_lines, diff_lines) +} + +/// Fold (hide) or unfold quoted text in `buffer` — every quoted line, at any +/// depth. A no-op if [`attach`] was never called on this buffer. +pub fn set_quote_folded(buffer: >k::TextBuffer, folded: bool) { + set_folded(buffer, FOLD_QUOTE_TAG, folded); +} + +/// Fold (hide) or unfold every line of a diff in `buffer` — headers, hunk +/// markers, metadata, added/removed lines, and unchanged context lines +/// alike. Context lines carry no color (see [`Kind`]), but still have to +/// fold away with the rest of the hunk: leaving them visible would strand +/// isolated fragments of unrelated-looking code with blank gaps around them +/// instead of folding the hunk closed. +pub fn set_diff_folded(buffer: >k::TextBuffer, folded: bool) { + set_folded(buffer, FOLD_DIFF_TAG, folded); +} + +fn set_folded(buffer: >k::TextBuffer, tag_name: &str, folded: bool) { + if let Some(tag) = buffer.tag_table().lookup(tag_name) { + tag.set_property("invisible", folded); + } +} + /// Find-in-thread highlight tags. Deliberately kept out of ALL_TAGS: the /// quote/diff refresh must not strip them, and they paint a background (not a /// foreground), so a match keeps its line's quote/diff coloring underneath. @@ -435,6 +509,10 @@ pub fn attach(buffer: >k::TextBuffer) { for name in [SEARCH_TAG, SEARCH_CURRENT_TAG, TRAILING_TAG] { buffer.create_tag(Some(name), &[]); } + // Unfolded (visible) by default; a fresh message always opens expanded. + for name in FOLD_TAGS { + buffer.create_tag(Some(name), &[("invisible", &false)]); + } let style = adw::StyleManager::default(); apply_colors(buffer, style.is_dark()); @@ -452,6 +530,9 @@ pub fn attach(buffer: >k::TextBuffer) { SPAN_CACHE.with_borrow_mut(|cache| { cache.remove(&key); }); + FOLD_CACHE.with_borrow_mut(|cache| { + cache.remove(&key); + }); PAINT_PROGRESS.with_borrow_mut(|progress| { progress.remove(&key); }); @@ -574,6 +655,49 @@ thread_local! { /// live buffers. static SPAN_CACHE: std::cell::RefCell>> = std::cell::RefCell::new(std::collections::HashMap::new()); + /// Last fold membership applied per buffer, keyed the same way as + /// [`SPAN_CACHE`] and cleared by the same weak-ref notify. + static FOLD_CACHE: std::cell::RefCell>> = + std::cell::RefCell::new(std::collections::HashMap::new()); +} + +/// Apply or clear the fold tags a line's [`fold_membership`] calls for, +/// touching only lines whose membership changed since the last call +/// (mirroring how [`refresh`] diffs spans). Each tag covers the *whole* +/// line, including its trailing newline — unlike a color span, folding only +/// shrinks a row's height when the newline itself is hidden too, not just +/// the glyphs before it. +fn retag_fold_lines(buffer: >k::TextBuffer, text: &str, line_starts: &[i32], total_chars: i32) { + let key = buffer.as_ptr() as usize; + let membership = fold_membership(text); + let old = FOLD_CACHE + .with_borrow(|cache| cache.get(&key).cloned()) + .unwrap_or_default(); + + let table = buffer.tag_table(); + for (n, &(quote, diff)) in membership.iter().enumerate() { + if old.get(n) == Some(&(quote, diff)) { + continue; + } + let base = line_starts[n]; + let line_end = line_starts.get(n + 1).copied().unwrap_or(total_chars); + let from = buffer.iter_at_offset(base); + let to = buffer.iter_at_offset(line_end); + for name in FOLD_TAGS { + if let Some(tag) = table.lookup(name) { + buffer.remove_tag(&tag, &from, &to); + } + } + if quote && let Some(tag) = table.lookup(FOLD_QUOTE_TAG) { + buffer.apply_tag(&tag, &from, &to); + } + if diff && let Some(tag) = table.lookup(FOLD_DIFF_TAG) { + buffer.apply_tag(&tag, &from, &to); + } + } + FOLD_CACHE.with_borrow_mut(|cache| { + cache.insert(key, membership); + }); } /// Re-run classification over the whole buffer and retag only the lines @@ -639,6 +763,8 @@ pub fn refresh(buffer: >k::TextBuffer) { SPAN_CACHE.with_borrow_mut(|cache| { cache.insert(key, spans); }); + + retag_fold_lines(buffer, &text, &line_starts, total_chars); } /// A partially applied highlight pass, so multi-megabyte bodies can be @@ -647,6 +773,7 @@ pub fn refresh(buffer: >k::TextBuffer) { /// nearly every line). struct PaintProgress { spans: Vec, + fold_membership: Vec<(bool, bool)>, line_starts: Vec, total_chars: i32, next_line: usize, @@ -683,6 +810,7 @@ pub fn refresh_step(buffer: >k::TextBuffer, lines: usize) -> bool { } PaintProgress { spans: classify(&text), + fold_membership: fold_membership(&text), line_starts, total_chars: off, next_line: 0, @@ -705,14 +833,44 @@ pub fn refresh_step(buffer: >k::TextBuffer, lines: usize) -> bool { buffer.apply_tag_by_name(tag_name(span.kind), &from, &to); progress.next_span += 1; } + + // Fold tags cover the whole line, including the newline, for the same + // [next_line, end_line) window the spans loop above just advanced + // through - see retag_fold_lines's doc comment for why. + let table = buffer.tag_table(); + let chunk_end = end_line.min(progress.fold_membership.len()); + for n in progress.next_line..chunk_end { + let (quote, diff) = progress.fold_membership[n]; + if !quote && !diff { + continue; + } + let base = progress.line_starts[n]; + let line_end = progress + .line_starts + .get(n + 1) + .copied() + .unwrap_or(progress.total_chars); + let from = buffer.iter_at_offset(base); + let to = buffer.iter_at_offset(line_end); + if quote && let Some(tag) = table.lookup(FOLD_QUOTE_TAG) { + buffer.apply_tag(&tag, &from, &to); + } + if diff && let Some(tag) = table.lookup(FOLD_DIFF_TAG) { + buffer.apply_tag(&tag, &from, &to); + } + } + progress.next_line = end_line; if progress.next_line >= progress.line_starts.len() { - // Done: leave the final spans where refresh's diffing expects them, - // so a later full refresh sees the true tag state. + // Done: leave the final spans/membership where refresh's diffing + // expects them, so a later full refresh sees the true tag state. SPAN_CACHE.with_borrow_mut(|cache| { cache.insert(key, progress.spans); }); + FOLD_CACHE.with_borrow_mut(|cache| { + cache.insert(key, progress.fold_membership); + }); false } else { PAINT_PROGRESS.with_borrow_mut(|map| { @@ -1013,6 +1171,57 @@ diff --git a/f b/f assert!(trailing_whitespace("a\nb\nc").is_empty()); } + #[test] + fn foldable_line_counts_separates_quote_from_diff() { + let body = "\ +reply line +> quoted line one +> quoted line two +diff --git a/f b/f +--- a/f ++++ b/f +@@ -1 +1 @@ +-old ++new"; + let (quote, diff) = foldable_line_counts(body); + assert_eq!(quote, 2); + assert_eq!(diff, 6); + } + + #[test] + fn foldable_line_counts_double_counts_a_quoted_diff_line() { + // A line that is both quoted and part of a diff contributes to both + // counts - callers subtracting both when folding both may + // under-estimate the freed height slightly, which is fine for a + // pre-realization size estimate. + let (quote, diff) = foldable_line_counts("> diff --git a/f b/f"); + assert_eq!(quote, 1); + assert_eq!(diff, 1); + } + + #[test] + fn foldable_line_counts_are_zero_for_plain_prose() { + assert_eq!(foldable_line_counts("just a reply\nwith no quotes"), (0, 0)); + } + + #[test] + fn foldable_line_counts_includes_context_lines_in_a_hunk() { + // Context lines carry no color span (see kinds_by_line's own check + // that a context line is untagged), but they still have to fold with + // the rest of the hunk - otherwise folding "diff" strands them as + // isolated, unexplained fragments instead of closing the hunk. + let body = "\ +diff --git a/f b/f +--- a/f ++++ b/f +@@ -1,3 +1,3 @@ + keep this line +-old ++new"; + let (_, diff) = foldable_line_counts(body); + assert_eq!(diff, 7, "the ' keep this line' context line must count too"); + } + #[test] fn crlf_body_classifies_like_lf() { let body = diff --git a/src/main.rs b/src/main.rs index b8b2cb0..b893967 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,8 @@ mod profile; mod profile_menu; mod remote_page; mod send; +mod sent; +mod sent_page; mod settings; mod subscriptions; mod subscriptions_page; @@ -21,7 +23,7 @@ mod thread_page; mod watcher; mod window_state; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use adw::prelude::*; @@ -30,6 +32,7 @@ use gtk::{gio, glib}; use favorites_page::{FAVORITES_PAGE_NAME, build_favorites_page}; use inbox_page::{INBOX_LIST_TITLE, build_inbox_page, build_inbox_page_deferred}; use profile_menu::build_profile_button; +use sent_page::{SENT_PAGE_NAME, build_sent_page}; use subscriptions_page::{SUBSCRIPTIONS_PAGE_NAME, build_subscriptions_page}; use thread_list_page::{ build_search_page, build_thread_list_page, build_thread_list_page_deferred, @@ -77,11 +80,15 @@ fn main() -> glib::ExitCode { let data_dir = glib::user_data_dir().join("koshi"); favorites::init(data_dir.join("favorites.json")); subscriptions::init(data_dir.join("subscriptions.json")); + sent::init(data_dir.join("sent.json")); window_state::init(data_dir.join("window-state.json")); // Preferences are user configuration, so they live in the config dir // ($XDG_CONFIG_HOME), not the data dir used for window state above. settings::init(glib::user_config_dir().join("koshi").join("settings.json")); load_css(); + apply_body_font_size(settings::body_font_size()); + apply_ui_text_scale(settings::ui_text_scale()); + apply_theme(settings::theme()); register_bundled_icons(); // Use the bundled app icon for window/taskbar decorations. When Koshi // is installed its desktop file points the shell at the same icon; this @@ -95,8 +102,9 @@ fn main() -> glib::ExitCode { app.run() } -// The single user-approved custom-CSS exception: compact address chips. -// Everything else must stay stock Adwaita. +// The two user-approved custom-CSS exceptions: compact address chips, and the +// user-adjustable body text size (below). Everything else must stay stock +// Adwaita. fn load_css() { let provider = gtk::CssProvider::new(); provider.load_from_string( @@ -111,6 +119,90 @@ fn load_css() { } } +thread_local! { + // Reloaded (not re-added) on every Preferences change, so the display + // never accumulates one provider per adjustment. + static BODY_FONT_PROVIDER: gtk::CssProvider = { + let provider = gtk::CssProvider::new(); + if let Some(display) = gtk::gdk::Display::default() { + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } + provider + }; +} + +/// Apply the user's chosen body text size to every message body: the thread +/// reading pane and the composer editor, both marked with the +/// `koshi-body-text` class. Scoped to that class alone so nothing else in the +/// UI moves off stock Adwaita sizing. +pub(crate) fn apply_body_font_size(px: u32) { + BODY_FONT_PROVIDER.with(|provider| { + provider.load_from_string(&format!( + "textview.koshi-body-text {{ font-size: {px}px; }}" + )); + }); +} + +/// GTK's own fallback font resolution when nothing else has set one — used as +/// the 100% baseline for [`apply_ui_text_scale`] when `gtk-xft-dpi` reads back +/// `-1` ("use the default"), which it always does unless something (the +/// desktop's own accessibility "large text" setting, or this function on an +/// earlier call) has already set a real value. 96 dpi is the standard +/// reference resolution X11/Pango assume in that case. +const DEFAULT_DPI_1024: i32 = 96 * 1024; + +thread_local! { + /// The 100% baseline, captured from `gtk-xft-dpi` the first time + /// [`apply_ui_text_scale`] runs (before it ever overwrites that setting), + /// so a later change in Preferences scales from the system's real default + /// rather than compounding onto whatever the last scale left behind. + static BASELINE_DPI_1024: Cell> = const { Cell::new(None) }; +} + +/// Apply the user's chosen interface text scale, as a percentage of the +/// system default, to the *entire* application — every label, button and +/// menu, not just message bodies. Unlike [`apply_body_font_size`] (a CSS rule +/// scoped to one class), this adjusts `GtkSettings:gtk-xft-dpi`, the same +/// font-resolution knob the desktop's own "large text" accessibility setting +/// uses, so it reaches every widget uniformly including ones in popovers and +/// dialogs that a CSS class on the main window would not. +pub(crate) fn apply_ui_text_scale(percent: u32) { + let Some(display_settings) = gtk::Settings::default() else { + return; + }; + let baseline = BASELINE_DPI_1024.with(|cell| { + if let Some(dpi) = cell.get() { + return dpi; + } + let current = display_settings.gtk_xft_dpi(); + let dpi = if current > 0 { + current + } else { + DEFAULT_DPI_1024 + }; + cell.set(Some(dpi)); + dpi + }); + let scaled = (baseline as i64 * percent as i64 / 100) as i32; + display_settings.set_gtk_xft_dpi(scaled); +} + +/// Apply the user's chosen theme via libadwaita's own color-scheme manager, +/// so it takes effect exactly like the system appearance changing under a +/// "System" choice would - every stock Adwaita color already responds to it. +pub(crate) fn apply_theme(theme: settings::Theme) { + let scheme = match theme { + settings::Theme::System => adw::ColorScheme::Default, + settings::Theme::Light => adw::ColorScheme::ForceLight, + settings::Theme::Dark => adw::ColorScheme::ForceDark, + }; + adw::StyleManager::default().set_color_scheme(scheme); +} + // Icons bundled in the gresource (e.g. the mirrored rewrap arrow) are not in // the system theme, so the resource icon dir must be on the theme's path. fn register_bundled_icons() { @@ -642,6 +734,25 @@ fn build_search_entry() -> gtk::SearchEntry { .build() } +/// macOS shows real native traffic-light buttons, fixed at the OS's top-left +/// corner, whenever a `GtkWindowControls` leaves `use-native-controls` at its +/// default - AdwHeaderBar doesn't expose that as a property of its own, so +/// this walks down to the two it builds internally (one per side, see its +/// `windowcontrols.start`/`windowcontrols.end` CSS nodes) and turns it off on +/// each, falling back to GTK's own drawn close/minimize/maximize buttons, the +/// same ones every other platform already shows. +#[cfg(target_os = "macos")] +fn force_gtk_drawn_window_controls(widget: >k::Widget) { + if let Some(controls) = widget.downcast_ref::() { + controls.set_use_native_controls(false); + } + let mut child = widget.first_child(); + while let Some(w) = child { + force_gtk_drawn_window_controls(&w); + child = w.next_sibling(); + } +} + fn build_header_bar( search_entry: >k::SearchEntry, tab_view: &adw::TabView, @@ -649,6 +760,14 @@ fn build_header_bar( thread_overview: &gio::SimpleAction, ) -> adw::HeaderBar { let header = adw::HeaderBar::new(); + #[cfg(target_os = "macos")] + { + header.set_decoration_layout(Some(":minimize,maximize,close")); + // AdwHeaderBar builds its internal windowcontrols children lazily, + // not yet present right after `new()` - connecting here instead of + // calling immediately guarantees they exist by the time this runs. + header.connect_map(|header| force_gtk_drawn_window_controls(header.upcast_ref())); + } let back_button = gtk::Button::builder() .icon_name("go-previous-symbolic") @@ -725,6 +844,26 @@ fn build_header_bar( } )); + let sent_button = gtk::Button::builder() + .icon_name("mail-reply-sender-symbolic") + .tooltip_text("Sent") + .build(); + sent_button.connect_clicked(glib::clone!( + #[weak] + tab_view, + move |_| { + let Some(nav) = selected_nav(&tab_view) else { + return; + }; + let already_there = nav + .visible_page() + .is_some_and(|page| page.widget_name() == SENT_PAGE_NAME); + if !already_there { + nav.push(&build_sent_page(&nav)); + } + } + )); + let clamp = adw::Clamp::builder() .maximum_size(600) .tightening_threshold(400) @@ -749,6 +888,7 @@ fn build_header_bar( header.pack_end(&build_profile_button()); header.pack_end(&favorites_button); header.pack_end(&subscriptions_button); + header.pack_end(&sent_button); header.pack_end(&overview_button); header @@ -810,13 +950,33 @@ fn setup_actions( app.add_action_entries([preferences, shortcuts, about, quit]); - app.set_accels_for_action("win.focus-search", &["l"]); - app.set_accels_for_action("win.close-tab", &["w"]); + // rather than : GTK is documented to resolve it to Cmd + // on macOS and Ctrl everywhere else, so these are meant to match each + // platform's own muscle memory instead of only ever responding to a + // literal Ctrl press. Belt-and-braces on macOS specifically: this GTK + // backend's -to-Cmd resolution has been unreliable in practice, + // so every action also gets an explicit (literal Cmd) binding + // there, at no cost to the Linux bindings above. + accel(app, "win.focus-search", "l", "l"); + accel(app, "win.close-tab", "w", "w"); app.set_accels_for_action("win.toggle-thread-overview", &["F9"]); - app.set_accels_for_action("win.find-in-thread", &["f"]); - app.set_accels_for_action("app.preferences", &["comma"]); - app.set_accels_for_action("app.shortcuts", &["question"]); - app.set_accels_for_action("app.quit", &["q"]); + accel(app, "win.find-in-thread", "f", "f"); + accel(app, "app.preferences", "comma", "comma"); + accel(app, "app.shortcuts", "question", "question"); + accel(app, "app.quit", "q", "q"); +} + +/// Bind `action` to `primary` (`...`, the portable Ctrl/Cmd +/// modifier) everywhere, plus `meta` (the literal Cmd modifier) as an extra +/// binding on macOS only — see the comment above this function's call sites. +fn accel(app: &adw::Application, action: &str, primary: &str, meta: &str) { + #[cfg(target_os = "macos")] + app.set_accels_for_action(action, &[primary, meta]); + #[cfg(not(target_os = "macos"))] + { + let _ = meta; + app.set_accels_for_action(action, &[primary]); + } } fn show_preferences(app: &adw::Application) { @@ -824,6 +984,7 @@ fn show_preferences(app: &adw::Application) { .title("General") .icon_name("emblem-system-symbolic") .build(); + page.add(&build_appearance_group()); page.add(&build_replies_group()); page.add(&build_signature_group()); page.add(&build_notifications_group()); @@ -832,9 +993,121 @@ fn show_preferences(app: &adw::Application) { let dialog = adw::PreferencesDialog::new(); dialog.add(&page); + #[cfg(target_os = "macos")] + dialog.connect_map(|d| move_dialog_close_button_to_end(d.upcast_ref(), d.upcast_ref())); dialog.present(app.active_window().as_ref()); } +/// AdwDialog forces any `AdwHeaderBar` placed inside it to show only a close +/// button (see the "Header Bar Integration" section of Adw.Dialog's docs) at +/// its own fixed position on the start (left) side via a private internal +/// widget, with no public property to move it - unlike the main window's +/// title buttons, this has nothing to do with macOS's native window chrome, +/// it is libadwaita's own cross-platform dialog convention. Disabling the +/// built-in title buttons and packing a plain close button of our own on the +/// end (right) side is the only way to move it, so every dialog matches the +/// main window's buttons-on-the-right layout on macOS. +/// +/// `dialog` and `widget` are the same object in different types (the search +/// needs `>k::Widget` to walk the tree; closing needs `&adw::Dialog`) - +/// callers that already hold an `adw::HeaderBar` they built themselves should +/// just call the two `set_show_*_title_buttons` calls directly instead of +/// walking down to find it. +#[cfg(target_os = "macos")] +fn move_dialog_close_button_to_end(widget: >k::Widget, dialog: &adw::Dialog) { + if let Some(header) = widget.downcast_ref::() { + header.set_show_start_title_buttons(false); + header.set_show_end_title_buttons(false); + let close = gtk::Button::builder() + .icon_name("window-close-symbolic") + .tooltip_text("Close") + .valign(gtk::Align::Center) + .css_classes(["flat", "circular"]) + .build(); + close.connect_clicked(glib::clone!( + #[weak] + dialog, + move |_| { + dialog.close(); + } + )); + header.pack_end(&close); + return; + } + let mut child = widget.first_child(); + while let Some(w) = child { + move_dialog_close_button_to_end(&w, dialog); + child = w.next_sibling(); + } +} + +/// The Preferences group for reading-pane and composer text size, in pixels. +fn build_appearance_group() -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder().title("Appearance").build(); + + let theme_row = adw::ComboRow::builder() + .title("Theme") + .model(>k::StringList::new(&["System", "Light", "Dark"])) + .selected(match settings::theme() { + settings::Theme::System => 0, + settings::Theme::Light => 1, + settings::Theme::Dark => 2, + }) + .build(); + theme_row.connect_selected_notify(|row| { + let theme = match row.selected() { + 1 => settings::Theme::Light, + 2 => settings::Theme::Dark, + _ => settings::Theme::System, + }; + settings::set_theme(theme); + apply_theme(theme); + }); + group.add(&theme_row); + + let adjustment = gtk::Adjustment::new( + settings::body_font_size() as f64, + settings::MIN_BODY_FONT_SIZE as f64, + settings::MAX_BODY_FONT_SIZE as f64, + 1.0, + 2.0, + 0.0, + ); + let row = adw::SpinRow::builder() + .title("Message text size") + .subtitle("Applies to the reading pane and the reply composer.") + .adjustment(&adjustment) + .build(); + row.connect_value_notify(|row| { + let px = row.value() as u32; + settings::set_body_font_size(px); + apply_body_font_size(px); + }); + group.add(&row); + + let ui_adjustment = gtk::Adjustment::new( + settings::ui_text_scale() as f64, + settings::MIN_UI_TEXT_SCALE as f64, + settings::MAX_UI_TEXT_SCALE as f64, + 5.0, + 10.0, + 0.0, + ); + let ui_row = adw::SpinRow::builder() + .title("Interface text size") + .subtitle("Scales every label, button and menu in Koshi, as a percentage.") + .adjustment(&ui_adjustment) + .build(); + ui_row.connect_value_notify(|row| { + let percent = row.value() as u32; + settings::set_ui_text_scale(percent); + apply_ui_text_scale(percent); + }); + group.add(&ui_row); + + group +} + fn build_replies_group() -> adw::PreferencesGroup { let group = adw::PreferencesGroup::builder().title("Replies").build(); @@ -925,6 +1198,32 @@ fn build_notifications_group() -> adw::PreferencesGroup { .description("Koshi notifies you of new replies on threads you subscribe to.") .build(); + // macOS notifications need the user to grant permission at a one-time + // system prompt, easy to dismiss without noticing (and the whole point + // of a background poll is that Koshi isn't necessarily in front when it + // fires) - denial is otherwise silent, so surface it here whenever it is + // actually known to have happened. See watcher::notifications_denied. + #[cfg(target_os = "macos")] + if watcher::notifications_denied() { + let warning = adw::ActionRow::builder() + .title("Notifications are turned off for Koshi") + .subtitle("New-reply alerts won't show until you allow them in System Settings.") + .build(); + warning.add_prefix(>k::Image::from_icon_name("dialog-warning-symbolic")); + let open_settings = gtk::Button::builder() + .label("Open Settings") + .valign(gtk::Align::Center) + .css_classes(["flat"]) + .build(); + open_settings.connect_clicked(|_| { + let _ = std::process::Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.notifications") + .spawn(); + }); + warning.add_suffix(&open_settings); + group.add(&warning); + } + let adjustment = gtk::Adjustment::new( settings::poll_interval_minutes() as f64, settings::MIN_POLL_INTERVAL_MINUTES as f64, diff --git a/src/profile.rs b/src/profile.rs index 868056e..83f4ce5 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -227,6 +227,55 @@ pub fn set_active_identity(name: &str) -> bool { ok } +/// Write (or, when `value` is `None`, clear) one `sendemail..` +/// setting in the user's global git config — the same file [`load`] reads, so +/// Koshi keeps no identity store of its own even when it is the one writing. +/// `key` is the lowercase git config variable name (e.g. `"smtpserver"`, not +/// `"smtpServer"`); git lowercases the section/variable regardless of what is +/// passed, but the caller should not rely on that. Returns whether the write +/// (or clear) succeeded; clearing a key that was never set counts as success, +/// since the caller's intent — that key being absent — already holds. +pub fn set_identity_setting(identity: &str, key: &str, value: Option<&str>) -> bool { + let config_key = format!("sendemail.{identity}.{key}"); + let ok = match value { + Some(value) if !value.trim().is_empty() => crate::flatpak::git_command() + .args(["config", "--global", &config_key, value]) + .status() + .map(|status| status.success()) + .unwrap_or(false), + _ => { + let status = crate::flatpak::git_command() + .args(["config", "--global", "--unset", &config_key]) + .status(); + match status { + // Exit code 5: "the key does not exist" - already absent. + Ok(status) => status.success() || status.code() == Some(5), + Err(_) => false, + } + } + }; + invalidate(); + ok +} + +/// Remove a whole `[sendemail ""]` section from the user's global git +/// config, deleting the identity and every setting under it. Returns whether +/// the removal succeeded. +pub fn delete_identity(name: &str) -> bool { + let ok = crate::flatpak::git_command() + .args([ + "config", + "--global", + "--remove-section", + &format!("sendemail.{name}"), + ]) + .status() + .map(|status| status.success()) + .unwrap_or(false); + invalidate(); + ok +} + /// Evict a stored send-email SMTP password from git's credential helpers, so /// the next send prompts for it again. Best-effort: a send with no helper /// simply has nothing to evict. diff --git a/src/profile_menu.rs b/src/profile_menu.rs index f521e9a..f506833 100644 --- a/src/profile_menu.rs +++ b/src/profile_menu.rs @@ -90,9 +90,7 @@ fn build_content( } root.append(&build_header(profile)); - if profile.identities.len() >= 2 { - root.append(&build_identities(popover, profile)); - } + root.append(&build_identities(popover, button, profile)); if let Some(transport) = build_transport(profile) { root.append(&transport); } @@ -158,23 +156,35 @@ fn build_email_pill(email: &str) -> gtk::Widget { pill.upcast() } -/// The identity switcher, shown only when two or more identities exist: one -/// activatable row each, the active one marked with a checkmark. Activating a -/// different row writes `sendemail.identity` and closes the popover. -fn build_identities(popover: >k::Popover, profile: &Profile) -> gtk::Widget { - let group = adw::PreferencesGroup::new(); +/// The identity switcher and editor. One row per configured identity — with +/// an edit button that opens [`build_identity_editor_dialog`] pre-filled — +/// plus a trailing "Add Identity" row that opens the same dialog empty. +/// Shown whenever there is any git profile at all, even with zero identities +/// configured yet, since adding the first one is the point. +/// +/// The row itself is only activatable (to switch identity) when there are two +/// or more: with zero or one there is nothing to switch to, and clicking the +/// lone row would be a confusing no-op. Activating a different row writes +/// `sendemail.identity` and closes the popover. +fn build_identities( + popover: >k::Popover, + button: >k::MenuButton, + profile: &Profile, +) -> gtk::Widget { + let group = adw::PreferencesGroup::builder().title("Identities").build(); + let switchable = profile.identities.len() >= 2; for identity in &profile.identities { let is_active = profile.active_identity.as_deref() == Some(identity.name.as_str()); let row = adw::ActionRow::builder() .title(glib::markup_escape_text(&identity.name)) - .activatable(true) + .activatable(switchable) .build(); if let Some(email) = &identity.email { row.set_subtitle(&glib::markup_escape_text(email)); } - if is_active { + if is_active && switchable { row.add_suffix(>k::Image::from_icon_name("object-select-symbolic")); } @@ -189,12 +199,257 @@ fn build_identities(popover: >k::Popover, profile: &Profile) -> gtk::Widget { popover.popdown(); } )); + + let edit = gtk::Button::builder() + .icon_name("document-edit-symbolic") + .valign(gtk::Align::Center) + .tooltip_text("Edit identity") + .css_classes(["flat"]) + .build(); + let identity = identity.clone(); + edit.connect_clicked(glib::clone!( + #[weak] + popover, + #[weak] + button, + move |_| { + let dialog = build_identity_editor_dialog(Some(&identity)); + dialog.present(button.root().and_downcast::().as_ref()); + popover.popdown(); + } + )); + row.add_suffix(&edit); + group.add(&row); } + let add_row = adw::ActionRow::builder() + .title("Add Identity") + .activatable(true) + .build(); + add_row.add_prefix(>k::Image::from_icon_name("list-add-symbolic")); + add_row.connect_activated(glib::clone!( + #[weak] + popover, + #[weak] + button, + move |_| { + let dialog = build_identity_editor_dialog(None); + dialog.present(button.root().and_downcast::().as_ref()); + popover.popdown(); + } + )); + group.add(&add_row); + group.upcast() } +/// The create/edit form for one `[sendemail ""]` identity. Save writes +/// straight to the user's global git config via +/// [`profile::set_identity_setting`] — this dialog is a friendlier way to +/// edit that file, not a parallel store, matching how the rest of Koshi's +/// identity handling works (see the module doc on [`crate::profile`]). +fn build_identity_editor_dialog(existing: Option<&profile::Identity>) -> adw::Dialog { + // Built without a child first so the buttons below can hold a weak + // reference to it and close it themselves on Save/Delete. + let dialog = adw::Dialog::builder() + .title(if existing.is_some() { + "Edit Identity" + } else { + "Add Identity" + }) + .content_width(440) + .content_height(560) + .build(); + + let page = adw::PreferencesPage::new(); + page.set_description( + "Identities are git send-email identities, kept in your global git \ + configuration \u{2014} saving here writes straight there; nothing is \ + stored by Koshi itself.", + ); + + let group = adw::PreferencesGroup::new(); + + let name_row = adw::EntryRow::builder().title("Identity name").build(); + if let Some(identity) = existing { + name_row.set_text(identity.name.as_str()); + // Renaming would mean moving every setting to a new git config + // subsection, which this editor does not do - keep it fixed once + // created, same as the git config file itself has no rename. + name_row.set_sensitive(false); + } + group.add(&name_row); + + let field = |key: &str| -> String { + existing + .and_then(|identity| identity.settings.iter().find(|s| s.key == key)) + .map(|s| s.value.clone()) + .unwrap_or_default() + }; + + let from_row = adw::EntryRow::builder() + .title("From (Name )") + .build(); + from_row.set_text(&field("from")); + group.add(&from_row); + + let smtp_server_row = adw::EntryRow::builder().title("SMTP server").build(); + smtp_server_row.set_text(&field("smtpServer")); + group.add(&smtp_server_row); + + let smtp_port_row = adw::EntryRow::builder().title("SMTP port").build(); + smtp_port_row.set_text(&field("smtpServerPort")); + group.add(&smtp_port_row); + + let encryption_row = adw::ComboRow::builder() + .title("Encryption") + .model(>k::StringList::new(&["None", "SSL", "TLS"])) + .build(); + encryption_row.set_selected( + match field("smtpEncryption").to_ascii_lowercase().as_str() { + "ssl" => 1, + "tls" => 2, + _ => 0, + }, + ); + group.add(&encryption_row); + + let smtp_user_row = adw::EntryRow::builder().title("SMTP username").build(); + smtp_user_row.set_text(&field("smtpUser")); + group.add(&smtp_user_row); + + let sendmail_row = adw::EntryRow::builder() + .title("Local sendmail command (instead of SMTP)") + .build(); + sendmail_row.set_text(&field("sendmailCmd")); + group.add(&sendmail_row); + + page.add(&group); + + let overlay = adw::ToastOverlay::new(); + let header = adw::HeaderBar::new(); + // AdwDialog forces its own close button onto the header's start side with + // no way to move it - on macOS, replace it with one of our own on the end + // side, matching how the main window's buttons were moved there too. See + // the comment on main.rs's move_dialog_close_button_to_end. + #[cfg(target_os = "macos")] + { + header.set_show_start_title_buttons(false); + header.set_show_end_title_buttons(false); + let close = gtk::Button::builder() + .icon_name("window-close-symbolic") + .tooltip_text("Close") + .valign(gtk::Align::Center) + .css_classes(["flat", "circular"]) + .build(); + close.connect_clicked(glib::clone!( + #[weak] + dialog, + move |_| { + dialog.close(); + } + )); + header.pack_end(&close); + } + + let save = gtk::Button::builder() + .label("Save") + .css_classes(["suggested-action"]) + .build(); + header.pack_end(&save); + let is_new = existing.is_none(); + save.connect_clicked(glib::clone!( + #[weak] + dialog, + #[weak] + overlay, + #[weak] + name_row, + #[weak] + from_row, + #[weak] + smtp_server_row, + #[weak] + smtp_port_row, + #[weak] + encryption_row, + #[weak] + smtp_user_row, + #[weak] + sendmail_row, + move |_| { + let name = name_row.text().trim().to_string(); + if name.is_empty() { + overlay.add_toast(adw::Toast::new("Identity name is required")); + return; + } + let encryption = match encryption_row.selected() { + 1 => "ssl", + 2 => "tls", + _ => "", + }; + let fields = [ + ("from", from_row.text()), + ("smtpserver", smtp_server_row.text()), + ("smtpserverport", smtp_port_row.text()), + ("smtpencryption", encryption.into()), + ("smtpuser", smtp_user_row.text()), + ("sendmailcmd", sendmail_row.text()), + ]; + let mut ok = true; + for (key, value) in fields { + let value = value.trim(); + let value = (!value.is_empty()).then_some(value); + if !profile::set_identity_setting(&name, key, value) { + ok = false; + } + } + if !ok { + overlay.add_toast(adw::Toast::new("Failed to save identity")); + return; + } + // A brand-new identity is the obvious thing to send as next; + // editing an existing one leaves whichever is active alone. + if is_new { + profile::set_active_identity(&name); + } + dialog.close(); + } + )); + + if let Some(identity) = existing { + let delete = gtk::Button::builder() + .icon_name("user-trash-symbolic") + .tooltip_text("Delete identity") + .css_classes(["flat", "destructive-action"]) + .build(); + header.pack_start(&delete); + let name = identity.name.clone(); + delete.connect_clicked(glib::clone!( + #[weak] + dialog, + #[weak] + overlay, + move |_| { + if profile::delete_identity(&name) { + dialog.close(); + } else { + overlay.add_toast(adw::Toast::new("Failed to delete identity")); + } + } + )); + } + + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + toolbar.set_content(Some(&page)); + overlay.set_child(Some(&toolbar)); + dialog.set_child(Some(&overlay)); + + dialog +} + /// A single calm row saying where mail goes: server as title, "Port 465 · SSL" /// as subtitle (the Wi-Fi-row idiom). Absent when nothing sends mail. fn build_transport(profile: &Profile) -> Option { @@ -347,17 +602,44 @@ fn build_sending_dialog(profile: &Profile) -> adw::Dialog { page.add(&build_forget_password_group(host, username, &overlay)); } + let dialog = adw::Dialog::builder() + .title("Send Email") + .content_width(460) + .content_height(620) + .build(); + + let header = adw::HeaderBar::new(); + // See the comment on main.rs's move_dialog_close_button_to_end: AdwDialog + // forces its own close button onto the header's start side with no way to + // move it, so on macOS this replaces it with one of our own on the end + // side, matching the main window's buttons-on-the-right layout. + #[cfg(target_os = "macos")] + { + header.set_show_start_title_buttons(false); + header.set_show_end_title_buttons(false); + let close = gtk::Button::builder() + .icon_name("window-close-symbolic") + .tooltip_text("Close") + .valign(gtk::Align::Center) + .css_classes(["flat", "circular"]) + .build(); + close.connect_clicked(glib::clone!( + #[weak] + dialog, + move |_| { + dialog.close(); + } + )); + header.pack_end(&close); + } + let toolbar = adw::ToolbarView::new(); - toolbar.add_top_bar(&adw::HeaderBar::new()); + toolbar.add_top_bar(&header); toolbar.set_content(Some(&page)); overlay.set_child(Some(&toolbar)); + dialog.set_child(Some(&overlay)); - adw::Dialog::builder() - .title("Send Email") - .content_width(460) - .content_height(620) - .child(&overlay) - .build() + dialog } /// A group with one destructive action: forget the SMTP password kept for this diff --git a/src/sent.rs b/src/sent.rs new file mode 100644 index 0000000..59dead2 --- /dev/null +++ b/src/sent.rs @@ -0,0 +1,248 @@ +//! A local log of replies Koshi has sent, so "did I already reply to this" +//! and "what did I say" don't depend on lore.kernel.org having indexed the +//! message yet (it can take minutes). Purely a history: Koshi does not use +//! this to decide anything about sending itself, and nothing here is ever +//! sent anywhere — it is written only after `git send-email` has already +//! confirmed delivery. + +use std::cell::RefCell; +use std::fs; +use std::path::{Path, PathBuf}; + +/// One reply Koshi has sent. `list`/`thread_message_id` are the thread it was +/// sent into, when it was a reply to one Koshi had open (a from-scratch +/// compose has neither) — enough to reopen that thread the same way a +/// favorite or subscription does. +#[derive(Clone)] +pub struct SentMessage { + pub subject: String, + pub to: String, + pub cc: String, + /// RFC 2822 send time, as `humantime`-free local formatting already used + /// elsewhere in Koshi (see `Mail::date`) — a plain string, not parsed + /// back into anything. + pub sent_at: String, + pub list: Option, + pub thread_message_id: Option, +} + +thread_local! { + static SENT: RefCell> = const { RefCell::new(Vec::new()) }; + static STORE_PATH: RefCell> = const { RefCell::new(None) }; +} + +/// Load sent history from `path` and persist every later record back to it. +/// Without this call the store is in-memory only. +pub fn init(path: PathBuf) { + if let Some(sent) = load(&path) { + SENT.set(sent); + } + STORE_PATH.set(Some(path)); +} + +fn load(path: &Path) -> Option> { + let json = fs::read_to_string(path).ok()?; + let value: serde_json::Value = serde_json::from_str(&json) + .inspect_err(|err| { + log::warn!("ignoring malformed {}: {err}", path.display()); + }) + .ok()?; + let items = value.get("sent").and_then(serde_json::Value::as_array)?; + let text = |item: &serde_json::Value, key: &str| { + item.get(key) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }; + Some( + items + .iter() + .filter_map(|item| { + Some(SentMessage { + subject: text(item, "subject")?, + to: text(item, "to")?, + cc: text(item, "cc")?, + sent_at: text(item, "sent_at")?, + list: text(item, "list"), + thread_message_id: text(item, "thread_message_id"), + }) + }) + .collect(), + ) +} + +fn save() { + let Some(path) = STORE_PATH.with_borrow(|path| path.clone()) else { + return; + }; + let sent: Vec<_> = SENT.with_borrow(|sent| { + sent.iter() + .map(|msg| { + serde_json::json!({ + "subject": msg.subject, + "to": msg.to, + "cc": msg.cc, + "sent_at": msg.sent_at, + "list": msg.list, + "thread_message_id": msg.thread_message_id, + }) + }) + .collect() + }); + let value = serde_json::json!({ "sent": sent }); + if let Err(err) = write_atomically(&path, &value.to_string()) { + log::error!("failed to save sent history to {}: {err}", path.display()); + } +} + +/// Write via a temp file and rename so a crash mid-write can't truncate +/// the store. +fn write_atomically(path: &Path, contents: &str) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir)?; + } + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, contents)?; + fs::rename(&tmp, path) +} + +/// Record a successfully sent message. Call only after `git send-email` has +/// confirmed delivery (see `send::Outcome::Sent`) — this is a history, not a +/// send queue, so there is nothing to retry or undo here. +pub fn record(msg: SentMessage) { + SENT.with_borrow_mut(|sent| sent.push(msg)); + save(); +} + +/// Every sent message, most recent first. +pub fn all() -> Vec { + SENT.with_borrow(|sent| sent.iter().rev().cloned().collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn msg(subject: &str) -> SentMessage { + SentMessage { + subject: subject.to_string(), + to: "maintainer@example.org".to_string(), + cc: "list@vger.kernel.org".to_string(), + sent_at: "Thu, 3 Jul 2026 12:00:00 +0000".to_string(), + list: Some("rockchip".to_string()), + thread_message_id: Some("".to_string()), + } + } + + #[test] + fn all_is_empty_without_a_store() { + assert!(all().is_empty()); + } + + #[test] + fn record_appends_and_all_returns_most_recent_first() { + record(msg("[PATCH v1] first")); + record(msg("[PATCH v2] second")); + let sent = all(); + assert_eq!(sent.len(), 2); + assert_eq!(sent[0].subject, "[PATCH v2] second"); + assert_eq!(sent[1].subject, "[PATCH v1] first"); + } + + #[test] + fn record_keeps_every_send_even_with_the_same_subject() { + // Unlike favorites/subscriptions, history is not keyed or deduped - + // resending (e.g. a v2) is a distinct, equally real event. + record(msg("[PATCH] retry")); + record(msg("[PATCH] retry")); + assert_eq!(all().len(), 2); + } + + /// A scratch store file in a per-test temp dir; the dir is removed + /// when the guard drops. + struct ScratchStore { + dir: PathBuf, + } + + impl ScratchStore { + fn new(test_name: &str) -> Self { + let dir = + std::env::temp_dir().join(format!("koshi-{test_name}-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + Self { dir } + } + + fn path(&self) -> PathBuf { + self.dir.join("sent.json") + } + } + + impl Drop for ScratchStore { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } + } + + #[test] + fn sent_history_survives_a_reload() { + let store = ScratchStore::new("sent-survives-a-reload"); + init(store.path()); + record(msg("[PATCH] persisted")); + + SENT.set(Vec::new()); + init(store.path()); + + let sent = all(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].subject, "[PATCH] persisted"); + assert_eq!(sent[0].to, "maintainer@example.org"); + assert_eq!(sent[0].list.as_deref(), Some("rockchip")); + assert_eq!(sent[0].thread_message_id.as_deref(), Some("")); + } + + #[test] + fn a_from_scratch_compose_has_no_thread_to_link_back_to() { + let store = ScratchStore::new("sent-no-thread"); + init(store.path()); + record(SentMessage { + list: None, + thread_message_id: None, + ..msg("[ANNOUNCE] something") + }); + let sent = all(); + assert!(sent[0].list.is_none()); + assert!(sent[0].thread_message_id.is_none()); + } + + #[test] + fn malformed_store_file_starts_empty_and_is_replaced_on_save() { + let store = ScratchStore::new("sent-malformed-store"); + fs::write(store.path(), "not json").unwrap(); + init(store.path()); + assert!(all().is_empty()); + + record(msg("[PATCH] after malformed")); + SENT.set(Vec::new()); + init(store.path()); + assert_eq!(all().len(), 1); + } + + #[test] + fn missing_store_file_starts_empty() { + let store = ScratchStore::new("sent-missing-store"); + init(store.path()); + assert!(all().is_empty()); + } + + #[test] + fn entries_missing_required_fields_are_skipped_not_fatal() { + let store = ScratchStore::new("sent-entries-missing-fields"); + fs::write( + store.path(), + r#"{"sent": [{"subject": "no to/cc/sent_at"}, {"subject": "ok", "to": "a@b", "cc": "", "sent_at": "now"}]}"#, + ) + .unwrap(); + init(store.path()); + assert_eq!(all().len(), 1, "the incomplete entry must be dropped"); + assert_eq!(all()[0].subject, "ok"); + } +} diff --git a/src/sent_page.rs b/src/sent_page.rs new file mode 100644 index 0000000..7d9eea4 --- /dev/null +++ b/src/sent_page.rs @@ -0,0 +1,87 @@ +use adw::prelude::*; +use gtk::glib; + +use crate::sent::{self, SentMessage}; +use crate::thread_page::build_thread_page; + +pub const SENT_TITLE: &str = "Sent"; + +/// Widget name marking the sent page, matching how [`crate::favorites_page`] +/// marks its own — recognizable without comparing titles or navigation tags. +pub const SENT_PAGE_NAME: &str = "koshi-sent-page"; + +pub fn build_sent_page(nav: &adw::NavigationView) -> adw::NavigationPage { + let sent = sent::all(); + + // HIG placeholder-page pattern: an empty view gets a symbolic + // AdwStatusPage instead of an empty list. + let page = if sent.is_empty() { + let status = adw::StatusPage::builder() + .icon_name("mail-reply-sender-symbolic") + .title("No Sent Messages") + .description("Replies you send are logged here") + .build(); + adw::NavigationPage::new(&status, SENT_TITLE) + } else { + let list = build_sent_list(nav, sent); + crate::list_page::build_list_page( + SENT_TITLE, + SENT_TITLE, + "Replies you've sent, most recent first", + &[], + &list, + ) + }; + page.set_widget_name(SENT_PAGE_NAME); + page +} + +fn build_sent_list(nav: &adw::NavigationView, sent: Vec) -> gtk::ListBox { + let list = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .build(); + for msg in &sent { + list.append(&build_sent_row(msg)); + } + list.connect_row_activated(glib::clone!( + #[weak] + nav, + move |_, row| { + let msg = &sent[row.index() as usize]; + // Only a reply to a thread Koshi had open can be reopened this + // way; a from-scratch compose has nowhere to go back to. + if let (Some(list_slug), Some(message_id)) = + (msg.list.as_deref(), msg.thread_message_id.as_deref()) + { + nav.push(&build_thread_page(&nav, list_slug, message_id)); + } + } + )); + list +} + +fn build_sent_row(msg: &SentMessage) -> adw::ActionRow { + let has_thread = msg.list.is_some() && msg.thread_message_id.is_some(); + let row = adw::ActionRow::builder() + .title(format!( + "{}", + glib::markup_escape_text(&msg.subject) + )) + .title_lines(1) + .subtitle(glib::markup_escape_text(&msg.to)) + .subtitle_lines(1) + .tooltip_text(&msg.subject) + .activatable(has_thread) + .build(); + row.add_suffix( + >k::Label::builder() + .label(&msg.sent_at) + .css_classes(["numeric", "caption", "dim-label"]) + .build(), + ); + if has_thread { + row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); + } + row +} diff --git a/src/settings.rs b/src/settings.rs index 49095f0..d3065fd 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -119,6 +119,114 @@ fn write_key(key: &str, new: serde_json::Value) { } } +/// The default, minimum, and maximum body text size, in pixels. The default +/// matches the size GTK's own monospace font stack renders at; the range +/// keeps the Preferences spinner from producing an unreadably small or +/// absurdly large body. +pub const DEFAULT_BODY_FONT_SIZE: u32 = 13; +pub const MIN_BODY_FONT_SIZE: u32 = 8; +pub const MAX_BODY_FONT_SIZE: u32 = 32; + +/// The font size for message bodies (reading pane and composer), in pixels. +/// Defaults to [`DEFAULT_BODY_FONT_SIZE`] and is clamped to the spinner's +/// range so a hand-edited store can't request an unusable size. +pub fn body_font_size() -> u32 { + let stored = STORE_PATH + .with_borrow(|path| path.clone()) + .and_then(|path| fs::read_to_string(&path).ok()) + .and_then(|json| serde_json::from_str::(&json).ok()) + .as_ref() + .and_then(|value| value.get("bodyFontSize")) + .and_then(serde_json::Value::as_u64); + match stored { + Some(size) => (size as u32).clamp(MIN_BODY_FONT_SIZE, MAX_BODY_FONT_SIZE), + None => DEFAULT_BODY_FONT_SIZE, + } +} + +/// Persist the body font size, preserving any other settings already in the +/// file. +pub fn set_body_font_size(px: u32) { + write_key("bodyFontSize", serde_json::Value::from(px)); +} + +/// The default, minimum, and maximum interface text scale, as a percentage of +/// the system's own default size. Unlike [`DEFAULT_BODY_FONT_SIZE`] (a fixed +/// pixel size for message bodies only), this scales every label, button and +/// menu in the app, so it is expressed relative to whatever the system +/// default already is rather than an absolute size. +pub const DEFAULT_UI_TEXT_SCALE: u32 = 100; +pub const MIN_UI_TEXT_SCALE: u32 = 50; +pub const MAX_UI_TEXT_SCALE: u32 = 200; + +/// The interface text scale, as a percentage. Defaults to +/// [`DEFAULT_UI_TEXT_SCALE`] (unchanged) and is clamped to the spinner's +/// range so a hand-edited store can't request an unusable scale. +pub fn ui_text_scale() -> u32 { + let stored = STORE_PATH + .with_borrow(|path| path.clone()) + .and_then(|path| fs::read_to_string(&path).ok()) + .and_then(|json| serde_json::from_str::(&json).ok()) + .as_ref() + .and_then(|value| value.get("uiTextScale")) + .and_then(serde_json::Value::as_u64); + match stored { + Some(scale) => (scale as u32).clamp(MIN_UI_TEXT_SCALE, MAX_UI_TEXT_SCALE), + None => DEFAULT_UI_TEXT_SCALE, + } +} + +/// Persist the interface text scale, preserving any other settings already in +/// the file. +pub fn set_ui_text_scale(percent: u32) { + write_key("uiTextScale", serde_json::Value::from(percent)); +} + +/// The user's chosen color scheme: follow the system, or always light/dark +/// regardless of it. Stored as one of these exact strings; anything else +/// (absent, or a hand-edited store with a stray value) reads back as +/// [`Theme::System`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Theme { + #[default] + System, + Light, + Dark, +} + +impl Theme { + fn as_str(self) -> &'static str { + match self { + Theme::System => "system", + Theme::Light => "light", + Theme::Dark => "dark", + } + } + + fn from_str(value: &str) -> Self { + match value { + "light" => Theme::Light, + "dark" => Theme::Dark, + _ => Theme::System, + } + } +} + +/// The user's chosen theme. Defaults to [`Theme::System`]. +pub fn theme() -> Theme { + read_key("theme") + .and_then(|value| value.as_str().map(Theme::from_str)) + .unwrap_or_default() +} + +/// Persist the theme, preserving any other settings already in the file. +pub fn set_theme(theme: Theme) { + write_key( + "theme", + serde_json::Value::String(theme.as_str().to_owned()), + ); +} + /// Koshi's default gap between subscription polls, in minutes. pub const DEFAULT_POLL_INTERVAL_MINUTES: u32 = 5; @@ -265,6 +373,77 @@ mod tests { assert!(send_user_agent()); } + #[test] + fn body_font_size_defaults_without_a_store() { + assert_eq!(body_font_size(), DEFAULT_BODY_FONT_SIZE); + } + + #[test] + fn body_font_size_survives_a_reload() { + let store = ScratchStore::new("settings-font-size"); + init(store.path()); + set_body_font_size(18); + assert_eq!(body_font_size(), 18); + } + + #[test] + fn body_font_size_is_clamped_to_the_spinner_range() { + let store = ScratchStore::new("settings-font-size-clamp"); + init(store.path()); + fs::write(store.path(), r#"{"bodyFontSize": 1}"#).unwrap(); + assert_eq!(body_font_size(), MIN_BODY_FONT_SIZE); + fs::write(store.path(), r#"{"bodyFontSize": 999}"#).unwrap(); + assert_eq!(body_font_size(), MAX_BODY_FONT_SIZE); + } + + #[test] + fn theme_defaults_to_system_without_a_store() { + assert_eq!(theme(), Theme::System); + } + + #[test] + fn theme_survives_a_reload() { + let store = ScratchStore::new("settings-theme"); + init(store.path()); + set_theme(Theme::Dark); + assert_eq!(theme(), Theme::Dark); + set_theme(Theme::Light); + assert_eq!(theme(), Theme::Light); + set_theme(Theme::System); + assert_eq!(theme(), Theme::System); + } + + #[test] + fn theme_falls_back_to_system_for_a_stray_value() { + let store = ScratchStore::new("settings-theme-stray"); + init(store.path()); + fs::write(store.path(), r#"{"theme": "purple"}"#).unwrap(); + assert_eq!(theme(), Theme::System); + } + + #[test] + fn ui_text_scale_defaults_without_a_store() { + assert_eq!(ui_text_scale(), DEFAULT_UI_TEXT_SCALE); + } + + #[test] + fn ui_text_scale_survives_a_reload() { + let store = ScratchStore::new("settings-ui-scale"); + init(store.path()); + set_ui_text_scale(125); + assert_eq!(ui_text_scale(), 125); + } + + #[test] + fn ui_text_scale_is_clamped_to_the_spinner_range() { + let store = ScratchStore::new("settings-ui-scale-clamp"); + init(store.path()); + fs::write(store.path(), r#"{"uiTextScale": 1}"#).unwrap(); + assert_eq!(ui_text_scale(), MIN_UI_TEXT_SCALE); + fs::write(store.path(), r#"{"uiTextScale": 999}"#).unwrap(); + assert_eq!(ui_text_scale(), MAX_UI_TEXT_SCALE); + } + #[test] fn poll_interval_defaults_without_a_store() { assert_eq!(poll_interval_minutes(), DEFAULT_POLL_INTERVAL_MINUTES); diff --git a/src/thread_page.rs b/src/thread_page.rs index 66d9c95..b6cd91d 100644 --- a/src/thread_page.rs +++ b/src/thread_page.rs @@ -168,6 +168,9 @@ impl MessageRow { // Counted once here so the reseed walk stays O(1) per row: bodies // run to megabytes and reseed is called on every row of every pass. imp.body_lines.set(mail.body.lines().count().max(1) as i32); + let (quote_lines, diff_lines) = highlight::foldable_line_counts(&mail.body); + imp.quote_lines.set(quote_lines as i32); + imp.diff_lines.set(diff_lines as i32); *imp.mail.borrow_mut() = Some(mail); // Seed the row's height before any of its widgetry exists: the @@ -190,14 +193,68 @@ impl MessageRow { return; } let (unit, header) = key; - let height = imp - .body_lines - .get() - .max(1) + self.set_size_request(-1, self.compute_height(unit, header)); + } + + /// The row height for `unit`/`header` line/header estimates, minus + /// whatever is currently folded away. A folded-and-quoted diff line is + /// subtracted once per active fold, which can under-estimate slightly — + /// harmless, since this only seeds height ahead of realization (see + /// reseed's doc comment); once laid out, natural sizing takes over. + fn compute_height(&self, unit: i32, header: i32) -> i32 { + let imp = self.imp(); + let hidden = if imp.quote_folded.get() { + imp.quote_lines.get() + } else { + 0 + } + if imp.diff_folded.get() { + imp.diff_lines.get() + } else { + 0 + }; + let visible_lines = (imp.body_lines.get() - hidden).max(1); + visible_lines .saturating_mul(unit) .saturating_add(header) - .saturating_add(ROW_CHROME_HEIGHT); - self.set_size_request(-1, height); + .saturating_add(ROW_CHROME_HEIGHT) + } + + /// Recompute and apply this row's height after a fold toggle, without + /// re-measuring the (unchanged) global line/header estimates reseed uses. + fn apply_fold_height(&self) { + let imp = self.imp(); + if imp.mail.borrow().is_none() { + return; + } + let (unit, header) = imp.seed_key.get(); + self.set_size_request(-1, self.compute_height(unit, header)); + } + + /// Fold or unfold quoted text in this row's body and resize to fit. + fn set_quote_folded(&self, folded: bool) { + let imp = self.imp(); + imp.quote_folded.set(folded); + if let Some(view) = imp.view.get() { + highlight::set_quote_folded(&view.buffer(), folded); + } + update_fold_label( + imp.quote_toggle.get(), + "quoted text", + imp.quote_lines.get(), + folded, + ); + self.apply_fold_height(); + } + + /// Fold or unfold diff hunks in this row's body and resize to fit. + fn set_diff_folded(&self, folded: bool) { + let imp = self.imp(); + imp.diff_folded.set(folded); + if let Some(view) = imp.view.get() { + highlight::set_diff_folded(&view.buffer(), folded); + } + update_fold_label(imp.diff_toggle.get(), "diff", imp.diff_lines.get(), folded); + self.apply_fold_height(); } fn is_filled(&self) -> bool { @@ -237,6 +294,28 @@ impl MessageRow { if filled { let view = imp.ensure_view().clone(); view.buffer().set_text(&mail.body); + // The buffer (and its tag table) is reused across rebinds, so a + // previous message's fold state must not leak into this one - a + // fresh message always opens fully expanded. + highlight::set_quote_folded(&view.buffer(), false); + highlight::set_diff_folded(&view.buffer(), false); + imp.quote_folded.set(false); + imp.diff_folded.set(false); + let quote_lines = imp.quote_lines.get(); + let diff_lines = imp.diff_lines.get(); + if let Some(toggle) = imp.quote_toggle.get() { + toggle.set_visible(quote_lines > 0); + toggle.set_active(false); + update_fold_label(Some(toggle), "quoted text", quote_lines, false); + } + if let Some(toggle) = imp.diff_toggle.get() { + toggle.set_visible(diff_lines > 0); + toggle.set_active(false); + update_fold_label(Some(toggle), "diff", diff_lines, false); + } + if let Some(fold_row) = imp.fold_row.get() { + fold_row.set_visible(quote_lines > 0 || diff_lines > 0); + } // First body anywhere: learn the real line height so every seed // from here on is exact. Measured as the advance between a one- @@ -278,6 +357,9 @@ impl MessageRow { view.buffer().set_text(""); } self.remove_header(); + if let Some(fold_row) = imp.fold_row.get() { + fold_row.set_visible(false); + } } imp.filled.set(filled); imp.highlighted.set(false); @@ -294,9 +376,10 @@ impl MessageRow { return; }; let composer = imp.composer.get().expect("MessageRow composer set"); + let list_slug = imp.list.get().expect("MessageRow list set"); let is_op = imp.is_op.get(); - let header = build_header_list(mail, &overlay, composer); + let header = build_header_list(mail, &overlay, composer, list_slug); // Selectable header labels replace right-clicks with their own stock // menu, shadowing the row's; hand them the mail actions as an extra // section. The action names resolve against the "mailview" group the @@ -361,6 +444,18 @@ impl MessageRow { } } +/// Set a fold toggle's label to reflect its current state: what folding it +/// would hide (with a count) while expanded, that it is currently hidden +/// while folded. A no-op if the toggle was never built (row not yet filled). +fn update_fold_label(button: Option<>k::ToggleButton>, what: &str, lines: i32, folded: bool) { + let Some(button) = button else { return }; + if folded { + button.set_label(&format!("Show {what} ({lines} lines hidden)")); + } else { + button.set_label(&format!("Hide {what}")); + } +} + mod imp { use std::cell::{Cell, OnceCell, RefCell}; use std::rc::Rc; @@ -426,6 +521,18 @@ mod imp { /// computed with, so reseed is a cheap no-op while the estimates /// are unchanged. pub(super) seed_key: Cell<(i32, i32)>, + /// The row holding the "Quoted text" / "Diff" fold toggles, hidden + /// when the bound message has neither. + pub(super) fold_row: OnceCell, + pub(super) quote_toggle: OnceCell, + pub(super) diff_toggle: OnceCell, + /// Distinct quoted/diff line counts for the bound message, from + /// [`highlight::foldable_line_counts`] - used both to decide whether + /// to show each fold toggle and to size the row while folded. + pub(super) quote_lines: Cell, + pub(super) diff_lines: Cell, + pub(super) quote_folded: Cell, + pub(super) diff_folded: Cell, } #[glib::object_subclass] @@ -466,6 +573,7 @@ mod imp { .right_margin(12) .top_margin(12) .bottom_margin(12) + .css_classes(["koshi-body-text"]) .build(); // Bodies don't wrap (patches carry deliberately long lines), so a @@ -478,6 +586,41 @@ mod imp { .propagate_natural_height(true) .build(); + // Quote/diff fold toggles: message-independent widgetry built + // once, like the view above; fill_header shows/hides and resets + // them per message since a recycled row can go from a message + // with a huge patch to one with none. + let quote_toggle = gtk::ToggleButton::builder() + .css_classes(["flat"]) + .halign(gtk::Align::Start) + .visible(false) + .build(); + let diff_toggle = gtk::ToggleButton::builder() + .css_classes(["flat"]) + .halign(gtk::Align::Start) + .visible(false) + .build(); + let fold_row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(6) + .visible(false) + .build(); + fold_row.append("e_toggle); + fold_row.append(&diff_toggle); + + let row_weak = self.obj().downgrade(); + quote_toggle.connect_toggled(move |button| { + if let Some(row) = row_weak.upgrade() { + row.set_quote_folded(button.is_active()); + } + }); + let row_weak = self.obj().downgrade(); + diff_toggle.connect_toggled(move |button| { + if let Some(row) = row_weak.upgrade() { + row.set_diff_folded(button.is_active()); + } + }); + // The column the fill mounts the header card into, above the // body. Its top margin and spacing are part of the row height // seed (ROW_CHROME_HEIGHT) — keep them in step. @@ -486,7 +629,11 @@ mod imp { .spacing(12) .margin_top(12) .build(); + content.append(&fold_row); content.append(&hscroll); + self.fold_row.set(fold_row).ok(); + self.quote_toggle.set(quote_toggle).ok(); + self.diff_toggle.set(diff_toggle).ok(); // The ListView is the ScrolledWindow's scrollable child (so it can // virtualize), which means the reading-width clamp lives per row @@ -793,7 +940,26 @@ fn parse_thread(mbox: &[u8]) -> Vec { .iter() .map(|raw| parse_message(&unescape_mboxrd(raw))) .collect(); - in_thread_order(mails) + in_thread_order(dedup_by_message_id(mails)) +} + +/// Drop repeated copies of the same message, keeping the first. lore mirrors +/// a message once per mailing list it was posted to, so a thread cross-posted +/// to more than one list can come back from `t.mbox.gz` with the same +/// Message-ID twice - which would otherwise show up as a genuinely +/// duplicated row in both the thread view and the overview sidebar, and (via +/// [`thread_message_digests`], which also goes through this function) as a +/// duplicate notification from the watcher. +fn dedup_by_message_id(mails: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + mails + .into_iter() + .filter(|mail| match &mail.message_id { + Some(id) => seen.insert(id.trim().trim_matches(['<', '>']).to_string()), + // No Message-ID to key on - never dedupe what we cannot compare. + None => true, + }) + .collect() } /// The minimum the subscription watcher needs from a fetched thread: each @@ -1291,7 +1457,7 @@ fn build_thread_content( let overlay = adw::ToastOverlay::new(); // The composer opens targeting the OP; each mail's Reply button can // retarget it later. - let composer = composer::build_composer(build_reply_context(op)); + let composer = composer::build_composer(build_reply_context(op, list)); // Favorites toggled through the header star and through a message's // context menu must agree; the hub keeps every view of a Message-ID in @@ -1331,12 +1497,12 @@ fn build_thread_content( let replies: Rc> = Rc::new( thread .iter() - .map(|mail| build_reply_context(mail)) + .map(|mail| build_reply_context(mail, list)) .collect(), ); let op_fav = favorite_of(op); let op_sub = subscription_of(op); - let op_reply = build_reply_context(op); + let op_reply = build_reply_context(op, list); // The message single view currently shows; the star, Reply and overview // highlight all track it. Single view is the default and opens on `opened`. let single_shown = Rc::new(Cell::new(opened)); @@ -3093,19 +3259,19 @@ fn add_label_extra_menus(widget: >k::Widget, menu: &gio::Menu) { /// Reply prefill: To = the author, Cc = everyone else on the thread, /// Re:-prefixed subject and the mail's Message-ID for threading. Parsed /// address lists are preferred; raw header values are the fallback. -fn build_reply_context(mail: &Mail) -> composer::ReplyContext { +fn build_reply_context(mail: &Mail, list: &str) -> composer::ReplyContext { // Cc the sender to themselves unless they have turned it off, so a copy of // the reply lands in their own mailbox. let cc_self = settings::cc_self() .then(|| profile::cached().sender_header()) .flatten(); - reply_context(mail, cc_self.as_deref()) + reply_context(mail, cc_self.as_deref(), list) } /// The pure core of [`build_reply_context`]: given the optional address to Cc /// the sender at, build the reply prefill. Split out so recipient handling is /// unit-testable without touching git config or the settings store. -fn reply_context(mail: &Mail, cc_self: Option<&str>) -> composer::ReplyContext { +fn reply_context(mail: &Mail, cc_self: Option<&str>, list: &str) -> composer::ReplyContext { let mut cc: Vec = Vec::new(); if mail.to_addrs.is_empty() { cc.push(mail.to.clone()); @@ -3130,6 +3296,11 @@ fn reply_context(mail: &Mail, cc_self: Option<&str>) -> composer::ReplyContext { subject: composer::reply_subject(&mail.subject), in_reply_to: mail.message_id.clone().unwrap_or_default(), references: mail.references.clone().unwrap_or_default(), + // Any Message-ID in the thread reopens the same thread (Koshi always + // fetches a whole thread by any one of its messages), so the mail + // being replied to works as well as the thread root would. + list: Some(list.to_string()), + thread_message_id: mail.message_id.clone(), } } @@ -3533,14 +3704,14 @@ pub(crate) fn add_star_and_bell( } /// A flat Reply icon button that retargets the composer to `mail`. -fn build_reply_button(mail: &Mail, composer: &composer::Composer) -> gtk::Button { +fn build_reply_button(mail: &Mail, composer: &composer::Composer, list_slug: &str) -> gtk::Button { let button = gtk::Button::builder() .icon_name("mail-reply-sender-symbolic") .tooltip_text("Reply") .valign(gtk::Align::Center) .css_classes(["flat"]) .build(); - let reply = build_reply_context(mail); + let reply = build_reply_context(mail, list_slug); button.connect_clicked(glib::clone!( #[strong] composer, @@ -3558,6 +3729,7 @@ fn build_header_list( mail: &Mail, overlay: &adw::ToastOverlay, composer: &composer::Composer, + list_slug: &str, ) -> gtk::ListBox { let list = gtk::ListBox::builder() .selection_mode(gtk::SelectionMode::None) @@ -3583,7 +3755,7 @@ fn build_header_list( let subject_row = build_single_line_row("Subject", &mail.subject, &visible_titles); subject_row.set_tooltip_text(Some(&mail.subject)); if let Some(content) = subject_row.child().and_downcast::() { - content.append(&build_reply_button(mail, composer)); + content.append(&build_reply_button(mail, composer, list_slug)); } list.append(&subject_row); @@ -3778,7 +3950,7 @@ fn build_mail_actions( list: &str, ) -> [gio::SimpleAction; 4] { let reply = gio::SimpleAction::new("reply", None); - let reply_context = build_reply_context(mail); + let reply_context = build_reply_context(mail, list); reply.connect_activate(glib::clone!( #[strong] composer, @@ -3946,7 +4118,7 @@ mod tests { #[test] fn reply_context_targets_the_clicked_message() { let thread = parse_thread(RAW_THREAD); - let reply = reply_context(&thread[1], None); + let reply = reply_context(&thread[1], None, "lkml"); assert_eq!(reply.to, "sashiko-bot@kernel.org"); assert!(reply.cc.contains("Linus Walleij ")); assert!(reply.cc.contains("linux-watchdog@vger.kernel.org")); @@ -3958,16 +4130,19 @@ mod tests { reply.in_reply_to, "<20260619204041.040D71F000E9@smtp.kernel.org>" ); + // The list carries through so a sent reply can reopen the thread. + assert_eq!(reply.list.as_deref(), Some("lkml")); + assert_eq!(reply.thread_message_id, thread[1].message_id); } #[test] fn reply_context_adds_self_to_cc_once() { let to = mail("t@x", None, "Subj", "Author "); // A brand-new address is appended to Cc. - let with_self = reply_context(&to, Some("Me ")); + let with_self = reply_context(&to, Some("Me "), "lkml"); assert!(with_self.cc.contains("Me ")); // Turning it off leaves Cc without it. - let without = reply_context(&to, None); + let without = reply_context(&to, None, "lkml"); assert!(!without.cc.contains("me@x")); } @@ -3976,7 +4151,7 @@ mod tests { // Replying to yourself: self is the reply target, so it must not also // appear in Cc. let own = mail("t@x", None, "Subj", "Me "); - let reply = reply_context(&own, Some("Me ")); + let reply = reply_context(&own, Some("Me "), "lkml"); assert_eq!(reply.to, "Me "); assert!(!reply.cc.contains("me@x")); } @@ -4030,6 +4205,34 @@ mod tests { assert!(thread_message_digests(mbox).is_empty()); } + #[test] + fn a_message_id_repeated_in_the_mbox_is_only_kept_once() { + // lore mirrors a message once per mailing list it was cross-posted + // to, so a thread spanning more than one list can come back from + // t.mbox.gz with the exact same Message-ID twice - both in + // parse_thread (a duplicated row in the thread view) and, since + // thread_message_digests goes through it too, as a duplicate + // notification from the watcher. + let mbox = b"From a@b Thu Jan 1 00:00:00 1970\n\ + From: Nika Krasnova \n\ + Subject: bleh\n\ + Message-ID: \n\n\ + body\n\ + From a@b Thu Jan 1 00:00:00 1970\n\ + From: Nika Krasnova \n\ + Subject: bleh\n\ + Message-ID: \n\n\ + body\n\ + From c@d Thu Jan 1 00:00:00 1970\n\ + From: reply-guy@example.org\n\ + Subject: Re: bleh\n\ + Message-ID: \n\ + In-Reply-To: \n\n\ + ok\n"; + assert_eq!(parse_thread(mbox).len(), 2); + assert_eq!(thread_message_digests(mbox).len(), 2); + } + #[test] fn splits_on_separator_lines_only() { let mbox = b"From a@b Thu Jan 1 00:00:00 1970\nSubject: x\n\n>From escaped\n\ @@ -4116,6 +4319,28 @@ mod tests { } } + #[test] + fn dedup_by_message_id_keeps_only_the_first_copy() { + let mails = vec![ + mail("root@x", None, "bleh", "Nika"), + mail("root@x", None, "bleh", "Nika"), + mail("reply@x", Some("root@x"), "Re: bleh", "Miguel"), + ]; + let deduped = dedup_by_message_id(mails); + assert_eq!(deduped.len(), 2); + assert_eq!(deduped[0].message_id.as_deref(), Some("")); + assert_eq!(deduped[1].message_id.as_deref(), Some("")); + } + + #[test] + fn dedup_by_message_id_never_drops_messages_with_no_id_to_compare() { + let mut a = mail("root@x", None, "bleh", "Nika"); + a.message_id = None; + let mut b = mail("root@x", None, "bleh", "Nika"); + b.message_id = None; + assert_eq!(dedup_by_message_id(vec![a, b]).len(), 2); + } + #[test] fn tree_nests_replies_depth_first() { // op ─ a ─ c ─ d, and op ─ b: DFS must visit a's subtree before b. diff --git a/src/watcher.rs b/src/watcher.rs index db79f82..a8bde09 100644 --- a/src/watcher.rs +++ b/src/watcher.rs @@ -14,7 +14,9 @@ //! limiter trips, it answers *everything* with 503, including whatever page //! the user is trying to read. -use std::collections::{HashMap, HashSet}; +#[cfg(not(target_os = "macos"))] +use std::collections::HashMap; +use std::collections::HashSet; use adw::prelude::*; use gtk::{gio, glib}; @@ -47,6 +49,13 @@ const POLL_SPACING_SECONDS: u32 = 2; /// concurrent cycles onto lore. The interval is re-read every cycle, so a /// change in Preferences is honoured from the following one. pub fn start(app: &adw::Application) { + // Ask up front rather than on the first notification: addNotificationRequest + // fails outright while authorization is still undecided, so requesting only + // when a message actually arrives would routinely lose a fresh install's + // very first notification to that race. See the macos_notify module doc. + #[cfg(target_os = "macos")] + macos_notify::request_authorization(); + let weak = app.downgrade(); glib::spawn_future_local(async move { glib::timeout_future_seconds(STARTUP_POLL_DELAY_SECONDS).await; @@ -222,6 +231,11 @@ async fn poll_one(app: &adw::Application, subscription: Subscription) -> Result< /// `id` is the message's own Message-Id: the portal replaces a notification /// whose id it has already seen, so reusing it dedupes a message that somehow /// surfaces twice without coalescing distinct arrivals. +/// +/// macOS has no session D-Bus (and so no portal); [`macos_notify::notify`] +/// below raises the same notification through `UNUserNotificationCenter` +/// instead, the Cocoa-native equivalent. +#[cfg(not(target_os = "macos"))] fn notify_new_message(app: &adw::Application, id: &str, author: &str, subject: &str) { let Some(connection) = app.dbus_connection() else { log::warn!("no session bus; cannot notify about \"{subject}\""); @@ -246,9 +260,19 @@ fn notify_new_message(app: &adw::Application, id: &str, author: &str, subject: & ); } +/// See the doc comment on the non-macOS `notify_new_message` above; this is +/// its `UNUserNotificationCenter` counterpart. `app` is unused here — Cocoa's +/// notification center is a process-wide singleton, not reached through the +/// session bus connection the portal needs. +#[cfg(target_os = "macos")] +fn notify_new_message(_app: &adw::Application, id: &str, author: &str, subject: &str) { + macos_notify::notify(id, author, subject); +} + /// The `(sa{sv})` argument tuple for /// `org.freedesktop.portal.Notification.AddNotification`: the notification id /// and its property dictionary (title, body, icon). +#[cfg(not(target_os = "macos"))] fn add_notification_params(id: &str, author: &str, subject: &str) -> glib::Variant { let mut notification: HashMap<&str, glib::Variant> = HashMap::new(); notification.insert("title", author.to_variant()); @@ -263,7 +287,7 @@ fn add_notification_params(id: &str, author: &str, subject: &str) -> glib::Varia (id, notification).to_variant() } -#[cfg(test)] +#[cfg(all(test, not(target_os = "macos")))] mod tests { use super::*; @@ -286,3 +310,111 @@ mod tests { assert_eq!(icon.type_().as_str(), "(sv)"); } } + +/// `UNUserNotificationCenter` backend for macOS, which has no session D-Bus +/// (and so no XDG notification portal) for `notify_new_message` above to use. +/// +/// Notifications need per-app authorization. [`macos_notify::request_authorization`] +/// fires it once at startup ([`start`] calls it), so the system prompt has the +/// whole [`STARTUP_POLL_DELAY_SECONDS`] head start (and realistically much +/// longer, given typical poll intervals) to be answered before any message +/// actually needs to notify — `addNotificationRequest` fails outright for a +/// still-undecided authorization, so asking any later would routinely lose +/// the very first notification of a fresh install to that race. +/// [`macos_notify::notify`] requests again defensively (a no-op once already +/// requested) in case it is ever reached without `start` having run first. +#[cfg(target_os = "macos")] +mod macos_notify { + use std::cell::Cell; + + use objc2::runtime::Bool; + use objc2_foundation::{NSBundle, NSError, NSString}; + use objc2_user_notifications::{ + UNAuthorizationOptions, UNMutableNotificationContent, UNNotificationRequest, + UNUserNotificationCenter, + }; + + thread_local! { + /// Whether authorization has been requested this run yet. + static AUTHORIZATION_REQUESTED: Cell = const { Cell::new(false) }; + /// The last known answer: `None` until the (async) system prompt has + /// actually been answered, then whatever it decided. Preferences + /// reads this to warn the user when it is `Some(false)` - the prompt + /// only ever appears once per install, so a denial is otherwise + /// invisible short of checking System Settings directly. + static AUTHORIZATION_GRANTED: Cell> = const { Cell::new(None) }; + } + + /// Ask the user to allow notifications, if this run has not already + /// asked. A no-op outside an app bundle, for the same reason [`notify`] + /// skips there — see its doc comment. + pub(super) fn request_authorization() { + if NSBundle::mainBundle().bundleIdentifier().is_none() { + return; + } + if AUTHORIZATION_REQUESTED.replace(true) { + return; + } + let center = UNUserNotificationCenter::currentNotificationCenter(); + let options = UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound; + let handler = block2::RcBlock::new(|granted: Bool, _error: *mut NSError| { + let granted = granted.as_bool(); + AUTHORIZATION_GRANTED.set(Some(granted)); + if !granted { + log::warn!("notification authorization was not granted"); + } + }); + center.requestAuthorizationWithOptions_completionHandler(options, &handler); + } + + /// Whether the system prompt has been answered *and* the answer was no - + /// `false` both when it was granted and while it is still undecided (the + /// prompt is asynchronous; the answer may not be in yet), so a caller + /// checking this to decide whether to show a warning never flashes one + /// before startup's authorization request has had a chance to resolve. + pub(super) fn is_denied() -> bool { + AUTHORIZATION_GRANTED.get() == Some(false) + } + + pub(super) fn notify(id: &str, author: &str, subject: &str) { + // UNUserNotificationCenter reads the process's own bundle identifier + // internally and *aborts the whole process* (an uncaught + // NSInternalInconsistencyException, not a Rust-catchable error) when + // there isn't one - true for `cargo run`/`cargo test`, which run the + // bare binary outside any .app bundle. Notifications only work from + // the packaged .app (see build-aux/macos/bundle.sh); anywhere else, + // skip with a log line instead of taking the process down. + if NSBundle::mainBundle().bundleIdentifier().is_none() { + log::warn!("not running inside an app bundle; cannot notify about \"{subject}\""); + return; + } + request_authorization(); + + let center = UNUserNotificationCenter::currentNotificationCenter(); + let content = UNMutableNotificationContent::new(); + content.setTitle(&NSString::from_str(author)); + content.setBody(&NSString::from_str(subject)); + + let request = UNNotificationRequest::requestWithIdentifier_content_trigger( + &NSString::from_str(id), + &content, + None, + ); + + let subject = subject.to_string(); + let completion = block2::RcBlock::new(move |error: *mut NSError| { + if !error.is_null() { + log::warn!("notification failed for \"{subject}\""); + } + }); + center.addNotificationRequest_withCompletionHandler(&request, Some(&completion)); + } +} + +/// Whether the user has explicitly declined the macOS notification +/// authorization prompt, for Preferences to surface a warning — see +/// [`macos_notify::is_denied`] for exactly what this does and does not mean. +#[cfg(target_os = "macos")] +pub fn notifications_denied() -> bool { + macos_notify::is_denied() +}