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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
35 changes: 20 additions & 15 deletions docs/CROSS-PLATFORM-HARDENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -53,19 +57,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)

Expand Down
164 changes: 155 additions & 9 deletions src-tauri/src/token_store.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand All @@ -15,30 +27,164 @@ const SERVICE: &str = "com.appkon.markup";
const ACCOUNT: &str = "github-access-token";

fn entry() -> Result<Entry, String> {
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<Option<String>, 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/<service>/github-token`, falling back to
/// `$HOME/.local/share/<service>/github-token`.
fn path() -> Result<PathBuf, String> {
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<Option<String>, 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);
}
}
}
Loading