diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index e6fd6c88..20a30a79 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1588,6 +1588,16 @@ dependencies = [ "cc", ] +[[package]] +name = "icns" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "467efeb2c1e570cebb9863f9173447f8fe92303d66adc97cb89ac824b60e7a0f" +dependencies = [ + "byteorder", + "png 0.18.1", +] + [[package]] name = "ico" version = "0.5.0" @@ -1810,6 +1820,11 @@ dependencies = [ name = "jcode-desktop" version = "0.5.3" dependencies = [ + "base64 0.22.1", + "icns", + "objc2-app-kit", + "objc2-foundation", + "plist", "serde", "serde_json", "tauri", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 8c9a2943..0004cd3b 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -28,6 +28,13 @@ tauri-plugin-single-instance = "2" tauri-plugin-window-state = "2" tauri-plugin-global-shortcut = "2" +[target.'cfg(target_os = "macos")'.dependencies] +base64 = "0.22" +icns = { version = "0.4", default-features = false, features = ["pngio"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString", "NSURL"] } +plist = "1" + [profile.release] panic = "abort" codegen-units = 1 diff --git a/desktop/src-tauri/src/desktop_apps.rs b/desktop/src-tauri/src/desktop_apps.rs new file mode 100644 index 00000000..ecca4583 --- /dev/null +++ b/desktop/src-tauri/src/desktop_apps.rs @@ -0,0 +1,357 @@ +//! Native macOS application discovery for the title-bar "Open in" menu. +//! +//! The frontend never guesses whether an editor is installed and never sends +//! an executable path. This module resolves a curated set of developer apps by +//! bundle identifier through Launch Services, extracts each installed app's +//! own icon, and accepts only the opaque IDs declared below when opening a +//! workspace. + +use serde::Serialize; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Copy)] +struct WorkspaceAppCandidate { + id: &'static str, + label: &'static str, + bundle_ids: &'static [&'static str], + group: &'static str, + reveal_in_finder: bool, +} + +const WORKSPACE_APP_CANDIDATES: &[WorkspaceAppCandidate] = &[ + WorkspaceAppCandidate { + id: "vscode", + label: "VS Code", + bundle_ids: &["com.microsoft.VSCode"], + group: "editor", + reveal_in_finder: false, + }, + WorkspaceAppCandidate { + id: "cursor", + label: "Cursor", + bundle_ids: &["com.todesktop.230313mzl4w4u92"], + group: "editor", + reveal_in_finder: false, + }, + WorkspaceAppCandidate { + id: "zed", + label: "Zed", + bundle_ids: &["dev.zed.Zed", "dev.zed.Zed-Preview"], + group: "editor", + reveal_in_finder: false, + }, + WorkspaceAppCandidate { + id: "antigravity", + label: "Antigravity", + bundle_ids: &["com.google.antigravity"], + group: "editor", + reveal_in_finder: false, + }, + WorkspaceAppCandidate { + id: "finder", + label: "Finder", + bundle_ids: &["com.apple.finder"], + group: "system", + reveal_in_finder: true, + }, + WorkspaceAppCandidate { + id: "terminal", + label: "Terminal", + bundle_ids: &["com.apple.Terminal"], + group: "system", + reveal_in_finder: false, + }, + WorkspaceAppCandidate { + id: "iterm", + label: "iTerm2", + bundle_ids: &["com.googlecode.iterm2"], + group: "system", + reveal_in_finder: false, + }, + WorkspaceAppCandidate { + id: "ghostty", + label: "Ghostty", + bundle_ids: &["com.mitchellh.ghostty"], + group: "system", + reveal_in_finder: false, + }, + WorkspaceAppCandidate { + id: "warp", + label: "Warp", + bundle_ids: &["dev.warp.Warp-Stable", "dev.warp.Warp"], + group: "system", + reveal_in_finder: false, + }, + WorkspaceAppCandidate { + id: "xcode", + label: "Xcode", + bundle_ids: &["com.apple.dt.Xcode"], + group: "system", + reveal_in_finder: false, + }, + WorkspaceAppCandidate { + id: "goland", + label: "GoLand", + bundle_ids: &["com.jetbrains.goland"], + group: "system", + reveal_in_finder: false, + }, +]; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceApplication { + id: &'static str, + label: &'static str, + group: &'static str, + icon_data_url: Option, +} + +#[tauri::command] +pub fn list_workspace_applications() -> Vec { + imp::list_workspace_applications() +} + +#[tauri::command] +pub fn open_workspace_in_application(path: String, app_id: String) -> Result<(), String> { + let workspace = validate_workspace_path(&path)?; + let candidate = candidate_by_id(&app_id) + .ok_or_else(|| format!("unsupported workspace application: {app_id}"))?; + imp::open_workspace(&workspace, candidate) +} + +fn candidate_by_id(id: &str) -> Option<&'static WorkspaceAppCandidate> { + WORKSPACE_APP_CANDIDATES + .iter() + .find(|candidate| candidate.id == id) +} + +fn validate_workspace_path(path: &str) -> Result { + let requested = PathBuf::from(path); + if !requested.is_absolute() { + return Err("workspace path must be absolute".to_string()); + } + + let canonical = requested + .canonicalize() + .map_err(|error| format!("workspace path is unavailable: {error}"))?; + if !canonical.is_dir() { + return Err("workspace path must be a directory".to_string()); + } + + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| "HOME is unavailable".to_string())?; + let canonical_home = home.canonicalize().unwrap_or(home); + if !is_allowed_workspace_root(&canonical, &canonical_home) { + return Err("workspace path is outside the allowed local roots".to_string()); + } + + Ok(canonical) +} + +fn is_allowed_workspace_root(path: &Path, home: &Path) -> bool { + path.starts_with(home) || path.starts_with("/Volumes") +} + +#[cfg(target_os = "macos")] +mod imp { + use super::{WorkspaceAppCandidate, WorkspaceApplication, WORKSPACE_APP_CANDIDATES}; + use base64::{engine::general_purpose::STANDARD, Engine as _}; + use icns::IconFamily; + use objc2_app_kit::NSWorkspace; + use objc2_foundation::{NSArray, NSString, NSURL}; + use plist::Value; + use std::fs::File; + use std::io::BufReader; + use std::path::{Path, PathBuf}; + use std::process::{Command, Stdio}; + + pub fn list_workspace_applications() -> Vec { + WORKSPACE_APP_CANDIDATES + .iter() + .filter_map(|candidate| { + let app_path = resolve_application(candidate)?; + Some(WorkspaceApplication { + id: candidate.id, + label: candidate.label, + group: candidate.group, + icon_data_url: app_icon_data_url(&app_path), + }) + }) + .collect() + } + + pub fn open_workspace( + workspace_path: &Path, + candidate: &WorkspaceAppCandidate, + ) -> Result<(), String> { + let app_path = resolve_application(candidate) + .ok_or_else(|| format!("{} is not installed", candidate.label))?; + + if candidate.reveal_in_finder { + reveal_in_finder(workspace_path); + return Ok(()); + } + + let output = Command::new("/usr/bin/open") + .arg("-a") + .arg(&app_path) + .arg("--") + .arg(workspace_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .map_err(|error| format!("could not start {}: {error}", candidate.label))?; + + if output.status.success() { + return Ok(()); + } + + let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if detail.is_empty() { + Err(format!( + "{} exited with status {}", + candidate.label, output.status + )) + } else { + Err(format!("could not open {}: {detail}", candidate.label)) + } + } + + fn resolve_application(candidate: &WorkspaceAppCandidate) -> Option { + let workspace = NSWorkspace::sharedWorkspace(); + for bundle_id in candidate.bundle_ids { + let identifier = NSString::from_str(bundle_id); + let Some(url) = workspace.URLForApplicationWithBundleIdentifier(&identifier) else { + continue; + }; + let Some(path) = url.path() else { + continue; + }; + let app_path = PathBuf::from(path.to_string()); + if app_path.is_dir() { + return Some(app_path); + } + } + None + } + + fn reveal_in_finder(path: &Path) { + let workspace = NSWorkspace::sharedWorkspace(); + let path = NSString::from_str(&path.to_string_lossy()); + let url = NSURL::fileURLWithPath(&path); + let urls = NSArray::from_retained_slice(&[url]); + workspace.activateFileViewerSelectingURLs(&urls); + } + + fn app_icon_data_url(app_path: &Path) -> Option { + let icon_path = app_icon_path(app_path)?; + let file = BufReader::new(File::open(icon_path).ok()?); + let family = IconFamily::read(file).ok()?; + let mut icon_types = family.available_icons().to_vec(); + icon_types.sort_by_key(|icon_type| { + let width = icon_type.pixel_width(); + if width >= 64 { + width - 64 + } else { + 10_000 + 64 - width + } + }); + + for icon_type in icon_types { + let Ok(image) = family.get_icon_with_type(icon_type) else { + continue; + }; + let mut png = Vec::new(); + if image.write_png(&mut png).is_ok() { + return Some(format!("data:image/png;base64,{}", STANDARD.encode(png))); + } + } + None + } + + fn app_icon_path(app_path: &Path) -> Option { + let info = Value::from_file(app_path.join("Contents/Info.plist")).ok()?; + let dictionary = info.as_dictionary()?; + let icon_name = dictionary + .get("CFBundleIconFile") + .and_then(Value::as_string) + .or_else(|| { + dictionary + .get("CFBundleIconName") + .and_then(Value::as_string) + })?; + + let mut icon_path = app_path.join("Contents/Resources").join(icon_name); + if icon_path.extension().is_none() { + icon_path.set_extension("icns"); + } + icon_path.is_file().then_some(icon_path) + } +} + +#[cfg(not(target_os = "macos"))] +mod imp { + use super::{WorkspaceAppCandidate, WorkspaceApplication}; + use std::path::Path; + + pub fn list_workspace_applications() -> Vec { + Vec::new() + } + + pub fn open_workspace( + _workspace_path: &Path, + _candidate: &WorkspaceAppCandidate, + ) -> Result<(), String> { + Err("workspace applications are only available on macOS".to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::{candidate_by_id, is_allowed_workspace_root}; + use std::path::Path; + + #[test] + fn candidate_lookup_accepts_only_declared_ids() { + assert_eq!( + candidate_by_id("vscode").map(|app| app.label), + Some("VS Code") + ); + assert!(candidate_by_id("../../Applications/Calculator.app").is_none()); + } + + #[test] + fn workspace_roots_are_scoped_to_home_and_volumes() { + let home = Path::new("/Users/tester"); + assert!(is_allowed_workspace_root( + Path::new("/Users/tester/work/jcode"), + home + )); + assert!(is_allowed_workspace_root( + Path::new("/Volumes/Workspace/jcode"), + home + )); + assert!(!is_allowed_workspace_root(Path::new("/tmp/jcode"), home)); + assert!(!is_allowed_workspace_root( + Path::new("/Users/another/jcode"), + home + )); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_discovery_returns_finder_with_its_native_icon() { + let applications = super::imp::list_workspace_applications(); + let finder = applications + .iter() + .find(|application| application.id == "finder") + .expect("Finder should be registered with Launch Services"); + assert!(finder + .icon_data_url + .as_deref() + .is_some_and(|icon| icon.starts_with("data:image/png;base64,"))); + } +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 50517542..12d9f790 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -1,6 +1,7 @@ // Prevents a stray console window on Windows in release builds. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod desktop_apps; mod shell_env; mod sidecar; mod tray; @@ -105,7 +106,11 @@ fn main() { .manage(SidecarHandle::default()) .manage(sidecar::SidecarPort::default()) .manage(DesktopState::default()) - .invoke_handler(tauri::generate_handler![get_sidecar_port]) + .invoke_handler(tauri::generate_handler![ + get_sidecar_port, + desktop_apps::list_workspace_applications, + desktop_apps::open_workspace_in_application + ]) .setup(|app| { // Start the backend FIRST so a (possibly cosmetic) tray failure can // never prevent the server — and thus the whole app — from coming up. diff --git a/internal/web/server.go b/internal/web/server.go index c831f76b..0900f4dc 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -685,22 +685,36 @@ func (s *Server) currentModelContextLimit(eng *Engine) int { } // handleWorkspace returns lightweight git workspace info (branch + dirty) for -// the current project so the web UI can show the real branch name. Diff stats +// the requested task, or the foreground project for legacy callers. Diff stats // are fetched separately via /api/diff. Empty branch = not a git repo. func (s *Server) handleWorkspace(w http.ResponseWriter, r *http.Request) { + pwd := s.activePwd() + if taskID := r.URL.Query().Get("task_id"); taskID != "" { + var err error + pwd, err = s.workspacePwdForTask(taskID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if pwd == "" { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "task workspace not found"}) + return + } + } + // Use the request context so the git commands are cancelled if the client // disconnects (CodeRabbit review feedback on PR #82). // `branch --show-current` (not `rev-parse --abbrev-ref HEAD`) so a freshly // initialised repo with no commits still reports its unborn branch (e.g. // "main") instead of the literal "HEAD". branchCmd := exec.CommandContext(r.Context(), "git", "branch", "--show-current") - branchCmd.Dir = s.activePwd() + branchCmd.Dir = pwd branchCmd.Env = utils.ScrubbedGitEnv() branchOut, _ := branchCmd.Output() branch := strings.TrimSpace(string(branchOut)) statusCmd := exec.CommandContext(r.Context(), "git", "status", "--porcelain") - statusCmd.Dir = s.activePwd() + statusCmd.Dir = pwd statusCmd.Env = utils.ScrubbedGitEnv() statusOut, _ := statusCmd.Output() dirty := strings.TrimSpace(string(statusOut)) != "" @@ -711,6 +725,24 @@ func (s *Server) handleWorkspace(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) workspacePwdForTask(taskID string) (string, error) { + if eng := s.resolveEngine(taskID); eng != nil { + return eng.pwd, nil + } + all, err := session.ListAllSessions() + if err != nil { + return "", fmt.Errorf("list task workspaces: %w", err) + } + for project, metas := range all { + for i := range metas { + if metas[i].UUID == taskID { + return project, nil + } + } + } + return "", nil +} + // --- Helpers --- func writeJSON(w http.ResponseWriter, status int, data any) { diff --git a/internal/web/tasks_test.go b/internal/web/tasks_test.go index 133ac448..2f0fd70f 100644 --- a/internal/web/tasks_test.go +++ b/internal/web/tasks_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -55,6 +56,43 @@ func TestWorkspaceNonGit(t *testing.T) { } } +func TestWorkspaceUsesRequestedTaskProject(t *testing.T) { + activeDir := initGitWorkspace(t, "active-branch") + taskDir := initGitWorkspace(t, "task-branch") + seedIndex(t, map[string][]session.SessionMeta{ + taskDir: {{UUID: "requested-task", Project: taskDir}}, + }) + s := &Server{ + Engine: &Engine{pwd: activeDir, taskID: "active-task"}, + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/workspace?task_id=requested-task", nil) + s.handleWorkspace(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String()) + } + var ws struct { + Branch string `json:"branch"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &ws); err != nil { + t.Fatalf("not JSON: %v", err) + } + if ws.Branch != "task-branch" { + t.Fatalf("branch=%q, want requested task branch", ws.Branch) + } +} + +func initGitWorkspace(t *testing.T, branch string) string { + t.Helper() + dir := t.TempDir() + cmd := exec.Command("git", "init", "-b", branch) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init: %v\n%s", err, out) + } + return dir +} + // P0-2: GET /api/tasks with no index returns an empty array (not null). func TestListAllTasksEmpty(t *testing.T) { seedIndex(t, map[string][]session.SessionMeta{}) diff --git a/web/src/App.tsx b/web/src/App.tsx index bebe37c5..81b0dd1c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -54,11 +54,13 @@ import { SetupView } from './components/SetupView' import { SettingsView } from './components/SettingsView' import { TopBar } from './components/TopBar' import { CloudSyncToggle } from './components/CloudSyncToggle' +import { DesktopTitlebar } from './components/DesktopTitlebar' import { ComputerShotPiP } from './components/ComputerShotPiP' import { RightPanel } from './components/RightPanel' import { TerminalPanel } from './components/TerminalPanel' import { RemoteConnectWizard } from './components/RemoteConnectWizard' import type { RemotePrefill } from './lib/remote' +import { isMacOSDesktop } from './lib/useDesktop' export default function App() { const dispatch = useAppDispatch() @@ -275,8 +277,19 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'cloud-mob
{/* Native title-bar drag strip — hidden in browser, shown in Tauri (CSS). */}