diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6deacc9..4475741 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,30 @@ jobs: - name: Run Rust tests run: cargo test --workspace + cross-platform: + name: cross-platform (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + + # The library crates carry the platform-sensitive logic (agent detection, + # git, persistence). Testing them on Windows and macOS guards against + # cross-platform regressions without the heavy WebView GUI build. + - name: Run library crate tests + run: cargo test -p opencherry_core -p opencherry_repo -p opencherry_agents -p opencherry_persistence + desktop-frontend: runs-on: ubuntu-latest diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml new file mode 100644 index 0000000..62d0de2 --- /dev/null +++ b/.github/workflows/release-macos.yml @@ -0,0 +1,82 @@ +name: Release (macOS) + +# Builds the OpenCherry desktop app as a macOS disk image (.dmg), for both +# Apple Silicon (aarch64) and Intel (x86_64). +# +# Triggers: +# - Push of a tag matching `v*` (e.g. v0.0.1) — attaches the .dmg files to the +# matching GitHub Release. +# - Manual `workflow_dispatch` — uploads the .dmg files as build artifacts +# only (no release), useful for contributors without a Mac. +# +# The images are unsigned and not notarized: on first launch macOS Gatekeeper +# may block the app (right-click → Open to bypass). + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-macos: + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + arch: aarch64 + - target: x86_64-apple-darwin + arch: x86_64 + runs-on: macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + with: + workspaces: ". -> target" + key: ${{ matrix.target }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: apps/desktop/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: apps/desktop + + - name: Build .dmg + run: pnpm tauri build --bundles dmg --target ${{ matrix.target }} + working-directory: apps/desktop + + - name: Upload .dmg artifact + uses: actions/upload-artifact@v4 + with: + name: opencherry-macos-${{ matrix.arch }}-dmg + path: target/${{ matrix.target }}/release/bundle/dmg/*.dmg + if-no-files-found: error + + - name: Attach .dmg to release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + files: target/${{ matrix.target }}/release/bundle/dmg/*.dmg diff --git a/AGENTS.md b/AGENTS.md index 29efedb..39f907d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,9 @@ E2E (separate package — do not run via root pnpm): ## CI expectations (`.github/workflows/ci.yml`) -Three jobs run on Ubuntu: `cargo test --workspace`, frontend `pnpm test` + `pnpm build`, and the desktop E2E (installs `tauri-driver` + `webkitgtk-webdriver`). Match these locally before pushing. +Four jobs: three on Ubuntu — `cargo test --workspace`, frontend `pnpm test` + `pnpm build`, and the desktop E2E (installs `tauri-driver` + `webkitgtk-webdriver`) — plus a `cross-platform` matrix that runs the library crate tests (`opencherry_core`/`repo`/`agents`/`persistence`) on `windows-latest` and `macos-latest`. Match these locally before pushing. Platform-specific logic (agent detection `.exe`/shell handling, path separators) must stay portable so the cross-platform job stays green. + +Release bundles are built by `release-linux.yml` (`.deb`), `release-windows.yml` (NSIS `.exe`), and `release-macos.yml` (`.dmg`, aarch64 + x86_64), triggered by `v*` tags or `workflow_dispatch`. ## Validation checklist after changes diff --git a/README.md b/README.md index b947cce..457fb66 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,24 @@ sudo apt install libwebkit2gtk-4.1-dev \ libayatana-appindicator3-dev ``` -#### Toolchain +#### System dependencies (macOS) -- Rust stable (1.80+) +- Xcode Command Line Tools: `xcode-select --install` + +WebKit (the WebView used by Tauri) ships with macOS, so there is nothing else +to install for the GUI. + +#### System dependencies (Windows) + +- Microsoft Visual Studio C++ Build Tools (MSVC linker) +- WebView2 runtime (preinstalled on Windows 10/11) + +`git2` builds with `vendored-libgit2`, so no system libgit2 or OpenSSL is +needed on any platform. + +#### Toolchain (all platforms) + +- Rust stable (1.80+) via `rustup` - Node.js 20+ - pnpm 9+ @@ -225,6 +240,33 @@ of the generated installer — `target\release\bundle\nsis\OpenCherry__ Double-click it to install. The installer is unsigned, so Windows SmartScreen may warn on first run; choose **More info → Run anyway**. +### macOS disk image (`.dmg`) + +macOS builds produce a `.dmg` disk image. There are two ways to get one. + +**Download from CI** — every tagged release (`v*`) attaches Apple Silicon +(`aarch64`) and Intel (`x86_64`) disk images to its GitHub Release. Manually +triggering the **Release (macOS)** workflow (`workflow_dispatch`) builds both +and uploads them as downloadable build artifacts. + +**Build locally on macOS** — install the Xcode Command Line Tools +(`xcode-select --install`), Rust stable via `rustup`, and Node.js 20+ / pnpm 9+. +Then, from the repo root: + +```bash +./scripts/build-macos.sh +``` + +This runs `pnpm -C apps/desktop tauri build --bundles dmg` and prints the path +of the generated image — `target/release/bundle/dmg/OpenCherry__.dmg`. +Open it and drag **OpenCherry** to Applications. The app is unsigned and not +notarized, so on first launch macOS Gatekeeper may block it; right-click the app +→ **Open**, or allow it in **System Settings → Privacy & Security**. + +To cross-compile for both architectures from one machine, add the targets with +`rustup target add aarch64-apple-darwin x86_64-apple-darwin` and set +`OPENCHERRY_MAC_TARGET` (e.g. `OPENCHERRY_MAC_TARGET=universal-apple-darwin`). + ## Testing The current codebase includes backend and frontend automated coverage for the diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index cce7830..2f5eab2 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -40,6 +40,14 @@ "desktopTemplate": "templates/opencherry.desktop" } }, + "macOS": { + "minimumSystemVersion": "10.15" + }, + "windows": { + "nsis": { + "installMode": "currentUser" + } + }, "icon": [ "icons/32x32.png", "icons/64x64.png", diff --git a/crates/opencherry_agents/src/lib.rs b/crates/opencherry_agents/src/lib.rs index 8461197..60700cc 100644 --- a/crates/opencherry_agents/src/lib.rs +++ b/crates/opencherry_agents/src/lib.rs @@ -81,11 +81,54 @@ pub fn classify_status(cpu_usage: f32, os_status: sysinfo::ProcessStatus) -> Age } } -/// Known interactive shell process names. -const SHELL_PROCESS_NAMES: &[&str] = &["bash", "zsh", "fish", "sh", "dash", "tmux", "screen"]; +/// Known interactive shell / terminal multiplexer process names. +/// +/// Includes Unix shells plus the common Windows shells and terminals, so the +/// `require_shell_parent` heuristic works on every supported platform. +const SHELL_PROCESS_NAMES: &[&str] = &[ + // Unix shells & multiplexers + "bash", + "zsh", + "fish", + "sh", + "dash", + "tmux", + "screen", + // Windows shells & terminals + "cmd", + "powershell", + "pwsh", + "wt", + "windowsterminal", + "conhost", + "nu", +]; + +/// Windows executable extensions to strip before comparing process names. +/// +/// On Windows `sysinfo` reports basenames such as `claude.exe` / `node.exe`, +/// but agent rules are written with bare names (`claude`, `node`). Stripping +/// the extension lets the same rule set match on every platform. +const WINDOWS_EXE_SUFFIXES: &[&str] = &[".exe", ".cmd", ".bat", ".com"]; + +/// Return `name` without a trailing Windows executable extension (if any). +fn strip_exe_suffix(name: &str) -> &str { + for ext in WINDOWS_EXE_SUFFIXES { + if name.len() > ext.len() { + let (base, suffix) = name.split_at(name.len() - ext.len()); + if suffix.eq_ignore_ascii_case(ext) { + return base; + } + } + } + name +} fn is_shell_process(exe_basename: &str) -> bool { - SHELL_PROCESS_NAMES.contains(&exe_basename) + let name = strip_exe_suffix(exe_basename); + SHELL_PROCESS_NAMES + .iter() + .any(|shell| shell.eq_ignore_ascii_case(name)) } /// Classify a process against a list of definitions. @@ -94,7 +137,9 @@ fn classify( all_processes: &[ProcessSnapshot], definitions: &[AgentDefinition], ) -> Option { - let exe_path_lc = process.exe_path.to_ascii_lowercase(); + // Normalize Windows path separators so the Unix-style substrings below + // match on every platform. + let exe_path_lc = process.exe_path.to_ascii_lowercase().replace('\\', "/"); // Universal exclusions (except for Antigravity CLI itself) if (exe_path_lc.contains("/.antigravity/extensions/") @@ -120,22 +165,23 @@ fn matches_rule( process: &ProcessSnapshot, all_processes: &[ProcessSnapshot], ) -> bool { - // 1. Exe basename match + // 1. Exe basename match (extension-insensitive so `claude.exe` on Windows + // matches a rule written as `claude`). if let Some(ref target) = rule.exe_basename { - if !process.exe_basename.eq_ignore_ascii_case(target) - && !process.process_name.eq_ignore_ascii_case(target) - { + let target = strip_exe_suffix(target); + let basename = strip_exe_suffix(&process.exe_basename); + let proc_name = strip_exe_suffix(&process.process_name); + if !basename.eq_ignore_ascii_case(target) && !proc_name.eq_ignore_ascii_case(target) { return false; } } - // 2. Exe path contains + // 2. Exe path contains (separator-insensitive: rules use `/`, Windows + // paths use `\`). if let Some(ref target) = rule.exe_path_contains { - if !process - .exe_path - .to_ascii_lowercase() - .contains(&target.to_ascii_lowercase()) - { + let haystack = process.exe_path.to_ascii_lowercase().replace('\\', "/"); + let needle = target.to_ascii_lowercase().replace('\\', "/"); + if !haystack.contains(&needle) { return false; } } @@ -150,13 +196,11 @@ fn matches_rule( } } - // 4. Exclude path contains + // 4. Exclude path contains (separator-insensitive, see rule 2). if let Some(ref target) = rule.exclude_path_contains { - if process - .exe_path - .to_ascii_lowercase() - .contains(&target.to_ascii_lowercase()) - { + let haystack = process.exe_path.to_ascii_lowercase().replace('\\', "/"); + let needle = target.to_ascii_lowercase().replace('\\', "/"); + if haystack.contains(&needle) { return false; } } @@ -430,6 +474,58 @@ mod tests { ); } + #[test] + fn classify_matches_windows_exe_basename() { + // On Windows sysinfo reports `opencode.exe`; a rule written as + // `opencode` must still match. + let defs = vec![def_opencode()]; + let proc = snapshot( + 123, + "C:\\Users\\me\\AppData\\opencode.exe", + "opencode.exe", + &["opencode.exe"], + false, + ); + assert_eq!( + classify(&proc, std::slice::from_ref(&proc), &defs), + Some(AgentKind::OpenCode) + ); + } + + #[test] + fn is_shell_process_recognizes_windows_shells() { + assert!(is_shell_process("powershell.exe")); + assert!(is_shell_process("pwsh.exe")); + assert!(is_shell_process("cmd.exe")); + assert!(is_shell_process("bash")); + assert!(!is_shell_process("opencode.exe")); + } + + #[test] + fn classify_matches_with_windows_shell_parent() { + let mut def = def_opencode(); + def.rules[0].require_shell_parent = true; + let defs = vec![def]; + + let shell = snapshot( + 100, + "C:\\Windows\\System32\\cmd.exe", + "cmd.exe", + &["cmd.exe"], + false, + ); + let proc = snapshot_with_parent( + 200, + Some(100), + "C:\\Users\\me\\opencode.exe", + "opencode.exe", + &["opencode.exe"], + false, + ); + let procs = vec![shell, proc.clone()]; + assert_eq!(classify(&proc, &procs, &defs), Some(AgentKind::OpenCode)); + } + #[test] fn classify_respects_shell_parent_requirement() { let mut def = def_opencode(); diff --git a/crates/opencherry_repo/src/lib.rs b/crates/opencherry_repo/src/lib.rs index 53fab83..b7b8214 100644 --- a/crates/opencherry_repo/src/lib.rs +++ b/crates/opencherry_repo/src/lib.rs @@ -766,6 +766,15 @@ mod tests { let path = root.path().join("repo"); fs::create_dir_all(&path).unwrap(); let repo = Repository::init(&path).unwrap(); + // Pin line-ending behavior so tests are deterministic across + // platforms. Windows CI runners default to `core.autocrlf=true` + // globally, which would rewrite checked-out `\n` content as `\r\n` + // and break exact-content assertions (e.g. in discard_files). + { + let mut config = repo.config().unwrap(); + config.set_bool("core.autocrlf", false).unwrap(); + config.set_str("core.eol", "lf").unwrap(); + } let this = Self { _root: root, path, diff --git a/justfile b/justfile index 70db0d5..eeff2f1 100644 --- a/justfile +++ b/justfile @@ -29,6 +29,14 @@ check: build-deb: ./scripts/build-deb.sh +# Build the production .dmg disk image (macOS) +build-macos: + ./scripts/build-macos.sh + +# Build the production .exe installer (Windows; run from PowerShell) +build-windows: + pwsh ./scripts/build-windows.ps1 + # List all available commands list: @just --list diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh new file mode 100755 index 0000000..4381026 --- /dev/null +++ b/scripts/build-macos.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# +# Build the OpenCherry desktop app as a macOS disk image (.dmg). +# +# Produces a single, unsigned, non-notarized disk image suitable for local +# install on macOS. No signing, no store — just `tauri build`. +# +# Usage: ./scripts/build-macos.sh +# Output: target/release/bundle/dmg/*.dmg (Cargo workspace target dir) +# +# Requirements: +# - macOS with the Xcode Command Line Tools (`xcode-select --install`). +# - Rust stable via rustup. +# - Node.js 20+ and pnpm 9+. +# git2 builds with `vendored-libgit2`, so no system libgit2/OpenSSL is needed. +# +# Apple Silicon vs Intel: by default this builds for the host architecture. +# Set OPENCHERRY_MAC_TARGET to a Rust target triple to cross-compile, e.g. +# OPENCHERRY_MAC_TARGET=universal-apple-darwin ./scripts/build-macos.sh +# (requires the matching `rustup target add` first; `universal-apple-darwin` +# produces a fat binary for both architectures). +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "==> ERROR: build-macos.sh must run on macOS (uname -s = $(uname -s))." >&2 + exit 1 +fi + +tauri_args=(--bundles dmg) +if [[ -n "${OPENCHERRY_MAC_TARGET:-}" ]]; then + echo "==> Building for target: ${OPENCHERRY_MAC_TARGET}" + tauri_args+=(--target "${OPENCHERRY_MAC_TARGET}") +fi + +echo "==> Building OpenCherry .dmg (release)…" +pnpm -C apps/desktop tauri build "${tauri_args[@]}" + +# This is a Cargo workspace, so the bundle lands in the workspace-root target/. +# With an explicit --target the path is nested under the triple. +if [[ -n "${OPENCHERRY_MAC_TARGET:-}" ]]; then + dmg_dir="target/${OPENCHERRY_MAC_TARGET}/release/bundle/dmg" +else + dmg_dir="target/release/bundle/dmg" +fi + +# Resolve the freshly built image by glob so version bumps don't break this. +dmg_file="$(ls -t "$dmg_dir"/*.dmg 2>/dev/null | head -n 1 || true)" + +if [[ -z "$dmg_file" ]]; then + echo "==> ERROR: no .dmg found in $dmg_dir" >&2 + exit 1 +fi + +echo +echo "==> Done. Disk image built:" +echo " $repo_root/$dmg_file" +echo +echo " Install by opening the .dmg and dragging OpenCherry to Applications." +echo " The app is unsigned, so on first launch macOS Gatekeeper may block it:" +echo " right-click the app → Open, or allow it in System Settings → Privacy."