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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
82 changes: 82 additions & 0 deletions .github/workflows/release-macos.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 44 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+

Expand Down Expand Up @@ -225,6 +240,33 @@ of the generated installer — `target\release\bundle\nsis\OpenCherry_<version>_
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_<version>_<arch>.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
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@
"desktopTemplate": "templates/opencherry.desktop"
}
},
"macOS": {
"minimumSystemVersion": "10.15"
},
"windows": {
"nsis": {
"installMode": "currentUser"
}
},
"icon": [
"icons/32x32.png",
"icons/64x64.png",
Expand Down
136 changes: 116 additions & 20 deletions crates/opencherry_agents/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -94,7 +137,9 @@ fn classify(
all_processes: &[ProcessSnapshot],
definitions: &[AgentDefinition],
) -> Option<AgentKind> {
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/")
Expand All @@ -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;
}
}
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions crates/opencherry_repo/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading