From d62ada9f5b1d665d48bb94ec28f853964b913b7c Mon Sep 17 00:00:00 2001 From: devin Date: Wed, 11 Feb 2026 12:44:13 -0600 Subject: [PATCH] Improve Linux accessibility and cross-platform navigation support --- .github/workflows/build.yml | 201 +++++++++++++++++---------- README.md | 12 +- po/paperback.pot | 22 ++- src/config.rs | 45 ++++++- src/ipc.rs | 5 +- src/main.rs | 2 +- src/parser/path.rs | 13 +- src/ui.rs | 1 + src/ui/accessibility.rs | 75 +++++++++++ src/ui/document_manager.rs | 261 +++++++++++++++++++++++++++++++++++- src/ui/find.rs | 6 +- src/ui/help.rs | 31 ++++- src/ui/main_window.rs | 9 +- src/ui/navigation.rs | 46 +++++-- src/ui/tray.rs | 2 +- src/update.rs | 193 ++++++++++++++++++++++++-- web/js/downloads.js | 50 ++++++- xtask/src/main.rs | 10 +- 18 files changed, 855 insertions(+), 129 deletions(-) create mode 100644 src/ui/accessibility.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 86796d7a..a2c36b2a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,76 +8,133 @@ permissions: contents: write jobs: build: - runs-on: windows-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + artifact_name: paperback-windows + artifact_paths: | + target/release/paperback_windows.zip + target/release/paperback_setup.exe + - os: ubuntu-latest + artifact_name: paperback-linux + artifact_paths: target/release/paperback_linux.zip + - os: macos-latest + artifact_name: paperback-macos + artifact_paths: target/release/paperback_mac.zip steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - submodules: recursive - fetch-depth: 0 - - name: Setup MSVC - uses: ilammy/msvc-dev-cmd@v1 - with: - arch: x64 - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - toolchain: stable - components: rustfmt, clippy - - name: Install CMake, Ninja, and Pandoc - run: | - choco install cmake ninja pandoc -y - - name: Install Gettext via MSYS2 - uses: msys2/setup-msys2@v2 - with: - msystem: UCRT64 - update: true - install: >- - gettext - mingw-w64-ucrt-x86_64-gettext-tools - - name: Build - run: | - $env:PATH = "$env:PATH;C:\msys64\ucrt64\bin;C:\msys64\usr\bin" - cargo release - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: paperback-build - path: | - target/release/paperback.zip - target/release/paperback_setup.exe - retention-days: 30 - - name: Get latest tag reachable from HEAD - id: get_tag - shell: bash - run: | - TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - echo "tag=$TAG" >> $GITHUB_OUTPUT - - name: Generate release notes - id: release_notes - shell: bash - run: | - PREV_TAG="${{ steps.get_tag.outputs.tag }}" - if [ -z "$PREV_TAG" ]; then - COMMITS=$(git log -1 --pretty=format:"- %h: %s" HEAD) - else - COMMITS=$(git log --pretty=format:"- %h: %s" "$PREV_TAG"..HEAD) - fi - COMMITS="${COMMITS//'%'/'%25'}" - echo "commits<> $GITHUB_OUTPUT - echo "$COMMITS" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - name: Create or update latest release - uses: softprops/action-gh-release@v1 - with: - tag_name: latest - name: Development Build - body: | - ## Commits since last release - ${{ steps.release_notes.outputs.commits }} - files: | - target/release/paperback.zip - target/release/paperback_setup.exe - prerelease: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + + - name: Setup MSVC + if: matrix.os == 'windows-latest' + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + components: rustfmt, clippy + + - name: Install Windows build dependencies + if: matrix.os == 'windows-latest' + run: | + choco install cmake ninja pandoc -y + + - name: Install Gettext via MSYS2 + if: matrix.os == 'windows-latest' + uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: true + install: >- + gettext + mingw-w64-ucrt-x86_64-gettext-tools + + - name: Install Linux build dependencies + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y cmake ninja-build pandoc gettext pkg-config libgtk-3-dev libexpat1-dev libtiff-dev + sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev + + - name: Install macOS build dependencies + if: matrix.os == 'macos-latest' + run: | + brew install cmake ninja pandoc gettext + echo "$(brew --prefix gettext)/bin" >> "$GITHUB_PATH" + + - name: Build (Windows) + if: matrix.os == 'windows-latest' + run: | + $env:PATH = "$env:PATH;C:\msys64\ucrt64\bin;C:\msys64\usr\bin" + cargo release + + - name: Build (Linux/macOS) + if: matrix.os != 'windows-latest' + run: cargo release + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: ${{ matrix.artifact_paths }} + retention-days: 30 + + release: + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + pattern: paperback-* + path: dist + merge-multiple: true + + - name: Get latest tag reachable from HEAD + id: get_tag + shell: bash + run: | + TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + echo "tag=$TAG" >> $GITHUB_OUTPUT + + - name: Generate release notes + id: release_notes + shell: bash + run: | + PREV_TAG="${{ steps.get_tag.outputs.tag }}" + if [ -z "$PREV_TAG" ]; then + COMMITS=$(git log -1 --pretty=format:"- %h: %s" HEAD) + else + COMMITS=$(git log --pretty=format:"- %h: %s" "$PREV_TAG"..HEAD) + fi + COMMITS="${COMMITS//'%'/'%25'}" + echo "commits<> $GITHUB_OUTPUT + echo "$COMMITS" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create or update latest release + uses: softprops/action-gh-release@v1 + with: + tag_name: latest + name: Development Build + body: | + ## Commits since last release + ${{ steps.release_notes.outputs.commits }} + files: dist/** + prerelease: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 56cb6fd8..96fc992c 100644 --- a/README.md +++ b/README.md @@ -19,16 +19,24 @@ To build, you'll need cargo, as well as CMake and Ninja for building wxDragon. -```batch +On Linux, you will also need clang/libclang (for bindgen), GTK3, WebKitGTK, Expat, and TIFF development packages (for example `clang`, `libclang-dev`, `libgtk-3-dev`, `libwebkit2gtk-4.1-dev`, `libexpat1-dev`, and `libtiff-dev` on Debian/Ubuntu). + +```bash cargo build --release ``` to generate the binary in the release folder, and -```batch +```bash cargo release ``` +`cargo release` produces platform-specific archives: + +* Windows: `paperback_windows.zip` and `paperback_setup.exe` +* Linux: `paperback_linux.zip` +* macOS: `paperback_mac.zip` + ### Optional tools: The following tools aren't required to build a functioning Paperback on a basic level, but will help you make a complete release build. diff --git a/po/paperback.pot b/po/paperback.pot index 8144df06..e9b676ba 100644 --- a/po/paperback.pot +++ b/po/paperback.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: paperback 0.8.0\n" "Report-Msgid-Bugs-To: https://github.com/trypsynth/paperback/issues\n" -"POT-Creation-Date: 2026-02-07 11:18-0700\n" +"POT-Creation-Date: 2026-02-11 12:18-0600\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -297,6 +297,9 @@ msgstr "" msgid "Ready" msgstr "" +msgid "File menu" +msgstr "" + msgid "&Password:" msgstr "" @@ -428,6 +431,21 @@ msgstr "" msgid "Update failed" msgstr "" +msgid "" +"Update downloaded to:\n" +"{}\n" +"Open it to finish installing." +msgstr "" + +msgid "" +"Update downloaded to:\n" +"{}\n" +"Please open it manually to finish installing." +msgstr "" + +msgid "Update Downloaded" +msgstr "" + msgid "Unsupported format selected." msgstr "" @@ -991,7 +1009,7 @@ msgid "Wrapping to end. " msgstr "" #, c-format -msgid "%s Heading level %d" +msgid "Heading level %d: %s" msgstr "" #, c-format diff --git a/src/config.rs b/src/config.rs index 36d865a7..889b9b93 100644 --- a/src/config.rs +++ b/src/config.rs @@ -877,15 +877,46 @@ pub fn get_sorted_document_list(config: &ConfigManager, open_paths: &[String], f fn get_config_path() -> String { let exe_dir = get_exe_directory(); - let is_installed = (0..10).any(|i| exe_dir.join(format!("unins{i:03}.exe")).exists()); - if is_installed { - if let Some(appdata) = env::var_os("APPDATA") { - let config_dir = PathBuf::from(appdata).join("Paperback"); - let _ = fs::create_dir_all(&config_dir); - return config_dir.join("Paperback.ini").to_string_lossy().to_string(); + let exe_config = exe_dir.join("Paperback.ini"); + if exe_config.exists() { + return exe_config.to_string_lossy().to_string(); + } + #[cfg(target_os = "windows")] + { + let is_installed = (0..10).any(|i| exe_dir.join(format!("unins{i:03}.exe")).exists()); + if is_installed { + if let Some(appdata) = env::var_os("APPDATA") { + let config_dir = PathBuf::from(appdata).join("Paperback"); + if fs::create_dir_all(&config_dir).is_ok() { + return config_dir.join("Paperback.ini").to_string_lossy().to_string(); + } + } + } + return exe_config.to_string_lossy().to_string(); + } + #[cfg(target_os = "macos")] + { + if let Some(home) = env::var_os("HOME") { + let config_dir = PathBuf::from(home).join("Library").join("Application Support").join("Paperback"); + if fs::create_dir_all(&config_dir).is_ok() { + return config_dir.join("Paperback.ini").to_string_lossy().to_string(); + } + } + return exe_config.to_string_lossy().to_string(); + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let config_root = env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))); + if let Some(root) = config_root { + let config_dir = root.join("paperback"); + if fs::create_dir_all(&config_dir).is_ok() { + return config_dir.join("Paperback.ini").to_string_lossy().to_string(); + } } + exe_config.to_string_lossy().to_string() } - exe_dir.join("Paperback.ini").to_string_lossy().to_string() } fn get_exe_directory() -> PathBuf { diff --git a/src/ipc.rs b/src/ipc.rs index 5138e05c..0ad4f17b 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -73,8 +73,11 @@ mod tests { #[test] fn normalize_cli_path_handles_absolute_and_relative() { + #[cfg(windows)] let abs = Path::new("C:\\nonexistent_abs_path"); - assert_eq!(normalize_cli_path(abs), PathBuf::from("C:\\nonexistent_abs_path")); + #[cfg(not(windows))] + let abs = Path::new("/nonexistent_abs_path"); + assert_eq!(normalize_cli_path(abs), PathBuf::from(abs)); let rel = Path::new("nonexistent_rel_path"); let expected = env::current_dir().unwrap().join(rel); assert_eq!(normalize_cli_path(rel), expected); diff --git a/src/main.rs b/src/main.rs index 75d2fa44..75fb752b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -#![cfg_attr(not(test), windows_subsystem = "windows")] +#![cfg_attr(all(not(test), target_os = "windows"), windows_subsystem = "windows")] #![warn(clippy::all, clippy::nursery, clippy::pedantic)] mod config; diff --git a/src/parser/path.rs b/src/parser/path.rs index 6e95fdc0..fefb50ef 100644 --- a/src/parser/path.rs +++ b/src/parser/path.rs @@ -19,10 +19,21 @@ mod tests { fn extracts_title_from_path() { assert_eq!(extract_title_from_path("foo.txt"), "foo"); assert_eq!(extract_title_from_path("/home/quin/books/worm.epub"), "worm"); - assert_eq!(extract_title_from_path("C:\\Users\\Quin\\Desktop\\file.log"), "file"); assert_eq!(extract_title_from_path("/path/with/trailing/slash/"), "Untitled"); assert_eq!(extract_title_from_path("C:\\path\\with\\trailing\\slash\\"), "Untitled"); assert_eq!(extract_title_from_path(" spaced.txt "), "spaced"); assert_eq!(extract_title_from_path(""), "Untitled"); } + + #[cfg(windows)] + #[test] + fn extracts_title_from_windows_path() { + assert_eq!(extract_title_from_path("C:\\Users\\Quin\\Desktop\\file.log"), "file"); + } + + #[cfg(not(windows))] + #[test] + fn extracts_title_from_windows_like_path_non_windows() { + assert_eq!(extract_title_from_path("C:\\Users\\Quin\\Desktop\\file.log"), "C:\\Users\\Quin\\Desktop\\file"); + } } diff --git a/src/ui.rs b/src/ui.rs index c4ef3246..35ff77a0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,3 +1,4 @@ +mod accessibility; mod app; mod dialogs; mod document_manager; diff --git a/src/ui/accessibility.rs b/src/ui/accessibility.rs new file mode 100644 index 00000000..10c7a82f --- /dev/null +++ b/src/ui/accessibility.rs @@ -0,0 +1,75 @@ +#[cfg(target_os = "linux")] +use std::{process::Command, sync::OnceLock, thread}; + +use wxdragon::prelude::StaticText; + +fn sanitize_message(message: &str) -> String { + let mut cleaned = String::new(); + for ch in message.chars() { + if matches!(ch, '\n' | '\r' | '\t') { + cleaned.push(' '); + } else if !ch.is_control() { + cleaned.push(ch); + } + } + let collapsed = cleaned.split_whitespace().collect::>().join(" "); + collapsed.chars().take(512).collect() +} + +#[cfg(target_os = "linux")] +fn has_spd_say() -> bool { + static HAS_SPD_SAY: OnceLock = OnceLock::new(); + *HAS_SPD_SAY.get_or_init(|| Command::new("spd-say").arg("--version").output().is_ok()) +} + +#[cfg(target_os = "linux")] +fn has_gdbus() -> bool { + static HAS_GDBUS: OnceLock = OnceLock::new(); + *HAS_GDBUS.get_or_init(|| Command::new("gdbus").arg("--version").output().is_ok()) +} + +#[cfg(target_os = "linux")] +fn present_with_orca(message: &str) -> bool { + if !has_gdbus() { + return false; + } + Command::new("gdbus") + .arg("call") + .arg("--session") + .arg("--dest") + .arg("org.gnome.Orca.Service") + .arg("--object-path") + .arg("/org/gnome/Orca/Service") + .arg("--timeout") + .arg("1") + .arg("--method") + .arg("org.gnome.Orca.Service.PresentMessage") + .arg(message) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +#[cfg(target_os = "linux")] +fn speak_with_spd_say(message: &str) { + if !has_spd_say() { + return; + } + let _ = Command::new("spd-say").arg("-N").arg("paperback").arg("-P").arg("notification").arg(message).status(); +} + +pub fn announce(label: StaticText, message: &str) { + live_region::announce(label, message); + #[cfg(target_os = "linux")] + { + let spoken = sanitize_message(message); + if spoken.is_empty() { + return; + } + thread::spawn(move || { + if !present_with_orca(&spoken) { + speak_with_spd_say(&spoken); + } + }); + } +} diff --git a/src/ui/document_manager.rs b/src/ui/document_manager.rs index 871d3d26..40474525 100644 --- a/src/ui/document_manager.rs +++ b/src/ui/document_manager.rs @@ -13,8 +13,9 @@ use wxdragon::{ }; use super::{ + accessibility, main_window::{SLEEP_TIMER_DURATION_MINUTES, SLEEP_TIMER_START_MS}, - menu_ids, status, + menu_ids, navigation, status, }; use crate::{config::ConfigManager, parser::PASSWORD_REQUIRED_ERROR_PREFIX, session::DocumentSession}; @@ -28,6 +29,128 @@ pub struct DocumentTab { const POSITION_SAVE_INTERVAL_SECS: u64 = 3; const WXK_F10: i32 = 349; +fn reader_navigation_command( + unicode_key: Option, + key_code: Option, + shift: bool, + ctrl_or_cmd: bool, + alt: bool, + meta: bool, +) -> Option { + if ctrl_or_cmd || alt || meta { + return None; + } + let key = unicode_key.or(key_code)?; + let ch = u32::try_from(key).ok().and_then(char::from_u32)?; + if matches!(ch, '[' | '{') { + return Some(menu_ids::PREVIOUS_SECTION); + } + if matches!(ch, ']' | '}') { + return Some(menu_ids::NEXT_SECTION); + } + if let Some(level) = heading_level_key(ch) { + return Some(match (level, shift) { + (1, true) => menu_ids::PREVIOUS_HEADING_1, + (1, false) => menu_ids::NEXT_HEADING_1, + (2, true) => menu_ids::PREVIOUS_HEADING_2, + (2, false) => menu_ids::NEXT_HEADING_2, + (3, true) => menu_ids::PREVIOUS_HEADING_3, + (3, false) => menu_ids::NEXT_HEADING_3, + (4, true) => menu_ids::PREVIOUS_HEADING_4, + (4, false) => menu_ids::NEXT_HEADING_4, + (5, true) => menu_ids::PREVIOUS_HEADING_5, + (5, false) => menu_ids::NEXT_HEADING_5, + (6, true) => menu_ids::PREVIOUS_HEADING_6, + (6, false) => menu_ids::NEXT_HEADING_6, + _ => return None, + }); + } + if ch.eq_ignore_ascii_case(&'h') { + return Some(if shift { menu_ids::PREVIOUS_HEADING } else { menu_ids::NEXT_HEADING }); + } + if ch.eq_ignore_ascii_case(&'p') { + return Some(if shift { menu_ids::PREVIOUS_PAGE } else { menu_ids::NEXT_PAGE }); + } + if ch.eq_ignore_ascii_case(&'k') { + return Some(if shift { menu_ids::PREVIOUS_LINK } else { menu_ids::NEXT_LINK }); + } + if ch.eq_ignore_ascii_case(&'t') { + return Some(if shift { menu_ids::PREVIOUS_TABLE } else { menu_ids::NEXT_TABLE }); + } + if ch.eq_ignore_ascii_case(&'s') { + return Some(if shift { menu_ids::PREVIOUS_SEPARATOR } else { menu_ids::NEXT_SEPARATOR }); + } + if ch.eq_ignore_ascii_case(&'l') { + return Some(if shift { menu_ids::PREVIOUS_LIST } else { menu_ids::NEXT_LIST }); + } + if ch.eq_ignore_ascii_case(&'i') { + return Some(if shift { menu_ids::PREVIOUS_LIST_ITEM } else { menu_ids::NEXT_LIST_ITEM }); + } + if ch.eq_ignore_ascii_case(&'b') { + return Some(if shift { menu_ids::PREVIOUS_BOOKMARK } else { menu_ids::NEXT_BOOKMARK }); + } + if ch.eq_ignore_ascii_case(&'n') { + return Some(if shift { menu_ids::PREVIOUS_NOTE } else { menu_ids::NEXT_NOTE }); + } + None +} + +fn heading_level_key(ch: char) -> Option { + match ch { + '1' | '!' => Some(1), + '2' | '@' => Some(2), + '3' | '#' => Some(3), + '4' | '$' => Some(4), + '5' | '%' => Some(5), + '6' | '^' => Some(6), + _ => None, + } +} + +fn marker_navigation_for_command(command_id: i32) -> Option<(navigation::MarkerNavTarget, bool)> { + match command_id { + menu_ids::PREVIOUS_SECTION => Some((navigation::MarkerNavTarget::Section, false)), + menu_ids::NEXT_SECTION => Some((navigation::MarkerNavTarget::Section, true)), + menu_ids::PREVIOUS_HEADING => Some((navigation::MarkerNavTarget::Heading(0), false)), + menu_ids::NEXT_HEADING => Some((navigation::MarkerNavTarget::Heading(0), true)), + menu_ids::PREVIOUS_HEADING_1 => Some((navigation::MarkerNavTarget::Heading(1), false)), + menu_ids::NEXT_HEADING_1 => Some((navigation::MarkerNavTarget::Heading(1), true)), + menu_ids::PREVIOUS_HEADING_2 => Some((navigation::MarkerNavTarget::Heading(2), false)), + menu_ids::NEXT_HEADING_2 => Some((navigation::MarkerNavTarget::Heading(2), true)), + menu_ids::PREVIOUS_HEADING_3 => Some((navigation::MarkerNavTarget::Heading(3), false)), + menu_ids::NEXT_HEADING_3 => Some((navigation::MarkerNavTarget::Heading(3), true)), + menu_ids::PREVIOUS_HEADING_4 => Some((navigation::MarkerNavTarget::Heading(4), false)), + menu_ids::NEXT_HEADING_4 => Some((navigation::MarkerNavTarget::Heading(4), true)), + menu_ids::PREVIOUS_HEADING_5 => Some((navigation::MarkerNavTarget::Heading(5), false)), + menu_ids::NEXT_HEADING_5 => Some((navigation::MarkerNavTarget::Heading(5), true)), + menu_ids::PREVIOUS_HEADING_6 => Some((navigation::MarkerNavTarget::Heading(6), false)), + menu_ids::NEXT_HEADING_6 => Some((navigation::MarkerNavTarget::Heading(6), true)), + menu_ids::PREVIOUS_PAGE => Some((navigation::MarkerNavTarget::Page, false)), + menu_ids::NEXT_PAGE => Some((navigation::MarkerNavTarget::Page, true)), + menu_ids::PREVIOUS_LINK => Some((navigation::MarkerNavTarget::Link, false)), + menu_ids::NEXT_LINK => Some((navigation::MarkerNavTarget::Link, true)), + menu_ids::PREVIOUS_TABLE => Some((navigation::MarkerNavTarget::Table, false)), + menu_ids::NEXT_TABLE => Some((navigation::MarkerNavTarget::Table, true)), + menu_ids::PREVIOUS_SEPARATOR => Some((navigation::MarkerNavTarget::Separator, false)), + menu_ids::NEXT_SEPARATOR => Some((navigation::MarkerNavTarget::Separator, true)), + menu_ids::PREVIOUS_LIST => Some((navigation::MarkerNavTarget::List, false)), + menu_ids::NEXT_LIST => Some((navigation::MarkerNavTarget::List, true)), + menu_ids::PREVIOUS_LIST_ITEM => Some((navigation::MarkerNavTarget::ListItem, false)), + menu_ids::NEXT_LIST_ITEM => Some((navigation::MarkerNavTarget::ListItem, true)), + _ => None, + } +} + +fn bookmark_navigation_for_command(command_id: i32) -> Option<(bool, bool)> { + match command_id { + menu_ids::PREVIOUS_BOOKMARK => Some((false, false)), + menu_ids::NEXT_BOOKMARK => Some((false, true)), + menu_ids::PREVIOUS_NOTE => Some((true, false)), + menu_ids::NEXT_NOTE => Some((true, true)), + _ => None, + } +} + pub struct DocumentManager { frame: Frame, notebook: Notebook, @@ -115,7 +238,8 @@ impl DocumentManager { let config = self.config.lock().unwrap(); let mut session = session; let word_wrap = config.get_app_bool("word_wrap", false); - let text_ctrl = Self::build_text_ctrl(panel, word_wrap, self_rc); + let text_ctrl = + Self::build_text_ctrl(panel, word_wrap, self_rc, Rc::clone(&self.config), self.live_region_label); let sizer = BoxSizer::builder(Orientation::Vertical).build(); sizer.add(&text_ctrl, 1, SizerFlag::Expand | SizerFlag::All, 0); panel.set_sizer(sizer, true); @@ -263,7 +387,7 @@ impl DocumentManager { tab.text_ctrl.set_insertion_point(result.offset); tab.text_ctrl.show_position(result.offset); tab.session.check_and_record_history(result.offset); - live_region::announce(self.live_region_label, &t("Navigated to internal link.")); + accessibility::announce(self.live_region_label, &t("Navigated to internal link.")); } crate::session::LinkAction::External => { wxdragon::utils::launch_default_browser( @@ -320,7 +444,8 @@ impl DocumentManager { let old_ctrl = tab.text_ctrl; let current_pos = old_ctrl.get_insertion_point(); let content = old_ctrl.get_value(); - let text_ctrl = Self::build_text_ctrl(tab.panel, word_wrap, self_rc); + let text_ctrl = + Self::build_text_ctrl(tab.panel, word_wrap, self_rc, Rc::clone(&self.config), self.live_region_label); let sizer = BoxSizer::builder(Orientation::Vertical).build(); sizer.add(&text_ctrl, 1, SizerFlag::Expand | SizerFlag::All, 0); tab.panel.set_sizer(sizer, true); @@ -335,7 +460,13 @@ impl DocumentManager { } } - fn build_text_ctrl(panel: Panel, word_wrap: bool, self_rc: &Rc>) -> TextCtrl { + fn build_text_ctrl( + panel: Panel, + word_wrap: bool, + self_rc: &Rc>, + config: Rc>, + live_region_label: StaticText, + ) -> TextCtrl { let style = TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::Rich2 @@ -370,10 +501,49 @@ impl DocumentManager { dm.save_position_throttled(); } }); + let dm_for_nav = Rc::clone(self_rc); + let config_for_nav = Rc::clone(&config); let text_ctrl_for_menu = text_ctrl; text_ctrl.on_key_down(move |event| { if let WindowEventData::Keyboard(kbd) = &event { + if let Some(command_id) = reader_navigation_command( + kbd.get_unicode_key(), + kbd.get_key_code(), + kbd.shift_down(), + kbd.cmd_down() || kbd.control_down(), + kbd.alt_down(), + kbd.meta_down(), + ) { + if let Some((target, next)) = marker_navigation_for_command(command_id) { + navigation::handle_marker_navigation( + &dm_for_nav, + &config_for_nav, + live_region_label, + target, + next, + ); + } else if let Some((notes_only, next)) = bookmark_navigation_for_command(command_id) { + navigation::handle_bookmark_navigation( + &dm_for_nav, + &config_for_nav, + live_region_label, + next, + notes_only, + ); + } + kbd.event.skip(false); + return; + } if let Some(key) = kbd.get_key_code() { + if key == WXK_F10 + && !kbd.shift_down() + && !kbd.cmd_down() && !kbd.control_down() + && !kbd.alt_down() && !kbd.meta_down() + { + accessibility::announce(live_region_label, &t("File menu")); + kbd.event.skip(true); + return; + } if key == WXK_F10 && kbd.shift_down() { kbd.event.skip(false); show_reader_context_menu(text_ctrl_for_menu); @@ -452,3 +622,84 @@ fn show_reader_context_menu(text_ctrl: TextCtrl) { .build(); text_ctrl.popup_menu(&mut menu, None); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reader_navigation_command_maps_next_heading() { + assert_eq!( + reader_navigation_command(Some(i32::from(b'h')), Some(i32::from(b'H')), false, false, false, false), + Some(menu_ids::NEXT_HEADING), + ); + } + + #[test] + fn reader_navigation_command_maps_previous_heading() { + assert_eq!( + reader_navigation_command(Some(i32::from(b'H')), Some(i32::from(b'H')), true, false, false, false), + Some(menu_ids::PREVIOUS_HEADING), + ); + } + + #[test] + fn reader_navigation_command_maps_heading_levels() { + assert_eq!( + reader_navigation_command(Some(i32::from(b'1')), Some(i32::from(b'1')), false, false, false, false), + Some(menu_ids::NEXT_HEADING_1), + ); + assert_eq!( + reader_navigation_command(Some(i32::from(b'!')), Some(i32::from(b'1')), true, false, false, false), + Some(menu_ids::PREVIOUS_HEADING_1), + ); + assert_eq!( + reader_navigation_command(Some(i32::from(b'6')), Some(i32::from(b'6')), false, false, false, false), + Some(menu_ids::NEXT_HEADING_6), + ); + assert_eq!( + reader_navigation_command(Some(i32::from(b'^')), Some(i32::from(b'6')), true, false, false, false), + Some(menu_ids::PREVIOUS_HEADING_6), + ); + } + + #[test] + fn reader_navigation_command_maps_other_single_key_shortcuts() { + assert_eq!( + reader_navigation_command(Some(i32::from(b']')), Some(i32::from(b']')), false, false, false, false), + Some(menu_ids::NEXT_SECTION), + ); + assert_eq!( + reader_navigation_command(Some(i32::from(b'[')), Some(i32::from(b'[')), false, false, false, false), + Some(menu_ids::PREVIOUS_SECTION), + ); + assert_eq!( + reader_navigation_command(Some(i32::from(b'k')), Some(i32::from(b'K')), false, false, false, false), + Some(menu_ids::NEXT_LINK), + ); + assert_eq!( + reader_navigation_command(Some(i32::from(b'K')), Some(i32::from(b'K')), true, false, false, false), + Some(menu_ids::PREVIOUS_LINK), + ); + assert_eq!( + reader_navigation_command(Some(i32::from(b'b')), Some(i32::from(b'B')), false, false, false, false), + Some(menu_ids::NEXT_BOOKMARK), + ); + assert_eq!( + reader_navigation_command(Some(i32::from(b'N')), Some(i32::from(b'N')), true, false, false, false), + Some(menu_ids::PREVIOUS_NOTE), + ); + } + + #[test] + fn reader_navigation_command_ignores_modifier_chords() { + assert_eq!( + reader_navigation_command(Some(i32::from(b'h')), Some(i32::from(b'H')), false, true, false, false), + None, + ); + assert_eq!( + reader_navigation_command(Some(i32::from(b'h')), Some(i32::from(b'H')), false, false, true, false), + None, + ); + } +} diff --git a/src/ui/find.rs b/src/ui/find.rs index d15afbb5..28be6f19 100644 --- a/src/ui/find.rs +++ b/src/ui/find.rs @@ -3,7 +3,7 @@ use std::{cell::Cell, rc::Rc, sync::Mutex}; use bitflags::bitflags; use wxdragon::{prelude::*, translations::translate as t}; -use super::document_manager::DocumentManager; +use super::{accessibility, document_manager::DocumentManager}; use crate::{config::ConfigManager, reader_core, text::display_len}; const DIALOG_PADDING: i32 = 10; @@ -431,14 +431,14 @@ fn do_find( let start_pos = if forward { sel_end } else { sel_start }; let result = find_text_with_wrap(&text, &query, start_pos, options); if !result.found { - live_region::announce(live_region_label, &t("Not found.")); + accessibility::announce(live_region_label, &t("Not found.")); state.dialog.show(true); state.dialog.raise(); state.focus_find_text(); return; } if result.wrapped { - live_region::announce(live_region_label, &t("No more results. Wrapping search.")); + accessibility::announce(live_region_label, &t("No more results. Wrapping search.")); } if result.position < 0 { return; diff --git a/src/ui/help.rs b/src/ui/help.rs index bcdc8f44..ed584078 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -215,7 +215,6 @@ fn handle_update_available( ACTIVE_PROGRESS.with(|p| { *p.borrow_mut() = None; }); - #[cfg(target_os = "windows")] execute_update(res); })); }); @@ -349,6 +348,36 @@ fn execute_update(result: Result) { } } +#[cfg(not(target_os = "windows"))] +fn execute_update(result: Result) { + let parent_window = main_window_parent(); + let Some(parent) = parent_window.as_ref() else { + return; + }; + match result { + Ok(path) => { + let url = format!("file://{}", path.to_string_lossy()); + let opened = wxdragon::utils::launch_default_browser(&url, wxdragon::utils::BrowserLaunchFlags::Default); + let template = if opened { + t("Update downloaded to:\n{}\nOpen it to finish installing.") + } else { + t("Update downloaded to:\n{}\nPlease open it manually to finish installing.") + }; + let message = template.replacen("{}", &path.to_string_lossy(), 1); + let dlg = MessageDialog::builder(parent, &message, &t("Update Downloaded")) + .with_style(MessageDialogStyle::OK | MessageDialogStyle::IconInformation) + .build(); + dlg.show_modal(); + } + Err(e) => { + let dlg = MessageDialog::builder(parent, &format!("{}: {e}", t("Update failed")), &t("Error")) + .with_style(MessageDialogStyle::OK | MessageDialogStyle::IconError) + .build(); + dlg.show_modal(); + } + } +} + struct ParentWindow { handle: *mut ffi::wxd_Window_t, } diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index cb054723..70d4a6d3 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -15,6 +15,7 @@ use std::{ use wxdragon::{prelude::*, timer::Timer, translations::translate as t}; use super::{ + accessibility, dialogs::{self, OptionsDialogFlags}, document_manager::DocumentManager, find::{self, FindDialogState}, @@ -452,7 +453,7 @@ impl MainWindow { }; let page_count = tab.session.page_count(); if page_count == 0 { - live_region::announce(live_region_label, &t("No pages.")); + accessibility::announce(live_region_label, &t("No pages.")); return; } let current_pos = tab.text_ctrl.get_insertion_point(); @@ -1068,7 +1069,7 @@ impl MainWindow { SLEEP_TIMER_DURATION_MINUTES.store(0, Ordering::SeqCst); let dm_ref = dm.lock().unwrap(); update_title_from_manager(&frame_copy, &dm_ref); - live_region::announce(live_region_label, &t("Sleep timer cancelled.")); + accessibility::announce(live_region_label, &t("Sleep timer cancelled.")); return; } let initial_duration = config.lock().unwrap().get_app_int("sleep_timer_duration", 30); @@ -1095,7 +1096,7 @@ impl MainWindow { } else { t("Sleep timer set for %d minutes.").replace("%d", &duration.to_string()) }; - live_region::announce(live_region_label, &msg); + accessibility::announce(live_region_label, &msg); } } menu_ids::ABOUT => { @@ -1144,7 +1145,7 @@ impl MainWindow { !config_guard.get_all_documents().is_empty() }; if !has_documents { - live_region::announce(live_region_label, &t("No recent documents.")); + accessibility::announce(live_region_label, &t("No recent documents.")); return; } let open_paths = dm.lock().unwrap().open_paths(); diff --git a/src/ui/navigation.rs b/src/ui/navigation.rs index abcbb3f9..b74e07db 100644 --- a/src/ui/navigation.rs +++ b/src/ui/navigation.rs @@ -2,7 +2,7 @@ use std::{rc::Rc, sync::Mutex}; use wxdragon::{prelude::*, translations::translate as t}; -use super::{dialogs, document_manager::DocumentManager}; +use super::{accessibility, dialogs, document_manager::DocumentManager}; use crate::{config::ConfigManager, reader_core, session::NavigationResult, types::BookmarkFilterType}; #[derive(Clone, Copy)] @@ -110,8 +110,8 @@ fn format_nav_found_message( match ann.format { NavFoundFormat::TextOnly => format!("{wrap_prefix}{context_text}"), NavFoundFormat::TextWithLevel => { - let template = t("%s Heading level %d"); - let message = template.replacen("%s", context_text, 1).replacen("%d", &context_index.to_string(), 1); + let template = t("Heading level %d: %s"); + let message = template.replacen("%d", &context_index.to_string(), 1).replacen("%s", context_text, 1); format!("{wrap_prefix}{message}") } NavFoundFormat::PageFormat => { @@ -127,6 +127,15 @@ fn format_nav_found_message( } } +fn sanitize_announcement(message: &str) -> String { + let filtered: String = message.chars().filter(|ch| !ch.is_control() || matches!(ch, '\n' | '\t')).collect(); + let mut truncated = String::new(); + for ch in filtered.chars().take(512) { + truncated.push(ch); + } + truncated +} + fn apply_navigation_result( tab: &super::document_manager::DocumentTab, result: &NavigationResult, @@ -140,25 +149,38 @@ fn apply_navigation_result( }; let ann = nav_announcements(target, level_filter); if result.not_supported { - live_region::announce(live_region_label, &ann.not_supported); + accessibility::announce(live_region_label, &ann.not_supported); return false; } if !result.found { let message = if next { &ann.not_found_next } else { &ann.not_found_prev }; - live_region::announce(live_region_label, message); + accessibility::announce(live_region_label, message); return false; } let mut context_text = result.marker_text.clone(); if context_text.is_empty() { context_text = tab.session.get_line_text(result.offset); } + context_text = context_text.replace('\n', " ").trim().to_string(); + if context_text.chars().count() > 200 { + context_text = context_text.chars().take(200).collect(); + } let context_index = match target { - MarkerNavTarget::Heading(_) => result.marker_level, + MarkerNavTarget::Heading(requested_level) => { + if result.marker_level > 0 { + result.marker_level + } else if requested_level > 0 { + requested_level + } else { + 1 + } + } MarkerNavTarget::Page => result.marker_index, _ => 0, }; let message = format_nav_found_message(&ann, &context_text, context_index, result.wrapped, next); - live_region::announce(live_region_label, &message); + let message = sanitize_announcement(&message); + accessibility::announce(live_region_label, &message); let offset = result.offset; tab.text_ctrl.set_focus(); tab.text_ctrl.set_insertion_point(offset); @@ -199,7 +221,7 @@ pub fn handle_history_navigation( } }; drop(dm); - live_region::announce(live_region_label, &message); + accessibility::announce(live_region_label, &message); if let Some((path_str, history, history_index)) = history_update { let cfg = config.lock().unwrap(); cfg.set_navigation_history(&path_str, &history, history_index); @@ -324,7 +346,7 @@ pub fn handle_bookmark_navigation( } }; drop(dm); - live_region::announce(live_region_label, &message); + accessibility::announce(live_region_label, &message); if let Some((path_str, history, history_index)) = history_update { let cfg = config.lock().unwrap(); cfg.set_navigation_history(&path_str, &history, history_index); @@ -371,7 +393,7 @@ pub fn handle_bookmark_dialog( (message, Some((path_str, history, history_index))) }; drop(dm); - live_region::announce(live_region_label, &message); + accessibility::announce(live_region_label, &message); if let Some((path_str, history, history_index)) = history_update { let cfg = config.lock().unwrap(); cfg.set_navigation_history(&path_str, &history, history_index); @@ -402,7 +424,7 @@ pub fn handle_toggle_bookmark( cfg.flush(); drop(cfg); let message = if existed { t("Bookmark removed.") } else { t("Bookmark added.") }; - live_region::announce(live_region_label, &message); + accessibility::announce(live_region_label, &message); } pub fn handle_bookmark_with_note( @@ -442,7 +464,7 @@ pub fn handle_bookmark_with_note( } cfg.flush(); drop(cfg); - live_region::announce(live_region_label, &t("Bookmark saved.")); + accessibility::announce(live_region_label, &t("Bookmark saved.")); } pub fn handle_view_note_text( diff --git a/src/ui/tray.rs b/src/ui/tray.rs index 8c6d5b82..eb7f3bf3 100644 --- a/src/ui/tray.rs +++ b/src/ui/tray.rs @@ -93,7 +93,7 @@ fn create_tray_state( { let doc_manager_click = Rc::clone(&doc_manager); let tray_state_click = Rc::clone(&tray_state); - icon.on_left_up(move |_event| { + icon.on_left_down(move |_event| { restore_from_tray(frame, &doc_manager_click, &tray_state_click); }); icon.on_left_double_click(move |_event| { diff --git a/src/update.rs b/src/update.rs index 2f612a42..1221de24 100644 --- a/src/update.rs +++ b/src/update.rs @@ -14,6 +14,17 @@ use ureq::{Agent, config::Config}; use crate::version; const RELEASE_URL: &str = "https://api.github.com/repos/trypsynth/paperback/releases/latest"; +const WINDOWS_INSTALLER_ASSETS: &[&str] = &["paperback_setup.exe"]; +const WINDOWS_PORTABLE_ASSETS: &[&str] = &["paperback_windows.zip", "paperback.zip"]; +const MACOS_ASSETS: &[&str] = &["paperback_mac.zip", "paperback_macos.zip"]; +const LINUX_ASSETS: &[&str] = &[ + "paperback_linux.zip", + "paperback-linux.zip", + "paperback_linux.tar.gz", + "paperback-linux.tar.gz", + "paperback.appimage", +]; +const GENERIC_ASSETS: &[&str] = &["paperback.zip"]; #[derive(Debug, Deserialize)] struct ReleaseAsset { @@ -64,6 +75,28 @@ impl Display for UpdateError { impl Error for UpdateError {} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum UpdateTarget { + Windows, + MacOs, + Linux, + Other, +} + +impl UpdateTarget { + const fn current() -> Self { + if cfg!(target_os = "windows") { + Self::Windows + } else if cfg!(target_os = "macos") { + Self::MacOs + } else if cfg!(target_os = "linux") { + Self::Linux + } else { + Self::Other + } + } +} + pub fn download_update_file(url: &str, mut progress_callback: impl FnMut(u64, u64)) -> Result { let user_agent = version::user_agent(); let config = Config::builder() @@ -82,11 +115,8 @@ pub fn download_update_file(url: &str, mut progress_callback: impl FnMut(u64, u6 .and_then(|v| v.parse::().ok()) .unwrap_or(0); let fname = url.rsplit('/').next().unwrap_or("update.bin"); - let is_exe = Path::new(fname).extension().is_some_and(|ext| ext.eq_ignore_ascii_case("exe")); let is_zip = Path::new(fname).extension().is_some_and(|ext| ext.eq_ignore_ascii_case("zip")); - let mut dest_path = if is_exe { - env::temp_dir() - } else if is_zip { + let mut dest_path = if matches!(UpdateTarget::current(), UpdateTarget::Windows) && is_zip { env::current_exe() .map_err(|e| UpdateError::NoDownload(format!("Failed to determine exe path: {e}")))? .parent() @@ -128,14 +158,112 @@ fn parse_semver_value(value: &str) -> Option<(u64, u64, u64)> { Some((major, minor, patch)) } -fn pick_download_url(is_installer: bool, assets: &[ReleaseAsset]) -> Option { - let preferred_name = if is_installer { "paperback_setup.exe" } else { "paperback.zip" }; +fn preferred_assets(target: UpdateTarget, is_installer: bool) -> &'static [&'static str] { + match (target, is_installer) { + (UpdateTarget::Windows, true) => WINDOWS_INSTALLER_ASSETS, + (UpdateTarget::Windows, false) => WINDOWS_PORTABLE_ASSETS, + (UpdateTarget::MacOs, _) => MACOS_ASSETS, + (UpdateTarget::Linux, _) => LINUX_ASSETS, + (UpdateTarget::Other, _) => GENERIC_ASSETS, + } +} + +fn is_tar_gz(name: &str) -> bool { + name.ends_with(".tar.gz") +} + +fn is_archive(name: &str) -> bool { + name.ends_with(".zip") || is_tar_gz(name) +} + +fn is_linux_package(name: &str) -> bool { + is_archive(name) || name.ends_with(".appimage") || name.ends_with(".deb") || name.ends_with(".rpm") +} + +fn pick_fallback_asset(target: UpdateTarget, is_installer: bool, assets: &[ReleaseAsset]) -> Option { + match target { + UpdateTarget::Windows if is_installer => { + for asset in assets { + let name = asset.name.to_ascii_lowercase(); + if name.contains("setup") && name.ends_with(".exe") { + return Some(asset.browser_download_url.clone()); + } + } + for asset in assets { + let name = asset.name.to_ascii_lowercase(); + if name.ends_with(".exe") { + return Some(asset.browser_download_url.clone()); + } + } + } + UpdateTarget::Windows => { + for asset in assets { + let name = asset.name.to_ascii_lowercase(); + if name.contains("windows") && is_archive(&name) { + return Some(asset.browser_download_url.clone()); + } + } + for asset in assets { + let name = asset.name.to_ascii_lowercase(); + if name == "paperback.zip" { + return Some(asset.browser_download_url.clone()); + } + } + for asset in assets { + let name = asset.name.to_ascii_lowercase(); + if is_archive(&name) { + return Some(asset.browser_download_url.clone()); + } + } + } + UpdateTarget::MacOs => { + for asset in assets { + let name = asset.name.to_ascii_lowercase(); + if name.contains("mac") && (is_archive(&name) || name.ends_with(".dmg")) { + return Some(asset.browser_download_url.clone()); + } + } + for asset in assets { + let name = asset.name.to_ascii_lowercase(); + if name.ends_with(".dmg") || is_archive(&name) { + return Some(asset.browser_download_url.clone()); + } + } + } + UpdateTarget::Linux => { + for asset in assets { + let name = asset.name.to_ascii_lowercase(); + if name.contains("linux") && is_linux_package(&name) { + return Some(asset.browser_download_url.clone()); + } + } + for asset in assets { + let name = asset.name.to_ascii_lowercase(); + if is_linux_package(&name) { + return Some(asset.browser_download_url.clone()); + } + } + } + UpdateTarget::Other => { + for asset in assets { + let name = asset.name.to_ascii_lowercase(); + if name.ends_with(".zip") { + return Some(asset.browser_download_url.clone()); + } + } + } + } + None +} + +fn pick_download_url(target: UpdateTarget, is_installer: bool, assets: &[ReleaseAsset]) -> Option { + let preferred_names = preferred_assets(target, is_installer); for asset in assets { - if asset.name.eq_ignore_ascii_case(preferred_name) { + if preferred_names.iter().any(|name| asset.name.eq_ignore_ascii_case(name)) { return Some(asset.browser_download_url.clone()); } } - None + pick_fallback_asset(target, is_installer, assets) } fn fetch_latest_release() -> Result { @@ -159,6 +287,7 @@ fn fetch_latest_release() -> Result { pub fn check_for_updates(current_version: &str, is_installer: bool) -> Result { let current = parse_semver_value(current_version) .ok_or_else(|| UpdateError::InvalidVersion("Current version was not a valid semantic version.".to_string()))?; + let target = UpdateTarget::current(); let release = fetch_latest_release()?; let latest_semver = parse_semver_value(&release.tag_name).ok_or_else(|| { UpdateError::InvalidResponse("Latest release tag does not contain a valid semantic version.".to_string()) @@ -167,7 +296,7 @@ pub fn check_for_updates(current_version: &str, is_installer: bool) -> Result pick_download_url(is_installer, list).ok_or_else(|| { + Some(list) if !list.is_empty() => pick_download_url(target, is_installer, list).ok_or_else(|| { UpdateError::NoDownload("Update is available but no matching download asset was found.".to_string()) })?, _ => return Err(UpdateError::NoDownload("Latest release does not include downloadable assets.".to_string())), @@ -215,7 +344,7 @@ mod tests { browser_download_url: "https://example.com/paperback_setup.exe".to_string(), }, ]; - let url = pick_download_url(true, &assets); + let url = pick_download_url(UpdateTarget::Windows, true, &assets); assert_eq!(url.as_deref(), Some("https://example.com/paperback_setup.exe")); } @@ -225,7 +354,49 @@ mod tests { name: "PAPERBACK.ZIP".to_string(), browser_download_url: "https://example.com/PAPERBACK.ZIP".to_string(), }]; - let url = pick_download_url(false, &assets); + let url = pick_download_url(UpdateTarget::Windows, false, &assets); assert_eq!(url.as_deref(), Some("https://example.com/PAPERBACK.ZIP")); } + + #[test] + fn pick_download_url_prefers_linux_assets() { + let assets = vec![ + ReleaseAsset { + name: "paperback.zip".to_string(), + browser_download_url: "https://example.com/paperback.zip".to_string(), + }, + ReleaseAsset { + name: "paperback_linux.zip".to_string(), + browser_download_url: "https://example.com/paperback_linux.zip".to_string(), + }, + ]; + let url = pick_download_url(UpdateTarget::Linux, false, &assets); + assert_eq!(url.as_deref(), Some("https://example.com/paperback_linux.zip")); + } + + #[test] + fn pick_download_url_prefers_macos_assets() { + let assets = vec![ + ReleaseAsset { + name: "paperback_windows.zip".to_string(), + browser_download_url: "https://example.com/paperback_windows.zip".to_string(), + }, + ReleaseAsset { + name: "paperback_mac.zip".to_string(), + browser_download_url: "https://example.com/paperback_mac.zip".to_string(), + }, + ]; + let url = pick_download_url(UpdateTarget::MacOs, false, &assets); + assert_eq!(url.as_deref(), Some("https://example.com/paperback_mac.zip")); + } + + #[test] + fn pick_download_url_linux_falls_back_to_generic_zip() { + let assets = vec![ReleaseAsset { + name: "paperback.zip".to_string(), + browser_download_url: "https://example.com/paperback.zip".to_string(), + }]; + let url = pick_download_url(UpdateTarget::Linux, false, &assets); + assert_eq!(url.as_deref(), Some("https://example.com/paperback.zip")); + } } diff --git a/web/js/downloads.js b/web/js/downloads.js index a806b66e..36c21b6d 100644 --- a/web/js/downloads.js +++ b/web/js/downloads.js @@ -15,17 +15,57 @@ const fmtCount = n => `downloaded ${n} ${n === 1 ? "time" : "times"}`; + const classifyAsset = asset => { + const name = asset.name.toLowerCase(); + + if (name === "paperback_setup.exe" || (name.includes("setup") && name.endsWith(".exe"))) { + return "Windows Installer (.exe)"; + } + if (name === "paperback_windows.zip" || name === "paperback.zip" || (name.includes("windows") && name.endsWith(".zip"))) { + return "Windows Portable (.zip)"; + } + if (name === "paperback_mac.zip" || name === "paperback_macos.zip" || (name.includes("mac") && name.endsWith(".zip"))) { + return "macOS Portable (.zip)"; + } + if (name.endsWith(".dmg")) { + return "macOS Installer (.dmg)"; + } + if (name === "paperback_linux.zip" || (name.includes("linux") && name.endsWith(".zip"))) { + return "Linux Portable (.zip)"; + } + if (name.includes("linux") && name.endsWith(".tar.gz")) { + return "Linux Archive (.tar.gz)"; + } + if (name.endsWith(".appimage")) { + return "Linux AppImage"; + } + if (name.endsWith(".deb")) { + return "Linux Debian Package (.deb)"; + } + if (name.endsWith(".rpm")) { + return "Linux RPM Package (.rpm)"; + } + return null; + }; + + const renderDownloads = release => { + const links = (release.assets ?? []) + .map(asset => { + const label = classifyAsset(asset) || asset.name; + return `
  • ${label} - ${fmtCount(asset.download_count)}
  • `; + }) + .filter(Boolean); + if (links.length === 0) return "

    No downloadable assets were published for this release.

    "; + return `
      ${links.join("")}
    `; + }; + const render = (release, label, subtitle = "") => { - const assets = release.assets ?? []; - const zip = assets.find(a => a.name.toLowerCase().endsWith(".zip")); - const exe = assets.find(a => a.name.toLowerCase().endsWith(".exe")); const version = release.tag_name.replace(/^v/, ""); return `

    ${label} ${version}

    ${subtitle ? `

    ${subtitle}

    ` : ""} -

    ${exe ? `

    Windows Installer (.exe) - ${fmtCount(exe.download_count)}

    ` : ""}

    -

    ${zip ? `

    Windows Portable (.zip) - ${fmtCount(zip.download_count)}

    ` : ""}

    + ${renderDownloads(release)}

    View on GitHub

    `.trim(); diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 89d5405d..9ffe293d 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -56,7 +56,15 @@ fn build_zip_package( readme_path: &Path, langs_dir: &Path, ) -> Result<(), Box> { - let package_name = if cfg!(target_os = "macos") { "paperback_mac.zip" } else { "paperback.zip" }; + let package_name = if cfg!(target_os = "windows") { + "paperback_windows.zip" + } else if cfg!(target_os = "macos") { + "paperback_mac.zip" + } else if cfg!(target_os = "linux") { + "paperback_linux.zip" + } else { + "paperback.zip" + }; let package_path = target_dir.join(package_name); let file = File::create(&package_path)?; let mut zip = ZipWriter::new(file);