From 1c7946203dcd1e6026aefa76ccba1b2893637599 Mon Sep 17 00:00:00 2001 From: oratis Date: Sat, 27 Jun 2026 13:52:23 +0800 Subject: [PATCH 1/2] ci(release): publish Win/Linux installers on tag (decoupled from macOS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds build-desktop (Linux deb+appimage, Windows nsis) + release-desktop jobs that attach the installers to the same GitHub Release on every v* tag. The build step is identical to the proven cross-platform-bundle spike. Decoupled from the macOS build/release jobs (mirrors the `mas` job): a flaky AppImage/NSIS build can't block a macOS release and vice versa. Unsigned today (Windows cert is the owner's call) and no auto-updater (Win/Linux updates are manual re-downloads) — both documented inline + in the hardening doc. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 128 +++++++++++++++++++++++++++++++ docs/CROSS-PLATFORM-HARDENING.md | 19 ++--- 2 files changed, 138 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2658059..d1b798a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,14 @@ env: # signed + notarized when the Developer ID secrets are populated, # unsigned when they're missing. # +# It also produces Windows + Linux installers (build-desktop → release-desktop): +# Linux .deb + .AppImage and a Windows NSIS .exe, appended to the same GitHub +# Release. These are UNSIGNED today (Windows code-signing needs a cert — see the +# build-desktop job) and ship WITHOUT the auto-updater (the updater latest.json +# stays macOS-only; Win/Linux users update by re-downloading). The desktop jobs +# are decoupled from the macOS build/release jobs — a Win/Linux failure can't +# block a macOS release, and vice versa. +# # Required secrets for signing (all six must be set, gated on # APPLE_TEAM_ID since it's the last to provision): # APPLE_CERTIFICATE_BASE64 .p12 with the Developer ID Application @@ -234,6 +242,88 @@ jobs: retention-days: 7 if-no-files-found: error + # Windows + Linux installers. Decoupled from the macOS build/release jobs (it + # neither blocks nor is blocked by them), so a flaky AppImage/NSIS build can't + # break a macOS release. UNSIGNED today: + # • Windows: SmartScreen warns until a code-signing cert is wired. To sign, + # add the cert to tauri.conf.json → bundle.windows (certificateThumbprint + + # timestampUrl, or a signCommand / Azure Trusted Signing) and pass its + # secrets here — mirror the HAS_*_SIGNING gate idiom used by the mac jobs. + # • Linux: .deb/.AppImage are conventionally unsigned; the repo is the trust + # anchor. + # No auto-updater artifacts (createUpdaterArtifacts is unset) — Win/Linux + # updates are manual re-downloads for now (docs/CROSS-PLATFORM-HARDENING.md P2). + build-desktop: + name: Build installers (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + bundles: deb,appimage + - os: windows-latest + bundles: nsis + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v6 + with: + version: 10 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + # Linux: WebKitGTK toolchain + libfuse2 (AppImage runtime needs FUSE 2). + - name: Install Linux system deps + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + libxdo-dev \ + libssl-dev \ + libfuse2 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + key: ${{ matrix.os }} + + - name: Install + run: pnpm install --frozen-lockfile + + # `--bundles` overrides the macOS-only config targets; createUpdaterArtifacts + # is unset so this needs no signing key. + - name: Build installers (${{ matrix.bundles }}) + run: pnpm tauri build --bundles ${{ matrix.bundles }} + + - name: Stage installers + shell: bash + run: | + set -euo pipefail + mkdir -p staged + find src-tauri/target/release/bundle -maxdepth 2 \ + \( -name '*.deb' -o -name '*.AppImage' -o -name '*.exe' \) \ + -exec cp {} staged/ \; + ls -lh staged/ + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: desktop-${{ matrix.os }} + path: staged/* + retention-days: 7 + if-no-files-found: error + # Mac App Store flavor: a sandboxed, Apple-Distribution-signed .pkg built by # scripts/build-mas.sh. Independent of the DMG build/release jobs (it neither # blocks nor is blocked by them), so a misconfig here can't break direct @@ -397,3 +487,41 @@ jobs: dmg-staging/*.app.tar.gz dmg-staging/latest.json dmg-staging/SHA256SUMS + + # Attach the Windows/Linux installers to the same GitHub Release. Separate from + # the macOS `release` job (which owns release-notes generation) so the two + # platforms can't block each other; action-gh-release upserts by tag, so this + # just adds files to the release the macOS job creates (or creates it if this + # wins the race — both are idempotent on the tag). + release-desktop: + needs: build-desktop + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Download desktop installer artifacts + uses: actions/download-artifact@v8 + with: + pattern: desktop-* + path: desktop-staging + merge-multiple: true + + - name: Compute SHA256SUMS (desktop) + run: | + set -euo pipefail + cd desktop-staging + shopt -s nullglob + ls -lh + shasum -a 256 *.deb *.AppImage *.exe > SHA256SUMS-desktop + cat SHA256SUMS-desktop + + - name: Add installers to the GitHub Release + uses: softprops/action-gh-release@v3 + with: + name: ${{ github.ref_name }} + tag_name: ${{ github.ref_name }} + fail_on_unmatched_files: false + files: | + desktop-staging/*.deb + desktop-staging/*.AppImage + desktop-staging/*.exe + desktop-staging/SHA256SUMS-desktop diff --git a/docs/CROSS-PLATFORM-HARDENING.md b/docs/CROSS-PLATFORM-HARDENING.md index 790b121..afdedf8 100644 --- a/docs/CROSS-PLATFORM-HARDENING.md +++ b/docs/CROSS-PLATFORM-HARDENING.md @@ -53,19 +53,20 @@ Ordered by user-visible impact. (`libfuse2` needed on the runner for AppImage) - Windows → `Markup_1.0.1_x64-setup.exe` (NSIS) - No bundler errors. **Remaining:** decide whether to bake these targets into - `tauri.conf.json` per-OS (vs CLI `--bundles`) and wire them into a real - release pipeline (today `release.yml` is macOS-only). `flatpak` / `msi` still - optional/unbuilt. + No bundler errors. ✅ **Now wired into `release.yml`** — the `build-desktop` + + `release-desktop` jobs build these on every `v*` tag and attach them to the + GitHub Release, decoupled from the macOS jobs (a Win/Linux failure can't block + a macOS release). `flatpak` / `msi` still optional/unbuilt. 4. **Code signing — owner's call.** The installers above are **unsigned**: Windows needs a **code-signing certificate** (EV or OV) or SmartScreen warns on every download; Linux AppImage/flatpak signing is lighter. Budget + procure the Windows cert (the real cost flagged in GTM §3). -5. **Updater per-platform.** The updater endpoint (`latest.json`) and signed - artifacts are currently macOS-only. Extend the release pipeline - (`.github/workflows/release.yml`) to build, sign, and publish Win/Linux - updater artifacts, or scope the updater to macOS and document manual updates - elsewhere. +5. **Updater scoped to macOS (decided).** `latest.json` + signed updater + artifacts stay macOS-only; Win/Linux ship installers **without** the + auto-updater, so those users update by re-downloading. Adding a Win/Linux + updater later means producing signed `createUpdaterArtifacts` bundles (needs + the Windows cert from item 4) and extending `latest.json` with + `windows-x86_64` / `linux-x86_64` entries. ### P3 — verify behaviour on the real webviews (manual, can't be done in headless CI) From e8af9e9b2206922c8bd30dcb4d4bf2d6647fbe5f Mon Sep 17 00:00:00 2001 From: oratis Date: Sat, 27 Jun 2026 13:56:19 +0800 Subject: [PATCH 2/2] feat(linux): opt-in headless token-file fallback when no Secret Service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux Secret Service needs a running keyring daemon, which headless/server boxes lack. keyring errors now carry an actionable hint, and setting MARKUP_TOKEN_FILE_FALLBACK=1 stores the GitHub token in a 0600 file under $XDG_DATA_HOME instead. Opt-in on purpose — never a silent downgrade to on-disk storage. Weaker than the keyring but still keeps the token out of the webview (the original threat); roundtrip + 0600 perms unit-tested (runs on the macOS CI host via cfg(unix)). Co-Authored-By: Claude Opus 4.8 --- docs/CROSS-PLATFORM-HARDENING.md | 16 +-- src-tauri/src/token_store.rs | 164 +++++++++++++++++++++++++++++-- 2 files changed, 165 insertions(+), 15 deletions(-) diff --git a/docs/CROSS-PLATFORM-HARDENING.md b/docs/CROSS-PLATFORM-HARDENING.md index afdedf8..d5a720f 100644 --- a/docs/CROSS-PLATFORM-HARDENING.md +++ b/docs/CROSS-PLATFORM-HARDENING.md @@ -36,12 +36,16 @@ Ordered by user-visible impact. `PendingOpenFiles` / `"open-files"` path. Double-clicking a `.md` should focus the running window and open the file. **Still needs a manual runtime check on real Windows/Linux** (CI only proves it compiles). -2. **✅ GitHub token credential store** — *done (resolution-verified).* `keyring` - is now declared per target: `apple-native` (macOS), `windows-native` - (Windows Credential Manager), `sync-secret-service` + `crypto-rust` (Linux - Secret Service via `dbus-secret-service`, pure-Rust crypto). ⚠️ Linux Secret - Service needs a running keyring daemon — **headless/server fallback still - undecided** (e.g. detect-and-warn, or an encrypted-file backend). +2. **✅ GitHub token credential store + headless fallback** — *done.* `keyring` + is declared per target: `apple-native` (macOS), `windows-native` (Windows + Credential Manager), `sync-secret-service` + `crypto-rust` (Linux Secret + Service via `dbus-secret-service`, pure-Rust crypto). **Headless Linux** + (no Secret Service daemon): keyring errors now carry an actionable hint, and + a user on a trusted box can opt in with **`MARKUP_TOKEN_FILE_FALLBACK=1`** to + store the token in a `0600` file under `$XDG_DATA_HOME` (`token_store.rs`). + Opt-in on purpose — we never silently downgrade to on-disk storage. It's + weaker than the keyring but still keeps the token out of the webview (the + threat the keyring move addressed); roundtrip + `0600` perms unit-tested. ### P2 — packaging & distribution (can't ship without) diff --git a/src-tauri/src/token_store.rs b/src-tauri/src/token_store.rs index d833b3e..c673d8e 100644 --- a/src-tauri/src/token_store.rs +++ b/src-tauri/src/token_store.rs @@ -1,11 +1,23 @@ -//! GitHub access-token storage in the macOS login Keychain. +//! GitHub access-token storage in the OS credential store. //! //! The desktop frontend used to keep the OAuth token in the webview's //! `localStorage`, where any script running in the webview could read it. -//! These commands move it into the Keychain (via the `keyring` crate's -//! Security-framework backend), keyed by the app's bundle identifier. The -//! frontend mirrors the value in a synchronous in-memory cache and calls -//! these to hydrate / persist it. +//! These commands move it into the platform credential store (via the +//! `keyring` crate — Keychain on macOS, Credential Manager on Windows, Secret +//! Service on Linux), keyed by the app's bundle identifier. The frontend +//! mirrors the value in a synchronous in-memory cache and calls these to +//! hydrate / persist it. +//! +//! ## Linux headless fallback +//! +//! Secret Service needs a running keyring daemon (gnome-keyring / KWallet), +//! which a headless or server box may not have. There, a user on a trusted +//! single-user machine can opt in with `MARKUP_TOKEN_FILE_FALLBACK=1` to store +//! the token in a `0600` file under `$XDG_DATA_HOME` instead. This is **weaker +//! than the system keyring** (a local process running as the same user can read +//! the file), but it still keeps the token out of the webview — the threat the +//! keyring move was made to address — and unblocks GitHub features. It is +//! **opt-in on purpose**: we never silently downgrade to on-disk storage. use keyring::{Entry, Error as KeyringError}; @@ -15,30 +27,164 @@ const SERVICE: &str = "com.appkon.markup"; const ACCOUNT: &str = "github-access-token"; fn entry() -> Result { - Entry::new(SERVICE, ACCOUNT).map_err(|e| e.to_string()) + Entry::new(SERVICE, ACCOUNT).map_err(annotate) +} + +/// On Linux, append an actionable hint about the headless file fallback to +/// keyring errors (the common cause is "no Secret Service daemon running"). +/// A no-op on macOS/Windows. +#[cfg(target_os = "linux")] +fn annotate(e: impl std::fmt::Display) -> String { + format!( + "{e}. If this machine has no Secret Service keyring (headless/server), \ + set MARKUP_TOKEN_FILE_FALLBACK=1 to store the token in a 0600 file under \ + $XDG_DATA_HOME (less secure than the system keyring)." + ) +} + +#[cfg(not(target_os = "linux"))] +fn annotate(e: impl std::fmt::Display) -> String { + e.to_string() } /// Read the stored token, or `None` when nothing is saved. #[tauri::command] pub fn github_token_load() -> Result, String> { + #[cfg(target_os = "linux")] + if file_fallback::enabled() { + return file_fallback::load(); + } match entry()?.get_password() { Ok(token) => Ok(Some(token)), Err(KeyringError::NoEntry) => Ok(None), - Err(e) => Err(e.to_string()), + Err(e) => Err(annotate(e)), } } /// Persist the token, overwriting any existing one. #[tauri::command] pub fn github_token_save(token: String) -> Result<(), String> { - entry()?.set_password(&token).map_err(|e| e.to_string()) + #[cfg(target_os = "linux")] + if file_fallback::enabled() { + return file_fallback::save(&token); + } + entry()?.set_password(&token).map_err(annotate) } /// Delete the stored token. Idempotent — a missing entry is treated as success. #[tauri::command] pub fn github_token_delete() -> Result<(), String> { + #[cfg(target_os = "linux")] + if file_fallback::enabled() { + return file_fallback::delete(); + } match entry()?.delete_credential() { Ok(()) | Err(KeyringError::NoEntry) => Ok(()), - Err(e) => Err(e.to_string()), + Err(e) => Err(annotate(e)), + } +} + +/// Opt-in plaintext token file for Linux headless boxes without a Secret +/// Service daemon. See the module docs for the threat-model rationale. +/// +/// Compiled on all Unix so its logic is unit-tested on the macOS CI host, but +/// only **activated** on Linux — see the `#[cfg(target_os = "linux")]` call +/// sites in the command fns above. +#[cfg(unix)] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +mod file_fallback { + use std::fs; + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + use std::path::PathBuf; + + /// True iff the user explicitly opted into the file fallback. + pub fn enabled() -> bool { + matches!( + std::env::var("MARKUP_TOKEN_FILE_FALLBACK").as_deref(), + Ok("1") | Ok("true") + ) + } + + /// `$XDG_DATA_HOME//github-token`, falling back to + /// `$HOME/.local/share//github-token`. + fn path() -> Result { + let base = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share"))) + .ok_or_else(|| "no XDG_DATA_HOME or HOME for the token file fallback".to_string())?; + Ok(base.join(super::SERVICE).join("github-token")) + } + + pub fn load() -> Result, String> { + let p = path()?; + match fs::read_to_string(&p) { + Ok(s) => { + let token = s.trim(); + Ok(if token.is_empty() { + None + } else { + Some(token.to_string()) + }) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.to_string()), + } + } + + pub fn save(token: &str) -> Result<(), String> { + let p = path()?; + if let Some(dir) = p.parent() { + fs::create_dir_all(dir).map_err(|e| e.to_string())?; + // Best-effort tighten the parent dir to owner-only. + let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700)); + } + // Create/truncate with 0600 from the start, so the token is never + // briefly world-readable. + let mut f = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&p) + .map_err(|e| e.to_string())?; + f.write_all(token.as_bytes()).map_err(|e| e.to_string())?; + Ok(()) + } + + pub fn delete() -> Result<(), String> { + match fs::remove_file(path()?) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.to_string()), + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn roundtrip_via_xdg_data_home() { + let tmp = std::env::temp_dir().join(format!("markup-tok-{}", std::process::id())); + let _ = fs::remove_dir_all(&tmp); + // SAFETY: single-threaded test; we set XDG_DATA_HOME for this process. + unsafe { std::env::set_var("XDG_DATA_HOME", &tmp) }; + + assert_eq!(load().unwrap(), None, "empty before save"); + save("ghp_example").unwrap(); + assert_eq!(load().unwrap(), Some("ghp_example".to_string())); + + // File must be owner-only (0600). + let mode = fs::metadata(path().unwrap()).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + + delete().unwrap(); + assert_eq!(load().unwrap(), None, "empty after delete"); + delete().unwrap(); // idempotent + + let _ = fs::remove_dir_all(&tmp); + } } }