From 98dce3500058e2629c09cdb8640fa3c957c6e7c6 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:35:23 +0800 Subject: [PATCH] perf(desktop): run the CLI from the macOS app binary instead of shipping a second engine copy --- .github/workflows/ci.yml | 47 +- .github/workflows/desktop-release.yml | 6 + Cargo.lock | 4 + apps/cli/src/lib.rs | 893 ++++++++++++++++++++++++++ apps/cli/src/main.rs | 871 +------------------------ apps/cli/src/record.rs | 2 +- apps/desktop-gpui/Cargo.lock | 785 +++++++++++++++++++++- apps/desktop-gpui/Cargo.toml | 2 + apps/desktop-gpui/README.md | 9 + apps/desktop-gpui/src/main.rs | 11 + crates/cli-launcher/Cargo.toml | 8 + crates/cli-launcher/src/main.rs | 38 ++ scripts/build-desktop-binaries.sh | 11 +- 13 files changed, 1795 insertions(+), 892 deletions(-) create mode 100644 apps/cli/src/lib.rs create mode 100644 crates/cli-launcher/Cargo.toml create mode 100644 crates/cli-launcher/src/main.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57810c00587..7778982e73a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,9 @@ jobs: - 'Cargo.lock' gpui: - '.gitattributes' + - 'apps/cli/**' - 'apps/desktop-gpui/**' + - 'crates/cli-launcher/**' - 'apps/desktop/src/assets/**' - 'apps/desktop/src-tauri/src/recording_start_sound.rs' - 'apps/media-server/src/__tests__/fixtures/**' @@ -279,7 +281,7 @@ jobs: - name: Test desktop regressions run: | cargo test --locked -p cap-utils -p cap-cli-install --lib - cargo test --locked -p cap --bin cap record::tests + cargo test --locked -p cap --lib record::tests cargo test --locked -p cap-recording --lib -- --test-threads=1 cargo test --locked -p cap-recording --test x11_shm_capture -- --test-threads=1 cargo test --locked -p cap-enc-ffmpeg @@ -370,6 +372,49 @@ jobs: fi cargo +1.95.0 test --manifest-path apps/desktop-gpui/Cargo.toml --locked --target ${{ matrix.settings.target }} -p cap-gpui-kernels + # On macOS the app binary is also the CLI: Contents/MacOS/cap-cli is a launcher that + # re-executes `Cap --cap-cli`. Run real commands through that path, laid out as in Cap.app. + - name: Test the macOS CLI launcher + if: ${{ runner.os == 'macOS' }} + shell: bash + run: | + set -euo pipefail + target=${{ matrix.settings.target }} + cargo +1.95.0 build --manifest-path apps/desktop-gpui/Cargo.toml --locked --target "$target" --bin cap-gpui + cargo build --locked -p cap-cli-launcher --target "$target" + app="$RUNNER_TEMP/Cap.app/Contents" + mkdir -p "$app/MacOS" "$app/Frameworks" + cp "apps/desktop-gpui/target/$target/debug/cap-gpui" "$app/MacOS/Cap" + cp "target/$target/debug/cap-cli-launcher" "$app/MacOS/cap-cli" + ln -sfn "$GITHUB_WORKSPACE/target/Frameworks/Spacedrive.framework" "$app/Frameworks/Spacedrive.framework" + run() { + "$@" > "$RUNNER_TEMP/out.txt" 2>&1 & + local pid=$! + for _ in $(seq 1 60); do + if ! kill -0 "$pid" 2>/dev/null; then + wait "$pid" + cat "$RUNNER_TEMP/out.txt" + return + fi + sleep 1 + done + kill "$pid" + cat "$RUNNER_TEMP/out.txt" + echo "::error::'$*' did not exit within 60 s (the app binary may have started its GUI)" + exit 1 + } + run "$app/MacOS/cap-cli" --version | tee "$RUNNER_TEMP/version.txt" + grep -q '^cap ' "$RUNNER_TEMP/version.txt" + run "$app/MacOS/cap-cli" project --help > /dev/null + home="$RUNNER_TEMP/home" + mkdir -p "$home" + touch "$home/.zshrc" + HOME="$home" SHELL=/bin/zsh run "$app/MacOS/cap-cli" desktop install-cli + [[ "$(readlink "$home/.cap/bin/cap")" == "$app/MacOS/cap-cli" ]] + HOME="$home" run "$home/.cap/bin/cap" --version | grep -q '^cap ' + HOME="$home" SHELL=/bin/zsh run "$home/.cap/bin/cap" desktop status | tee "$RUNNER_TEMP/status.txt" + grep -q 'installed: true' "$RUNNER_TEMP/status.txt" + tauri-plugins: name: Verify Tauri plugin versions runs-on: blacksmith-8vcpu-ubuntu-2404 diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index db087d09bc3..88e5cba253d 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -536,6 +536,12 @@ jobs: codesign --verify "$dmg" } verify_app "$CAP_BUNDLE/macos/Cap.app" Cap cap-muxer cap-cli + # cap-cli re-executes the app binary as `Cap --cap-cli`; the signed bundle's CLI must run. + cli_version="$("$CAP_BUNDLE/macos/Cap.app/Contents/MacOS/cap-cli" --version)" + if [[ "$cli_version" != cap\ * ]]; then + echo "::error::The bundled cap-cli did not run the CLI: $cli_version" + exit 1 + fi if [[ -e "$CAP_BUNDLE/macos/Cap.app/Contents/MacOS/cap-exporter" ]]; then echo "::error::Cap still bundles the duplicate cap-exporter sidecar." exit 1 diff --git a/Cargo.lock b/Cargo.lock index 31774499228..3fe8a0ee8f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1469,6 +1469,10 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "cap-cli-launcher" +version = "0.1.0" + [[package]] name = "cap-cpal-ffmpeg" version = "0.1.0" diff --git a/apps/cli/src/lib.rs b/apps/cli/src/lib.rs new file mode 100644 index 00000000000..a8302dc0fd3 --- /dev/null +++ b/apps/cli/src/lib.rs @@ -0,0 +1,893 @@ +mod account; +mod agent_auth; +mod agent_client; +mod agents; +mod analytics; +mod atomic; +mod automation; +mod caps; +mod confirmation; +mod credentials; +mod developers; +mod doctor; +mod export; +mod guide; +mod jobs; +mod library; +mod mcp; +mod notifications; +mod organizations; +mod project; +mod record; +mod recordings; +mod screenshot; +mod selftest; +mod session; +mod targets; +mod update; +mod upload; + +use std::{ + io::{IsTerminal, Write, stderr, stdout}, + path::PathBuf, +}; + +use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use export::{Export, ExportPreview}; +use record::RecordStart; +use serde::Serialize; +use tracing_subscriber::{filter::LevelFilter, layer::SubscriberExt, util::SubscriberInitExt}; + +const TOKIO_WORKER_THREAD_STACK_SIZE: usize = 16 * 1024 * 1024; +const ANSI_RESET: &str = "\x1b[0m"; +const ANSI_BOLD: &str = "\x1b[1m"; +const ANSI_BLUE: &str = "\x1b[38;2;71;133;255m"; +const ANSI_SOFT_BLUE: &str = "\x1b[38;2;173;201;255m"; +const ANSI_WHITE: &str = "\x1b[38;2;255;255;255m"; +const ANSI_MUTED: &str = "\x1b[38;2;118;128;145m"; + +const WELCOME_LINES: &[&[(&str, &str)]] = &[ + &[(ANSI_BLUE, " ██████████")], + &[ + (ANSI_BLUE, " ███"), + (ANSI_SOFT_BLUE, "████████"), + (ANSI_BLUE, "███"), + (ANSI_BOLD, " ____"), + ], + &[ + (ANSI_BLUE, " ███"), + (ANSI_SOFT_BLUE, "██"), + (ANSI_WHITE, "████████"), + (ANSI_SOFT_BLUE, "██"), + (ANSI_BLUE, "███"), + (ANSI_BOLD, " / ___|__ _ _ __"), + ], + &[ + (ANSI_BLUE, " ██"), + (ANSI_SOFT_BLUE, "██"), + (ANSI_WHITE, "██████████"), + (ANSI_SOFT_BLUE, "██"), + (ANSI_BLUE, "██"), + (ANSI_BOLD, " | | / _` | '_ \\"), + ], + &[ + (ANSI_BLUE, " ██"), + (ANSI_SOFT_BLUE, "█"), + (ANSI_WHITE, "████████████"), + (ANSI_SOFT_BLUE, "█"), + (ANSI_BLUE, "██"), + (ANSI_BOLD, " | |__| (_| | |_) |"), + ], + &[ + (ANSI_BLUE, " ██"), + (ANSI_SOFT_BLUE, "██"), + (ANSI_WHITE, "██████████"), + (ANSI_SOFT_BLUE, "██"), + (ANSI_BLUE, "██"), + (ANSI_BOLD, " \\____\\__,_| .__/"), + ], + &[ + (ANSI_BLUE, " ███"), + (ANSI_SOFT_BLUE, "██"), + (ANSI_WHITE, "████████"), + (ANSI_SOFT_BLUE, "██"), + (ANSI_BLUE, "███"), + (ANSI_BOLD, " |_|"), + ], + &[ + (ANSI_BLUE, " ███"), + (ANSI_SOFT_BLUE, "████████"), + (ANSI_BLUE, "███"), + ], + &[(ANSI_BLUE, " ██████████")], + &[], + &[( + ANSI_BOLD, + " Record, edit, and share screen recordings from the command line.", + )], + &[], + &[(ANSI_BLUE, " cap record start --screen --detach")], + &[( + ANSI_MUTED, + " cap targets cap doctor cap guide --json cap --help", + )], +]; + +/// Long-form help epilogue. Agents read `cap --help` before doing anything, so the conventions they +/// need to drive the CLI correctly (JSON on stdout, env vars, the canonical workflow) live here. +const AGENT_HELP: &str = "\ +OUTPUT + Pass --json (global) for machine-readable JSON on stdout; stderr stays human-readable. + stdout is the authoritative result. On failure the process exits non-zero and, in --json + mode, prints a final object/event containing an \"error\" field. Streaming commands (record, + export) emit newline-delimited JSON (NDJSON) events. Run `cap guide --json` for the full + machine-readable capability + schema manifest. + +AUTH + `cap upload` authenticates automatically by reusing the login Cap Desktop already stored — no + key to copy when you are signed in there. Check with `cap auth status --json`. For headless/CI, + create a CLI API key in the Cap dashboard under Settings -> Account and set it as CAP_API_KEY. + +ENVIRONMENT + CAP_API_KEY Overrides auth (CLI API key from the Cap dashboard, Settings -> Account); + optional when signed into Cap Desktop. + CAP_SERVER_URL Cap server base URL; defaults to Cap Desktop's server, else https://cap.so. + CAP_NO_MODIFY_PATH Set to skip editing shell profiles during `cap desktop install-cli`. + CAP_DESKTOP_FORCE_INSTALL + Force the web installer scripts to replace Cap Desktop before linking the CLI. + +TYPICAL AGENT WORKFLOW + cap doctor --json # verify permissions & capture readiness + cap targets --json # discover screens/windows/cameras/mics + cap record start --screen --json --detach # start in background -> {recordingId, pid, path} + cap record stop --id --json # finalize the .cap recording + cap project validate --json # confirm the recording is complete + cap export --output out.mp4 --json + cap upload out.mp4 --json # get a shareable link (needs CAP_API_KEY)"; + +#[derive(Parser)] +#[command( + name = "cap", + version, + about = "Cap screen recording from the command line", + long_about = "Cap screen recording from the command line.\n\nDesigned to be driven by automation and AI agents: add --json to any command for \ +machine-readable output. See the sections below for the JSON convention, environment variables, and \ +the canonical record -> export -> upload workflow.", + after_help = AGENT_HELP, + after_long_help = AGENT_HELP +)] +struct Cli { + #[arg(long, value_enum, global = true, default_value_t = CliLogLevel::Warn)] + log_level: CliLogLevel, + /// Emit machine-readable JSON to stdout (overrides each command's --format) + #[arg(long, global = true)] + json: bool, + #[command(subcommand)] + command: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum CliLogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +impl CliLogLevel { + fn level_filter(self) -> LevelFilter { + match self { + Self::Trace => LevelFilter::TRACE, + Self::Debug => LevelFilter::DEBUG, + Self::Info => LevelFilter::INFO, + Self::Warn => LevelFilter::WARN, + Self::Error => LevelFilter::ERROR, + } + } +} + +#[derive(Subcommand)] +enum Commands { + /// Export a '.cap' project to a video file + Export(Export), + /// Render an export preview frame + ExportPreview(ExportPreview), + /// Inspect or validate a '.cap' project + Project(ProjectArgs), + /// Start a recording or list available capture targets and devices + Record(RecordArgs), + /// Capture a still screenshot of a screen or window + Screenshot(screenshot::Screenshot), + /// List recordings discovered in the desktop library (or a custom directory) + Recordings(RecordingsArgs), + /// Upload a recording or video file and get a shareable link + Upload(upload::UploadArgs), + /// Update Cap Desktop and the bundled CLI + Update(FormatArgs), + /// Show how `cap upload` will authenticate (env key or Cap Desktop login) + Auth(AuthArgs), + /// Read and manage Caps in your personal library + Caps(caps::CapsArgs), + /// Read or update the authenticated Cap account + Account(account::AccountArgs), + /// Inspect Cap organizations, members, billing, and storage connections + Organizations(organizations::OrganizationsArgs), + /// Manage folders, spaces, and space membership + Library(library::LibraryArgs), + /// Read and manage account notifications + Notifications(notifications::NotificationsArgs), + /// Read organization, space, or Cap analytics + Analytics(analytics::AnalyticsArgs), + /// Inspect developer apps, domains, usage, and credits + Developers(developers::DevelopersArgs), + /// Inspect or wait for asynchronous Cap operations + Jobs(jobs::JobsArgs), + /// Run Cap's local Model Context Protocol server + Mcp(mcp::McpArgs), + /// Install Cap integrations for one explicitly selected agent + Agents(agents::AgentsArgs), + /// List available capture targets and devices + Targets(TargetsArgs), + /// Report CLI environment and capture-readiness diagnostics + Doctor(FormatArgs), + /// Run end-to-end diagnostics that verify Cap works on this machine + Selftest(selftest::SelftestArgs), + /// Print CLI version and execution context + Version(FormatArgs), + /// Inspect or manage the desktop-installed `cap` shim + Desktop(DesktopArgs), + /// Print the machine-readable capability & JSON-schema manifest for agents + Guide(FormatArgs), + /// List automation rules shared with Cap Desktop + Automations(AutomationsArgs), + /// Generate shell completion scripts + Completions(CompletionsArgs), +} + +impl Commands { + fn exit_after_success(&self) -> bool { + // Selftest runs an export, so it shares export's teardown-crash + // avoidance on Windows. + matches!( + self, + Self::Export(_) | Self::ExportPreview(_) | Self::Selftest(_) + ) + } +} + +impl Cli { + fn exit_after_success(&self) -> bool { + self.command + .as_ref() + .is_some_and(Commands::exit_after_success) + } +} + +#[derive(Args)] +#[command(args_conflicts_with_subcommands = true)] +struct RecordArgs { + #[command(subcommand)] + command: Option, + + #[command(flatten)] + args: RecordStart, +} + +#[derive(Subcommand)] +enum RecordCommands { + /// Start a recording (use --detach to run in the background and stop later) + Start(RecordStart), + /// Stop a detached recording started with `cap record start --detach` + Stop(record::RecordStopArgs), + /// List active and recent detached recording sessions + Status(FormatArgs), + /// Internal: background worker for detached recordings (do not call directly) + #[command(name = "__session-run", hide = true)] + SessionRun(record::SessionRunArgs), + /// List screens available for capturing + Screens(FormatArgs), + /// List windows available for capturing + Windows(FormatArgs), + /// List cameras available for capturing + Cameras(FormatArgs), + /// List microphones available for capturing + Mics(FormatArgs), +} + +#[derive(Args)] +#[command(args_conflicts_with_subcommands = true)] +struct TargetsArgs { + #[command(subcommand)] + command: Option, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Subcommand)] +enum TargetCommands { + /// List screens available for capturing + Screens(FormatArgs), + /// List windows available for capturing + Windows(FormatArgs), + /// List cameras available for capturing + Cameras(FormatArgs), + /// List microphones available for capturing + Mics(FormatArgs), +} + +#[derive(Args)] +struct FormatArgs { + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub enum OutputFormat { + Text, + Json, +} + +#[derive(Args)] +struct ProjectArgs { + #[command(subcommand)] + command: ProjectCommands, +} + +#[derive(Subcommand)] +enum ProjectCommands { + /// Print project metadata and editor configuration + Inspect(ProjectTarget), + /// Verify a project's metadata and expected media files exist + Validate(ProjectTarget), + /// Read or write a project's editor configuration (project-config.json) + Config(ProjectConfigArgs), +} + +#[derive(Args)] +struct ProjectTarget { + project_path: PathBuf, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct ProjectConfigArgs { + #[command(subcommand)] + command: ProjectConfigCommands, +} + +#[derive(Subcommand)] +enum ProjectConfigCommands { + /// Print the project's editor configuration as JSON + Get(ProjectTarget), + /// Replace the project's editor configuration from a full JSON document + Set(ProjectConfigSet), +} + +#[derive(Args)] +struct ProjectConfigSet { + project_path: PathBuf, + /// Full ProjectConfiguration as a JSON string (camelCase keys); omitted fields reset to defaults + #[arg(long)] + settings_json: String, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct RecordingsArgs { + #[command(subcommand)] + command: RecordingsCommands, +} + +#[derive(Subcommand)] +enum RecordingsCommands { + /// List '.cap' recordings discovered on disk + List(RecordingsListArgs), +} + +#[derive(Args)] +struct RecordingsListArgs { + /// Directory to scan (defaults to the desktop recordings library) + #[arg(long)] + dir: Option, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, +} + +#[derive(Args)] +struct DesktopArgs { + #[command(subcommand)] + command: DesktopCommands, +} + +#[derive(Subcommand)] +enum DesktopCommands { + /// Show whether the `cap` shim is installed and on PATH + Status(FormatArgs), + /// Install the `cap` shim onto your PATH + InstallCli(FormatArgs), + /// Remove the `cap` shim from your PATH + UninstallCli(FormatArgs), +} + +#[derive(Args)] +struct AuthArgs { + #[command(subcommand)] + command: AuthCommands, +} + +#[derive(Subcommand)] +enum AuthCommands { + /// Report whether a credential is available and where it comes from (never prints the secret) + Status(FormatArgs), + /// Authorize Cap CLI in the browser using PKCE + Login(agent_auth::LoginArgs), + /// Revoke and remove the Cap CLI credential + Logout(agent_auth::LogoutArgs), +} + +#[derive(Args)] +struct AutomationsArgs { + #[command(subcommand)] + command: AutomationsCommands, +} + +#[derive(Subcommand)] +enum AutomationsCommands { + /// List the automation rules configured in Cap Desktop + List(FormatArgs), +} + +#[derive(Args)] +struct CompletionsArgs { + #[arg(value_enum)] + shell: clap_complete::Shell, +} + +/// Whether this process is the Cap app binary running the CLI (`Cap --cap-cli ...`), in which +/// case re-running the CLI has to go through the app binary with that flag again. +static EMBEDDED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); + +/// The CLI inside the macOS app binary: `Contents/MacOS/cap-cli` re-executes `Cap --cap-cli`, so +/// the app ships one copy of the engine instead of two. `args` starts with the program name. +pub fn run_embedded(args: impl IntoIterator) { + let _ = EMBEDDED.set(()); + run_main(args); +} + +/// A command that runs this CLI again: the standalone binary, or the app binary with +/// `--cap-cli` when the CLI is embedded in it. +pub(crate) fn cli_command(exe: &std::path::Path) -> std::process::Command { + let mut command = std::process::Command::new(exe); + if EMBEDDED.get().is_some() { + command.arg("--cap-cli"); + } + command +} + +pub fn run_main(args: impl IntoIterator) { + #[cfg(target_os = "linux")] + if let Some(threads) = cap_utils::linux_runtime::llvmpipe_thread_count() { + // Mesa counts host CPUs inside containers; configure it before creating the CLI runtime. + unsafe { + std::env::set_var("LP_NUM_THREADS", threads.to_string()); + } + } + + #[cfg(windows)] + { + use windows::Win32::UI::HiDpi::{PROCESS_PER_MONITOR_DPI_AWARE, SetProcessDpiAwareness}; + + let _ = unsafe { SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE) }; + } + + let cli = Cli::parse_from(args); + let level_filter = cli.log_level.level_filter(); + + let registry = tracing_subscriber::registry().with(tracing_subscriber::filter::filter_fn( + // The binary crate is named `cap`, so its own spans/events have target `cap`/`cap::…`, + // which a bare `cap_` prefix excludes — keep those alongside the `cap_*` library crates. + (|v| { + let target = v.target(); + target == "cap" || target.starts_with("cap::") || target.starts_with("cap_") + }) as fn(&tracing::Metadata) -> bool, + )); + + registry + .with(level_filter) + .with( + tracing_subscriber::fmt::layer() + .with_ansi(true) + .with_target(true) + .with_writer(stderr), + ) + .init(); + + let _diagnostic_guard = if std::env::var_os("CAP_DIAGNOSTIC_PARENT").is_some() { + let (writer, guard) = tracing_appender::non_blocking(std::io::stderr()); + let queue_errors = writer.error_counter(); + cap_utils::operation_diagnostics::install_queue_loss_counter(move || { + queue_errors.dropped_lines() + }); + cap_utils::operation_diagnostics::install_sink( + cap_utils::operation_diagnostics::AppInfo { + flavor: "export_worker", + version: env!("CARGO_PKG_VERSION"), + source_revision: option_env!("CAP_BUILD_REVISION"), + debug_build: cfg!(debug_assertions), + source_dirty: option_env!("CAP_BUILD_DIRTY").map(|value| value == "true"), + }, + move |bytes| { + use std::io::Write; + let _ = writer.clone().write_all(bytes); + }, + ); + Some(guard) + } else { + None + }; + + let exit_after_success = cli.exit_after_success(); + + // The self-test opens a window, which AppKit requires to live on the real + // process main thread — so the main thread stays here to serve pattern + // requests while the command itself runs on the runtime thread. + let pattern_rx = matches!(cli.command, Some(Commands::Selftest(_))) + .then(selftest::pattern::install_main_thread_runner); + + // Windows export exercises deep WGPU/MediaFoundation/FFmpeg stacks. Running the CLI runtime + // on an explicitly large stack is what stopped the export worker from overflowing before + // the first frame; keep the sidecar and desktop runtimes in sync. + let runtime_thread = std::thread::Builder::new() + .name("cap-cli-runtime".to_string()) + .stack_size(TOKIO_WORKER_THREAD_STACK_SIZE) + .spawn(move || -> Result<(), String> { + // serve_main_thread blocks the main thread until this shutdown + // runs; a drop guard keeps that true on every exit path, including + // the runtime failing to build and panics unwinding out of run(). + struct PatternShutdown; + impl Drop for PatternShutdown { + fn drop(&mut self) { + selftest::pattern::shutdown_main_thread_runner(); + } + } + let _pattern_shutdown = PatternShutdown; + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(TOKIO_WORKER_THREAD_STACK_SIZE) + .build() + .map_err(|e| format!("Failed to build Tokio runtime: {e}"))?; + + if _diagnostic_guard.is_some() { + drop(runtime.spawn(cap_utils::operation_diagnostics::run_checkpoints())); + } + let result = runtime.block_on(run(cli)); + drop(_diagnostic_guard); + if exit_after_success && result.is_ok() { + // Successful export/preview workers have already written their output by here. + // Exiting directly avoids Windows GPU/MediaFoundation teardown crashes in the + // short-lived sidecar process. + let _ = stdout().flush(); + let _ = stderr().flush(); + std::process::exit(0); + } + + result + }); + + // A failed spawn means nothing will ever call shutdown_main_thread_runner, so the + // pattern server below would block forever — bail out before serving. + let runtime_thread = match runtime_thread { + Ok(handle) => handle, + Err(e) => { + eprintln!("error: Failed to spawn CLI runtime thread: {e}"); + std::process::exit(1); + } + }; + + if let Some(rx) = pattern_rx { + selftest::pattern::serve_main_thread(rx); + } + + // Surface failures as a clean, unquoted `error: ...` line on stderr (the default + // `Result`-returning main prints `Error: "debug-quoted"`, which is noisy for humans and brittle + // for agents scraping stderr). clap already exits 2 for usage/parse errors before we get here. + let outcome = runtime_thread.join(); + + match outcome { + Ok(Ok(())) => {} + Ok(Err(message)) => { + eprintln!("error: {message}"); + std::process::exit(1); + } + Err(_) => { + eprintln!("error: CLI runtime thread panicked"); + std::process::exit(1); + } + } +} + +async fn run(cli: Cli) -> Result<(), String> { + let json = cli.json; + let Some(command) = cli.command else { + return print_welcome(json); + }; + + match command { + Commands::Export(e) => e.run(json).await, + Commands::ExportPreview(e) => e.run().await, + Commands::Selftest(args) => args.run(json).await, + Commands::Project(args) => args.run(json), + Commands::Record(RecordArgs { command, args }) => match command { + Some(RecordCommands::Start(args)) => args.run(json).await, + Some(RecordCommands::Stop(args)) => args.run(json).await, + Some(RecordCommands::Status(args)) => { + let format = resolve_format(json, args.format); + finish_json(format, record::status(format)) + } + Some(RecordCommands::SessionRun(args)) => args.run().await, + Some(RecordCommands::Screens(args)) => { + let format = resolve_format(json, args.format); + finish_json(format, targets::print_screens(format)) + } + Some(RecordCommands::Windows(args)) => { + let format = resolve_format(json, args.format); + finish_json(format, targets::print_windows(format)) + } + Some(RecordCommands::Cameras(args)) => { + let format = resolve_format(json, args.format); + finish_json(format, targets::print_cameras(format)) + } + Some(RecordCommands::Mics(args)) => { + let format = resolve_format(json, args.format); + finish_json(format, targets::print_mics(format)) + } + None => args.run(json).await, + }, + Commands::Screenshot(s) => s.run(json).await, + Commands::Recordings(args) => args.run(json), + Commands::Upload(args) => args.run(json).await, + Commands::Update(args) => { + let format = resolve_format(json, args.format); + finish_json(format, update::run(format)) + } + Commands::Auth(args) => match args.command { + AuthCommands::Status(args) => { + let format = resolve_format(json, args.format); + finish_json(format, credentials::status(format).await) + } + AuthCommands::Login(args) => args.run(json).await, + AuthCommands::Logout(args) => args.run(json).await, + }, + Commands::Caps(args) => args.run(json).await, + Commands::Account(args) => args.run(json).await, + Commands::Organizations(args) => args.run(json).await, + Commands::Library(args) => args.run(json).await, + Commands::Notifications(args) => args.run(json).await, + Commands::Analytics(args) => args.run(json).await, + Commands::Developers(args) => args.run(json).await, + Commands::Jobs(args) => args.run(json).await, + Commands::Mcp(args) => args.run().await, + Commands::Agents(args) => args.run(json), + Commands::Targets(args) => args.run(json), + Commands::Doctor(args) => doctor::run_doctor(resolve_format(json, args.format)).await, + Commands::Version(args) => { + let format = resolve_format(json, args.format); + finish_json(format, doctor::run_version(format)) + } + Commands::Desktop(args) => args.run(json), + Commands::Guide(args) => { + let format = resolve_format(json, args.format); + finish_json(format, guide::run(format)) + } + Commands::Automations(args) => match args.command { + AutomationsCommands::List(a) => { + let format = resolve_format(json, a.format); + finish_json(format, automation::list(format)) + } + }, + Commands::Completions(args) => { + args.run(); + Ok(()) + } + } +} + +fn print_welcome(json: bool) -> Result<(), String> { + if json { + return write_json(&serde_json::json!({ + "name": "cap", + "about": "Cap screen recording from the command line", + "commands": ["record", "targets", "doctor", "guide", "upload"], + })); + } + + let ansi = stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none(); + let mut stdout = stdout(); + for line in WELCOME_LINES { + for segment in *line { + let (style, text) = *segment; + if ansi && !text.is_empty() { + write!(stdout, "{style}{text}{ANSI_RESET}").map_err(|e| e.to_string())?; + } else { + write!(stdout, "{text}").map_err(|e| e.to_string())?; + } + } + writeln!(stdout).map_err(|e| e.to_string())?; + } + stdout.flush().map_err(|e| e.to_string()) +} + +/// `--json` is a global convenience that forces JSON regardless of a command's local `--format` +/// (which stays for back-compat). Either one selecting JSON wins. +pub fn resolve_format(global_json: bool, local: OutputFormat) -> OutputFormat { + if global_json { + OutputFormat::Json + } else { + local + } +} + +impl TargetsArgs { + fn run(self, json: bool) -> Result<(), String> { + let (format, result) = match self.command { + Some(TargetCommands::Screens(args)) => { + let format = resolve_format(json, args.format); + (format, targets::print_screens(format)) + } + Some(TargetCommands::Windows(args)) => { + let format = resolve_format(json, args.format); + (format, targets::print_windows(format)) + } + Some(TargetCommands::Cameras(args)) => { + let format = resolve_format(json, args.format); + (format, targets::print_cameras(format)) + } + Some(TargetCommands::Mics(args)) => { + let format = resolve_format(json, args.format); + (format, targets::print_mics(format)) + } + None => { + let format = resolve_format(json, self.format); + (format, targets::print_all(format)) + } + }; + finish_json(format, result) + } +} + +impl ProjectArgs { + fn run(self, json: bool) -> Result<(), String> { + match self.command { + // validate emits its own structured report (incl. JSON errors), so it is not wrapped. + ProjectCommands::Validate(args) => { + project::validate(args.project_path, resolve_format(json, args.format)) + } + ProjectCommands::Inspect(args) => { + let format = resolve_format(json, args.format); + finish_json(format, project::inspect(args.project_path, format)) + } + ProjectCommands::Config(ProjectConfigArgs { command }) => match command { + ProjectConfigCommands::Get(args) => finish_json( + resolve_format(json, args.format), + project::config_get(args.project_path), + ), + ProjectConfigCommands::Set(args) => { + let format = resolve_format(json, args.format); + finish_json( + format, + project::config_set(args.project_path, &args.settings_json, format), + ) + } + }, + } + } +} + +impl RecordingsArgs { + fn run(self, json: bool) -> Result<(), String> { + match self.command { + RecordingsCommands::List(args) => { + let format = resolve_format(json, args.format); + finish_json(format, recordings::list(args.dir, format)) + } + } + } +} + +impl DesktopArgs { + fn run(self, json: bool) -> Result<(), String> { + let (format, result) = match self.command { + DesktopCommands::Status(args) => { + let format = resolve_format(json, args.format); + ( + format, + emit_install_status(cap_cli_install::status(), format), + ) + } + DesktopCommands::InstallCli(args) => { + let format = resolve_format(json, args.format); + ( + format, + emit_install_status(cap_cli_install::install(), format), + ) + } + DesktopCommands::UninstallCli(args) => { + let format = resolve_format(json, args.format); + ( + format, + emit_install_status(cap_cli_install::uninstall(), format), + ) + } + }; + finish_json(format, result) + } +} + +/// When `--format json` is requested and a command fails before it could emit its own structured +/// output, write a machine-readable `{"error": "..."}` to stdout so agents do not have to scrape +/// the human-readable stderr line. The original `Err` still propagates for a non-zero exit code. +fn finish_json(format: OutputFormat, result: Result<(), String>) -> Result<(), String> { + if format == OutputFormat::Json + && let Err(message) = &result + { + let _ = write_json(&serde_json::json!({ "error": message })); + } + result +} + +fn emit_install_status( + status: Result, + format: OutputFormat, +) -> Result<(), String> { + let status = status?; + match format { + OutputFormat::Json => write_json(&status), + OutputFormat::Text => { + println!("install dir: {}", status.install_dir); + println!("shim: {}", status.shim_path); + println!("target: {}", status.target_path); + println!("installed: {}", status.installed); + println!("on PATH: {}", status.on_path); + if let Some(conflict) = &status.conflict { + println!("conflict: {conflict}"); + } + if !status.on_path { + if status.path_configured { + println!( + "PATH updated; restart your terminal or run: {}", + status.shell_command + ); + } else { + println!("add to PATH: {}", status.shell_command); + } + } + Ok(()) + } + } +} + +impl CompletionsArgs { + fn run(self) { + let mut cmd = Cli::command(); + let name = cmd.get_name().to_string(); + clap_complete::generate(self.shell, &mut cmd, name, &mut stdout()); + } +} + +pub fn write_json(value: &T) -> Result<(), String> { + let mut stdout = stdout(); + serde_json::to_writer_pretty(&mut stdout, value).map_err(|e| e.to_string())?; + writeln!(&mut stdout).map_err(|e| e.to_string())?; + stdout.flush().map_err(|e| e.to_string()) +} + +pub fn write_json_line(value: &T) -> Result<(), String> { + let mut stdout = stdout(); + serde_json::to_writer(&mut stdout, value).map_err(|e| e.to_string())?; + writeln!(&mut stdout).map_err(|e| e.to_string())?; + stdout.flush().map_err(|e| e.to_string()) +} diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 00f1635d151..7bc09083c81 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -1,872 +1,3 @@ -mod account; -mod agent_auth; -mod agent_client; -mod agents; -mod analytics; -mod atomic; -mod automation; -mod caps; -mod confirmation; -mod credentials; -mod developers; -mod doctor; -mod export; -mod guide; -mod jobs; -mod library; -mod mcp; -mod notifications; -mod organizations; -mod project; -mod record; -mod recordings; -mod screenshot; -mod selftest; -mod session; -mod targets; -mod update; -mod upload; - -use std::{ - io::{IsTerminal, Write, stderr, stdout}, - path::PathBuf, -}; - -use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; -use export::{Export, ExportPreview}; -use record::RecordStart; -use serde::Serialize; -use tracing_subscriber::{filter::LevelFilter, layer::SubscriberExt, util::SubscriberInitExt}; - -const TOKIO_WORKER_THREAD_STACK_SIZE: usize = 16 * 1024 * 1024; -const ANSI_RESET: &str = "\x1b[0m"; -const ANSI_BOLD: &str = "\x1b[1m"; -const ANSI_BLUE: &str = "\x1b[38;2;71;133;255m"; -const ANSI_SOFT_BLUE: &str = "\x1b[38;2;173;201;255m"; -const ANSI_WHITE: &str = "\x1b[38;2;255;255;255m"; -const ANSI_MUTED: &str = "\x1b[38;2;118;128;145m"; - -const WELCOME_LINES: &[&[(&str, &str)]] = &[ - &[(ANSI_BLUE, " ██████████")], - &[ - (ANSI_BLUE, " ███"), - (ANSI_SOFT_BLUE, "████████"), - (ANSI_BLUE, "███"), - (ANSI_BOLD, " ____"), - ], - &[ - (ANSI_BLUE, " ███"), - (ANSI_SOFT_BLUE, "██"), - (ANSI_WHITE, "████████"), - (ANSI_SOFT_BLUE, "██"), - (ANSI_BLUE, "███"), - (ANSI_BOLD, " / ___|__ _ _ __"), - ], - &[ - (ANSI_BLUE, " ██"), - (ANSI_SOFT_BLUE, "██"), - (ANSI_WHITE, "██████████"), - (ANSI_SOFT_BLUE, "██"), - (ANSI_BLUE, "██"), - (ANSI_BOLD, " | | / _` | '_ \\"), - ], - &[ - (ANSI_BLUE, " ██"), - (ANSI_SOFT_BLUE, "█"), - (ANSI_WHITE, "████████████"), - (ANSI_SOFT_BLUE, "█"), - (ANSI_BLUE, "██"), - (ANSI_BOLD, " | |__| (_| | |_) |"), - ], - &[ - (ANSI_BLUE, " ██"), - (ANSI_SOFT_BLUE, "██"), - (ANSI_WHITE, "██████████"), - (ANSI_SOFT_BLUE, "██"), - (ANSI_BLUE, "██"), - (ANSI_BOLD, " \\____\\__,_| .__/"), - ], - &[ - (ANSI_BLUE, " ███"), - (ANSI_SOFT_BLUE, "██"), - (ANSI_WHITE, "████████"), - (ANSI_SOFT_BLUE, "██"), - (ANSI_BLUE, "███"), - (ANSI_BOLD, " |_|"), - ], - &[ - (ANSI_BLUE, " ███"), - (ANSI_SOFT_BLUE, "████████"), - (ANSI_BLUE, "███"), - ], - &[(ANSI_BLUE, " ██████████")], - &[], - &[( - ANSI_BOLD, - " Record, edit, and share screen recordings from the command line.", - )], - &[], - &[(ANSI_BLUE, " cap record start --screen --detach")], - &[( - ANSI_MUTED, - " cap targets cap doctor cap guide --json cap --help", - )], -]; - -/// Long-form help epilogue. Agents read `cap --help` before doing anything, so the conventions they -/// need to drive the CLI correctly (JSON on stdout, env vars, the canonical workflow) live here. -const AGENT_HELP: &str = "\ -OUTPUT - Pass --json (global) for machine-readable JSON on stdout; stderr stays human-readable. - stdout is the authoritative result. On failure the process exits non-zero and, in --json - mode, prints a final object/event containing an \"error\" field. Streaming commands (record, - export) emit newline-delimited JSON (NDJSON) events. Run `cap guide --json` for the full - machine-readable capability + schema manifest. - -AUTH - `cap upload` authenticates automatically by reusing the login Cap Desktop already stored — no - key to copy when you are signed in there. Check with `cap auth status --json`. For headless/CI, - create a CLI API key in the Cap dashboard under Settings -> Account and set it as CAP_API_KEY. - -ENVIRONMENT - CAP_API_KEY Overrides auth (CLI API key from the Cap dashboard, Settings -> Account); - optional when signed into Cap Desktop. - CAP_SERVER_URL Cap server base URL; defaults to Cap Desktop's server, else https://cap.so. - CAP_NO_MODIFY_PATH Set to skip editing shell profiles during `cap desktop install-cli`. - CAP_DESKTOP_FORCE_INSTALL - Force the web installer scripts to replace Cap Desktop before linking the CLI. - -TYPICAL AGENT WORKFLOW - cap doctor --json # verify permissions & capture readiness - cap targets --json # discover screens/windows/cameras/mics - cap record start --screen --json --detach # start in background -> {recordingId, pid, path} - cap record stop --id --json # finalize the .cap recording - cap project validate --json # confirm the recording is complete - cap export --output out.mp4 --json - cap upload out.mp4 --json # get a shareable link (needs CAP_API_KEY)"; - -#[derive(Parser)] -#[command( - name = "cap", - version, - about = "Cap screen recording from the command line", - long_about = "Cap screen recording from the command line.\n\nDesigned to be driven by automation and AI agents: add --json to any command for \ -machine-readable output. See the sections below for the JSON convention, environment variables, and \ -the canonical record -> export -> upload workflow.", - after_help = AGENT_HELP, - after_long_help = AGENT_HELP -)] -struct Cli { - #[arg(long, value_enum, global = true, default_value_t = CliLogLevel::Warn)] - log_level: CliLogLevel, - /// Emit machine-readable JSON to stdout (overrides each command's --format) - #[arg(long, global = true)] - json: bool, - #[command(subcommand)] - command: Option, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] -enum CliLogLevel { - Trace, - Debug, - Info, - Warn, - Error, -} - -impl CliLogLevel { - fn level_filter(self) -> LevelFilter { - match self { - Self::Trace => LevelFilter::TRACE, - Self::Debug => LevelFilter::DEBUG, - Self::Info => LevelFilter::INFO, - Self::Warn => LevelFilter::WARN, - Self::Error => LevelFilter::ERROR, - } - } -} - -#[derive(Subcommand)] -enum Commands { - /// Export a '.cap' project to a video file - Export(Export), - /// Render an export preview frame - ExportPreview(ExportPreview), - /// Inspect or validate a '.cap' project - Project(ProjectArgs), - /// Start a recording or list available capture targets and devices - Record(RecordArgs), - /// Capture a still screenshot of a screen or window - Screenshot(screenshot::Screenshot), - /// List recordings discovered in the desktop library (or a custom directory) - Recordings(RecordingsArgs), - /// Upload a recording or video file and get a shareable link - Upload(upload::UploadArgs), - /// Update Cap Desktop and the bundled CLI - Update(FormatArgs), - /// Show how `cap upload` will authenticate (env key or Cap Desktop login) - Auth(AuthArgs), - /// Read and manage Caps in your personal library - Caps(caps::CapsArgs), - /// Read or update the authenticated Cap account - Account(account::AccountArgs), - /// Inspect Cap organizations, members, billing, and storage connections - Organizations(organizations::OrganizationsArgs), - /// Manage folders, spaces, and space membership - Library(library::LibraryArgs), - /// Read and manage account notifications - Notifications(notifications::NotificationsArgs), - /// Read organization, space, or Cap analytics - Analytics(analytics::AnalyticsArgs), - /// Inspect developer apps, domains, usage, and credits - Developers(developers::DevelopersArgs), - /// Inspect or wait for asynchronous Cap operations - Jobs(jobs::JobsArgs), - /// Run Cap's local Model Context Protocol server - Mcp(mcp::McpArgs), - /// Install Cap integrations for one explicitly selected agent - Agents(agents::AgentsArgs), - /// List available capture targets and devices - Targets(TargetsArgs), - /// Report CLI environment and capture-readiness diagnostics - Doctor(FormatArgs), - /// Run end-to-end diagnostics that verify Cap works on this machine - Selftest(selftest::SelftestArgs), - /// Print CLI version and execution context - Version(FormatArgs), - /// Inspect or manage the desktop-installed `cap` shim - Desktop(DesktopArgs), - /// Print the machine-readable capability & JSON-schema manifest for agents - Guide(FormatArgs), - /// List automation rules shared with Cap Desktop - Automations(AutomationsArgs), - /// Generate shell completion scripts - Completions(CompletionsArgs), -} - -impl Commands { - fn exit_after_success(&self) -> bool { - // Selftest runs an export, so it shares export's teardown-crash - // avoidance on Windows. - matches!( - self, - Self::Export(_) | Self::ExportPreview(_) | Self::Selftest(_) - ) - } -} - -impl Cli { - fn exit_after_success(&self) -> bool { - self.command - .as_ref() - .is_some_and(Commands::exit_after_success) - } -} - -#[derive(Args)] -#[command(args_conflicts_with_subcommands = true)] -struct RecordArgs { - #[command(subcommand)] - command: Option, - - #[command(flatten)] - args: RecordStart, -} - -#[derive(Subcommand)] -enum RecordCommands { - /// Start a recording (use --detach to run in the background and stop later) - Start(RecordStart), - /// Stop a detached recording started with `cap record start --detach` - Stop(record::RecordStopArgs), - /// List active and recent detached recording sessions - Status(FormatArgs), - /// Internal: background worker for detached recordings (do not call directly) - #[command(name = "__session-run", hide = true)] - SessionRun(record::SessionRunArgs), - /// List screens available for capturing - Screens(FormatArgs), - /// List windows available for capturing - Windows(FormatArgs), - /// List cameras available for capturing - Cameras(FormatArgs), - /// List microphones available for capturing - Mics(FormatArgs), -} - -#[derive(Args)] -#[command(args_conflicts_with_subcommands = true)] -struct TargetsArgs { - #[command(subcommand)] - command: Option, - #[arg(long, value_enum, default_value_t = OutputFormat::Text)] - format: OutputFormat, -} - -#[derive(Subcommand)] -enum TargetCommands { - /// List screens available for capturing - Screens(FormatArgs), - /// List windows available for capturing - Windows(FormatArgs), - /// List cameras available for capturing - Cameras(FormatArgs), - /// List microphones available for capturing - Mics(FormatArgs), -} - -#[derive(Args)] -struct FormatArgs { - #[arg(long, value_enum, default_value_t = OutputFormat::Text)] - format: OutputFormat, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] -pub enum OutputFormat { - Text, - Json, -} - -#[derive(Args)] -struct ProjectArgs { - #[command(subcommand)] - command: ProjectCommands, -} - -#[derive(Subcommand)] -enum ProjectCommands { - /// Print project metadata and editor configuration - Inspect(ProjectTarget), - /// Verify a project's metadata and expected media files exist - Validate(ProjectTarget), - /// Read or write a project's editor configuration (project-config.json) - Config(ProjectConfigArgs), -} - -#[derive(Args)] -struct ProjectTarget { - project_path: PathBuf, - #[arg(long, value_enum, default_value_t = OutputFormat::Text)] - format: OutputFormat, -} - -#[derive(Args)] -struct ProjectConfigArgs { - #[command(subcommand)] - command: ProjectConfigCommands, -} - -#[derive(Subcommand)] -enum ProjectConfigCommands { - /// Print the project's editor configuration as JSON - Get(ProjectTarget), - /// Replace the project's editor configuration from a full JSON document - Set(ProjectConfigSet), -} - -#[derive(Args)] -struct ProjectConfigSet { - project_path: PathBuf, - /// Full ProjectConfiguration as a JSON string (camelCase keys); omitted fields reset to defaults - #[arg(long)] - settings_json: String, - #[arg(long, value_enum, default_value_t = OutputFormat::Text)] - format: OutputFormat, -} - -#[derive(Args)] -struct RecordingsArgs { - #[command(subcommand)] - command: RecordingsCommands, -} - -#[derive(Subcommand)] -enum RecordingsCommands { - /// List '.cap' recordings discovered on disk - List(RecordingsListArgs), -} - -#[derive(Args)] -struct RecordingsListArgs { - /// Directory to scan (defaults to the desktop recordings library) - #[arg(long)] - dir: Option, - #[arg(long, value_enum, default_value_t = OutputFormat::Text)] - format: OutputFormat, -} - -#[derive(Args)] -struct DesktopArgs { - #[command(subcommand)] - command: DesktopCommands, -} - -#[derive(Subcommand)] -enum DesktopCommands { - /// Show whether the `cap` shim is installed and on PATH - Status(FormatArgs), - /// Install the `cap` shim onto your PATH - InstallCli(FormatArgs), - /// Remove the `cap` shim from your PATH - UninstallCli(FormatArgs), -} - -#[derive(Args)] -struct AuthArgs { - #[command(subcommand)] - command: AuthCommands, -} - -#[derive(Subcommand)] -enum AuthCommands { - /// Report whether a credential is available and where it comes from (never prints the secret) - Status(FormatArgs), - /// Authorize Cap CLI in the browser using PKCE - Login(agent_auth::LoginArgs), - /// Revoke and remove the Cap CLI credential - Logout(agent_auth::LogoutArgs), -} - -#[derive(Args)] -struct AutomationsArgs { - #[command(subcommand)] - command: AutomationsCommands, -} - -#[derive(Subcommand)] -enum AutomationsCommands { - /// List the automation rules configured in Cap Desktop - List(FormatArgs), -} - -#[derive(Args)] -struct CompletionsArgs { - #[arg(value_enum)] - shell: clap_complete::Shell, -} - fn main() { - #[cfg(target_os = "linux")] - if let Some(threads) = cap_utils::linux_runtime::llvmpipe_thread_count() { - // Mesa counts host CPUs inside containers; configure it before creating the CLI runtime. - unsafe { - std::env::set_var("LP_NUM_THREADS", threads.to_string()); - } - } - - #[cfg(windows)] - { - use windows::Win32::UI::HiDpi::{PROCESS_PER_MONITOR_DPI_AWARE, SetProcessDpiAwareness}; - - let _ = unsafe { SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE) }; - } - - let cli = Cli::parse(); - let level_filter = cli.log_level.level_filter(); - - let registry = tracing_subscriber::registry().with(tracing_subscriber::filter::filter_fn( - // The binary crate is named `cap`, so its own spans/events have target `cap`/`cap::…`, - // which a bare `cap_` prefix excludes — keep those alongside the `cap_*` library crates. - (|v| { - let target = v.target(); - target == "cap" || target.starts_with("cap::") || target.starts_with("cap_") - }) as fn(&tracing::Metadata) -> bool, - )); - - registry - .with(level_filter) - .with( - tracing_subscriber::fmt::layer() - .with_ansi(true) - .with_target(true) - .with_writer(stderr), - ) - .init(); - - let _diagnostic_guard = if std::env::var_os("CAP_DIAGNOSTIC_PARENT").is_some() { - let (writer, guard) = tracing_appender::non_blocking(std::io::stderr()); - let queue_errors = writer.error_counter(); - cap_utils::operation_diagnostics::install_queue_loss_counter(move || { - queue_errors.dropped_lines() - }); - cap_utils::operation_diagnostics::install_sink( - cap_utils::operation_diagnostics::AppInfo { - flavor: "export_worker", - version: env!("CARGO_PKG_VERSION"), - source_revision: option_env!("CAP_BUILD_REVISION"), - debug_build: cfg!(debug_assertions), - source_dirty: option_env!("CAP_BUILD_DIRTY").map(|value| value == "true"), - }, - move |bytes| { - use std::io::Write; - let _ = writer.clone().write_all(bytes); - }, - ); - Some(guard) - } else { - None - }; - - let exit_after_success = cli.exit_after_success(); - - // The self-test opens a window, which AppKit requires to live on the real - // process main thread — so the main thread stays here to serve pattern - // requests while the command itself runs on the runtime thread. - let pattern_rx = matches!(cli.command, Some(Commands::Selftest(_))) - .then(selftest::pattern::install_main_thread_runner); - - // Windows export exercises deep WGPU/MediaFoundation/FFmpeg stacks. Running the CLI runtime - // on an explicitly large stack is what stopped the export worker from overflowing before - // the first frame; keep the sidecar and desktop runtimes in sync. - let runtime_thread = std::thread::Builder::new() - .name("cap-cli-runtime".to_string()) - .stack_size(TOKIO_WORKER_THREAD_STACK_SIZE) - .spawn(move || -> Result<(), String> { - // serve_main_thread blocks the main thread until this shutdown - // runs; a drop guard keeps that true on every exit path, including - // the runtime failing to build and panics unwinding out of run(). - struct PatternShutdown; - impl Drop for PatternShutdown { - fn drop(&mut self) { - selftest::pattern::shutdown_main_thread_runner(); - } - } - let _pattern_shutdown = PatternShutdown; - - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .thread_stack_size(TOKIO_WORKER_THREAD_STACK_SIZE) - .build() - .map_err(|e| format!("Failed to build Tokio runtime: {e}"))?; - - if _diagnostic_guard.is_some() { - drop(runtime.spawn(cap_utils::operation_diagnostics::run_checkpoints())); - } - let result = runtime.block_on(run(cli)); - drop(_diagnostic_guard); - if exit_after_success && result.is_ok() { - // Successful export/preview workers have already written their output by here. - // Exiting directly avoids Windows GPU/MediaFoundation teardown crashes in the - // short-lived sidecar process. - let _ = stdout().flush(); - let _ = stderr().flush(); - std::process::exit(0); - } - - result - }); - - // A failed spawn means nothing will ever call shutdown_main_thread_runner, so the - // pattern server below would block forever — bail out before serving. - let runtime_thread = match runtime_thread { - Ok(handle) => handle, - Err(e) => { - eprintln!("error: Failed to spawn CLI runtime thread: {e}"); - std::process::exit(1); - } - }; - - if let Some(rx) = pattern_rx { - selftest::pattern::serve_main_thread(rx); - } - - // Surface failures as a clean, unquoted `error: ...` line on stderr (the default - // `Result`-returning main prints `Error: "debug-quoted"`, which is noisy for humans and brittle - // for agents scraping stderr). clap already exits 2 for usage/parse errors before we get here. - let outcome = runtime_thread.join(); - - match outcome { - Ok(Ok(())) => {} - Ok(Err(message)) => { - eprintln!("error: {message}"); - std::process::exit(1); - } - Err(_) => { - eprintln!("error: CLI runtime thread panicked"); - std::process::exit(1); - } - } -} - -async fn run(cli: Cli) -> Result<(), String> { - let json = cli.json; - let Some(command) = cli.command else { - return print_welcome(json); - }; - - match command { - Commands::Export(e) => e.run(json).await, - Commands::ExportPreview(e) => e.run().await, - Commands::Selftest(args) => args.run(json).await, - Commands::Project(args) => args.run(json), - Commands::Record(RecordArgs { command, args }) => match command { - Some(RecordCommands::Start(args)) => args.run(json).await, - Some(RecordCommands::Stop(args)) => args.run(json).await, - Some(RecordCommands::Status(args)) => { - let format = resolve_format(json, args.format); - finish_json(format, record::status(format)) - } - Some(RecordCommands::SessionRun(args)) => args.run().await, - Some(RecordCommands::Screens(args)) => { - let format = resolve_format(json, args.format); - finish_json(format, targets::print_screens(format)) - } - Some(RecordCommands::Windows(args)) => { - let format = resolve_format(json, args.format); - finish_json(format, targets::print_windows(format)) - } - Some(RecordCommands::Cameras(args)) => { - let format = resolve_format(json, args.format); - finish_json(format, targets::print_cameras(format)) - } - Some(RecordCommands::Mics(args)) => { - let format = resolve_format(json, args.format); - finish_json(format, targets::print_mics(format)) - } - None => args.run(json).await, - }, - Commands::Screenshot(s) => s.run(json).await, - Commands::Recordings(args) => args.run(json), - Commands::Upload(args) => args.run(json).await, - Commands::Update(args) => { - let format = resolve_format(json, args.format); - finish_json(format, update::run(format)) - } - Commands::Auth(args) => match args.command { - AuthCommands::Status(args) => { - let format = resolve_format(json, args.format); - finish_json(format, credentials::status(format).await) - } - AuthCommands::Login(args) => args.run(json).await, - AuthCommands::Logout(args) => args.run(json).await, - }, - Commands::Caps(args) => args.run(json).await, - Commands::Account(args) => args.run(json).await, - Commands::Organizations(args) => args.run(json).await, - Commands::Library(args) => args.run(json).await, - Commands::Notifications(args) => args.run(json).await, - Commands::Analytics(args) => args.run(json).await, - Commands::Developers(args) => args.run(json).await, - Commands::Jobs(args) => args.run(json).await, - Commands::Mcp(args) => args.run().await, - Commands::Agents(args) => args.run(json), - Commands::Targets(args) => args.run(json), - Commands::Doctor(args) => doctor::run_doctor(resolve_format(json, args.format)).await, - Commands::Version(args) => { - let format = resolve_format(json, args.format); - finish_json(format, doctor::run_version(format)) - } - Commands::Desktop(args) => args.run(json), - Commands::Guide(args) => { - let format = resolve_format(json, args.format); - finish_json(format, guide::run(format)) - } - Commands::Automations(args) => match args.command { - AutomationsCommands::List(a) => { - let format = resolve_format(json, a.format); - finish_json(format, automation::list(format)) - } - }, - Commands::Completions(args) => { - args.run(); - Ok(()) - } - } -} - -fn print_welcome(json: bool) -> Result<(), String> { - if json { - return write_json(&serde_json::json!({ - "name": "cap", - "about": "Cap screen recording from the command line", - "commands": ["record", "targets", "doctor", "guide", "upload"], - })); - } - - let ansi = stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none(); - let mut stdout = stdout(); - for line in WELCOME_LINES { - for segment in *line { - let (style, text) = *segment; - if ansi && !text.is_empty() { - write!(stdout, "{style}{text}{ANSI_RESET}").map_err(|e| e.to_string())?; - } else { - write!(stdout, "{text}").map_err(|e| e.to_string())?; - } - } - writeln!(stdout).map_err(|e| e.to_string())?; - } - stdout.flush().map_err(|e| e.to_string()) -} - -/// `--json` is a global convenience that forces JSON regardless of a command's local `--format` -/// (which stays for back-compat). Either one selecting JSON wins. -pub fn resolve_format(global_json: bool, local: OutputFormat) -> OutputFormat { - if global_json { - OutputFormat::Json - } else { - local - } -} - -impl TargetsArgs { - fn run(self, json: bool) -> Result<(), String> { - let (format, result) = match self.command { - Some(TargetCommands::Screens(args)) => { - let format = resolve_format(json, args.format); - (format, targets::print_screens(format)) - } - Some(TargetCommands::Windows(args)) => { - let format = resolve_format(json, args.format); - (format, targets::print_windows(format)) - } - Some(TargetCommands::Cameras(args)) => { - let format = resolve_format(json, args.format); - (format, targets::print_cameras(format)) - } - Some(TargetCommands::Mics(args)) => { - let format = resolve_format(json, args.format); - (format, targets::print_mics(format)) - } - None => { - let format = resolve_format(json, self.format); - (format, targets::print_all(format)) - } - }; - finish_json(format, result) - } -} - -impl ProjectArgs { - fn run(self, json: bool) -> Result<(), String> { - match self.command { - // validate emits its own structured report (incl. JSON errors), so it is not wrapped. - ProjectCommands::Validate(args) => { - project::validate(args.project_path, resolve_format(json, args.format)) - } - ProjectCommands::Inspect(args) => { - let format = resolve_format(json, args.format); - finish_json(format, project::inspect(args.project_path, format)) - } - ProjectCommands::Config(ProjectConfigArgs { command }) => match command { - ProjectConfigCommands::Get(args) => finish_json( - resolve_format(json, args.format), - project::config_get(args.project_path), - ), - ProjectConfigCommands::Set(args) => { - let format = resolve_format(json, args.format); - finish_json( - format, - project::config_set(args.project_path, &args.settings_json, format), - ) - } - }, - } - } -} - -impl RecordingsArgs { - fn run(self, json: bool) -> Result<(), String> { - match self.command { - RecordingsCommands::List(args) => { - let format = resolve_format(json, args.format); - finish_json(format, recordings::list(args.dir, format)) - } - } - } -} - -impl DesktopArgs { - fn run(self, json: bool) -> Result<(), String> { - let (format, result) = match self.command { - DesktopCommands::Status(args) => { - let format = resolve_format(json, args.format); - ( - format, - emit_install_status(cap_cli_install::status(), format), - ) - } - DesktopCommands::InstallCli(args) => { - let format = resolve_format(json, args.format); - ( - format, - emit_install_status(cap_cli_install::install(), format), - ) - } - DesktopCommands::UninstallCli(args) => { - let format = resolve_format(json, args.format); - ( - format, - emit_install_status(cap_cli_install::uninstall(), format), - ) - } - }; - finish_json(format, result) - } -} - -/// When `--format json` is requested and a command fails before it could emit its own structured -/// output, write a machine-readable `{"error": "..."}` to stdout so agents do not have to scrape -/// the human-readable stderr line. The original `Err` still propagates for a non-zero exit code. -fn finish_json(format: OutputFormat, result: Result<(), String>) -> Result<(), String> { - if format == OutputFormat::Json - && let Err(message) = &result - { - let _ = write_json(&serde_json::json!({ "error": message })); - } - result -} - -fn emit_install_status( - status: Result, - format: OutputFormat, -) -> Result<(), String> { - let status = status?; - match format { - OutputFormat::Json => write_json(&status), - OutputFormat::Text => { - println!("install dir: {}", status.install_dir); - println!("shim: {}", status.shim_path); - println!("target: {}", status.target_path); - println!("installed: {}", status.installed); - println!("on PATH: {}", status.on_path); - if let Some(conflict) = &status.conflict { - println!("conflict: {conflict}"); - } - if !status.on_path { - if status.path_configured { - println!( - "PATH updated; restart your terminal or run: {}", - status.shell_command - ); - } else { - println!("add to PATH: {}", status.shell_command); - } - } - Ok(()) - } - } -} - -impl CompletionsArgs { - fn run(self) { - let mut cmd = Cli::command(); - let name = cmd.get_name().to_string(); - clap_complete::generate(self.shell, &mut cmd, name, &mut stdout()); - } -} - -pub fn write_json(value: &T) -> Result<(), String> { - let mut stdout = stdout(); - serde_json::to_writer_pretty(&mut stdout, value).map_err(|e| e.to_string())?; - writeln!(&mut stdout).map_err(|e| e.to_string())?; - stdout.flush().map_err(|e| e.to_string()) -} - -pub fn write_json_line(value: &T) -> Result<(), String> { - let mut stdout = stdout(); - serde_json::to_writer(&mut stdout, value).map_err(|e| e.to_string())?; - writeln!(&mut stdout).map_err(|e| e.to_string())?; - stdout.flush().map_err(|e| e.to_string()) + cap::run_main(std::env::args_os()); } diff --git a/apps/cli/src/record.rs b/apps/cli/src/record.rs index b628a154a9f..48d406ff8fc 100644 --- a/apps/cli/src/record.rs +++ b/apps/cli/src/record.rs @@ -280,7 +280,7 @@ async fn run_detached(params: RecordParams, format: OutputFormat) -> Result<(), let mut worker_params = params.clone(); worker_params.path = Some(path.clone()); - let mut command = std::process::Command::new(&exe); + let mut command = crate::cli_command(&exe); command .arg("record") .arg("__session-run") diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index b9df7cbb901..370b26526fa 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -8,6 +8,22 @@ version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "accesskit" version = "0.24.1" @@ -368,6 +384,31 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni 0.22.4", + "libc", + "log", + "ndk 0.9.0", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.20", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -453,6 +494,17 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "ar_archive_writer" version = "0.5.3" @@ -1346,6 +1398,20 @@ dependencies = [ "system-deps 6.2.2", ] +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + [[package]] name = "calloop" version = "0.14.4" @@ -1359,13 +1425,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + [[package]] name = "calloop-wayland-source" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ - "calloop", + "calloop 0.14.4", "rustix 1.1.4", "wayland-backend", "wayland-client", @@ -1380,6 +1458,59 @@ dependencies = [ "serde_core", ] +[[package]] +name = "cap" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "cap-automation", + "cap-camera", + "cap-cli-install", + "cap-editor", + "cap-export", + "cap-media-info", + "cap-project", + "cap-recording", + "cap-timestamp", + "cap-utils", + "chrono", + "cidre", + "clap", + "clap_complete", + "cpal 0.15.3 (git+https://github.com/CapSoftware/cpal?rev=6013cb5f8bd3)", + "dirs 6.0.0", + "ffmpeg-next", + "flume 0.11.1", + "futures", + "image 0.25.10", + "include_dir", + "kameo", + "keyring", + "libc", + "open", + "relative-path", + "reqwest 0.12.28", + "rmcp", + "rpassword", + "scap-screencapturekit", + "scap-targets", + "serde", + "serde_json", + "sha2", + "softbuffer", + "tokio", + "tokio-util", + "toml_edit 0.23.10+spec-1.0.0", + "tracing", + "tracing-appender", + "tracing-subscriber", + "url", + "uuid", + "windows 0.60.0", + "winit", + "workspace-hack", +] + [[package]] name = "cap-audio" version = "0.1.0" @@ -1397,6 +1528,21 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "cap-automation" +version = "0.1.0" +dependencies = [ + "cap-project", + "serde", + "serde_json", + "specta", + "thiserror 1.0.69", + "tokio", + "tracing", + "uuid", + "workspace-hack", +] + [[package]] name = "cap-camera" version = "0.1.0" @@ -1543,6 +1689,7 @@ dependencies = [ "base64 0.22.1", "block2 0.5.1", "bytes", + "cap", "cap-audio", "cap-camera", "cap-camera-effects", @@ -2288,6 +2435,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.4" @@ -2851,11 +3007,11 @@ dependencies = [ "core-foundation-sys", "coreaudio-rs", "dasp_sample", - "jni", + "jni 0.21.1", "js-sys", "libc", "mach2 0.4.3", - "ndk", + "ndk 0.8.0", "ndk-context", "oboe", "wasm-bindgen", @@ -2873,11 +3029,11 @@ dependencies = [ "core-foundation-sys", "coreaudio-rs", "dasp_sample", - "jni", + "jni 0.21.1", "js-sys", "libc", "mach2 0.4.3", - "ndk", + "ndk 0.8.0", "ndk-context", "oboe", "wasm-bindgen", @@ -3031,7 +3187,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" dependencies = [ "ctor-proc-macro", - "dtor", + "dtor 0.3.0", +] + +[[package]] +name = "ctor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "dtor 0.8.1", ] [[package]] @@ -3050,6 +3215,12 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "darling" version = "0.20.11" @@ -3427,6 +3598,46 @@ dependencies = [ "serde", ] +[[package]] +name = "drm" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "libc", + "rustix 0.38.44", +] + +[[package]] +name = "drm-ffi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" +dependencies = [ + "drm-sys", + "rustix 1.1.4", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" +dependencies = [ + "libc", + "linux-raw-sys 0.9.4", +] + [[package]] name = "dtoa" version = "1.0.11" @@ -3451,6 +3662,12 @@ dependencies = [ "dtor-proc-macro", ] +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" + [[package]] name = "dtor-proc-macro" version = "0.0.6" @@ -4652,8 +4869,8 @@ dependencies = [ "ashpd 0.13.13", "bitflags 2.13.1", "bytemuck", - "calloop", - "calloop-wayland-source", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", "collections", "filedescriptor", "futures", @@ -4729,7 +4946,7 @@ dependencies = [ "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", - "objc2-user-notifications", + "objc2-user-notifications 0.3.2", "parking_lot", "pathfinder_geometry", "raw-window-handle", @@ -5503,6 +5720,25 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "277ff51754a3f68f12f58446c5d006aa8baa4914ea273cce24a599cfaff33d4f" +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indenter" version = "0.3.4" @@ -5733,6 +5969,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -5853,6 +6119,27 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "4.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0298a59b384c540e408a600c8b375a09b49c3f97debc080e2c30675d79a6368a" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -6043,7 +6330,10 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ + "bitflags 2.13.1", "libc", + "plain", + "redox_syscall 0.9.4", ] [[package]] @@ -6097,6 +6387,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -6712,6 +7008,21 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + [[package]] name = "ndk-context" version = "0.1.1" @@ -7061,7 +7372,7 @@ dependencies = [ "block2 0.6.2", "libc", "objc2 0.6.4", - "objc2-cloud-kit", + "objc2-cloud-kit 0.3.2", "objc2-core-data 0.3.2", "objc2-core-foundation", "objc2-core-graphics", @@ -7105,6 +7416,19 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-cloud-kit" version = "0.3.2" @@ -7116,6 +7440,17 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-core-audio" version = "0.3.2" @@ -7209,7 +7544,19 @@ dependencies = [ [[package]] name = "objc2-core-location" -version = "0.3.2" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" dependencies = [ @@ -7280,6 +7627,7 @@ checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.13.1", "block2 0.5.1", + "dispatch", "libc", "objc2 0.5.2", ] @@ -7329,6 +7677,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-media-toolbox" version = "0.3.2" @@ -7391,6 +7751,37 @@ dependencies = [ "objc2-metal 0.3.2", ] +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications 0.2.2", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -7400,16 +7791,40 @@ dependencies = [ "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", - "objc2-cloud-kit", + "objc2-cloud-kit 0.3.2", "objc2-core-data 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-core-image 0.3.2", - "objc2-core-location", + "objc2-core-location 0.3.2", "objc2-core-text", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", - "objc2-user-notifications", + "objc2-user-notifications 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -7421,7 +7836,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", - "objc2-core-location", + "objc2-core-location 0.3.2", "objc2-foundation 0.3.2", ] @@ -7467,8 +7882,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" dependencies = [ - "jni", - "ndk", + "jni 0.21.1", + "ndk 0.8.0", "ndk-context", "num-derive", "num-traits", @@ -7628,6 +8043,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + [[package]] name = "ordered-channel" version = "1.2.0" @@ -7701,11 +8126,20 @@ dependencies = [ "nix 0.31.3", "objc2 0.6.4", "objc2-foundation 0.3.2", - "objc2-ui-kit", + "objc2-ui-kit 0.3.2", "serde", "windows-sys 0.61.2", ] +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser 0.25.1", +] + [[package]] name = "pango" version = "0.18.3" @@ -7771,7 +8205,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -8101,6 +8535,12 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.10.0" @@ -8856,6 +9296,15 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -8865,6 +9314,15 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -9028,9 +9486,11 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-channel", "futures-core", "futures-util", + "h2", "http 1.5.0", "http-body", "http-body-util", @@ -9040,6 +9500,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime", "mime_guess", "native-tls", "percent-encoding", @@ -9177,6 +9638,41 @@ dependencies = [ "portable-atomic-util", ] +[[package]] +name = "rmcp" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "pastey 0.2.3", + "pin-project-lite", + "rmcp-macros", + "schemars 1.2.2", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.119", +] + [[package]] name = "rodio" version = "0.19.0" @@ -9204,6 +9700,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -9497,6 +10014,7 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ + "chrono", "dyn-clone", "indexmap 2.14.0", "ref-cast", @@ -9580,12 +10098,44 @@ dependencies = [ "once_cell", ] +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + [[package]] name = "seahash" version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -10063,6 +10613,16 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simd_helpers" version = "0.1.0" @@ -10072,6 +10632,12 @@ dependencies = [ "quote", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simplecss" version = "0.2.2" @@ -10134,6 +10700,31 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + [[package]] name = "smol" version = "2.0.2" @@ -10191,6 +10782,38 @@ dependencies = [ "winapi", ] +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "drm", + "fastrand", + "js-sys", + "memmap2", + "ndk 0.9.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 1.1.4", + "tiny-xlib", + "tracing", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.61.2", + "x11rb", +] + [[package]] name = "specta" version = "2.0.0-rc.20" @@ -11019,6 +11642,19 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tiny-xlib" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +dependencies = [ + "as-raw-xcb-connection", + "ctor 0.10.1", + "libloading 0.8.9", + "pkg-config", + "tracing", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -12105,6 +12741,17 @@ dependencies = [ "wayland-scanner", ] +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + [[package]] name = "wayland-cursor" version = "0.31.14" @@ -12954,6 +13601,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.1.1" @@ -13412,6 +14072,58 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk 0.9.0", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str 0.2.2", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + [[package]] name = "winnow" version = "0.5.40" @@ -13594,6 +14306,17 @@ dependencies = [ "x11rb", ] +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + [[package]] name = "x11rb" version = "0.13.2" @@ -13662,6 +14385,19 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + [[package]] name = "xkeysym" version = "0.2.1" @@ -13796,6 +14532,17 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + [[package]] name = "zbus_macros" version = "5.19.0" diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index f496ed30e58..3d2900c0eb2 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -205,6 +205,8 @@ raw-window-handle = "0.6" x11rb = "0.13" [target.'cfg(target_os = "macos")'.dependencies] +# The CLI runs inside this binary on macOS (`Cap --cap-cli`); see crates/cli-launcher. +cap = { path = "../cli" } # Native window treatment (panel levels, Spaces) gpui does not expose. Same # versions gpui's own tree compiles, so no second objc2 universe gets built. objc2 = "0.5.2" diff --git a/apps/desktop-gpui/README.md b/apps/desktop-gpui/README.md index 492f4a547d3..0cf5b2f8061 100644 --- a/apps/desktop-gpui/README.md +++ b/apps/desktop-gpui/README.md @@ -103,6 +103,15 @@ The bundle carries `cap-muxer` and `cap-cli` only: `cap-exporter` was a byte-identical copy of `cap-cli`, and the self-test now runs `cap-cli` directly. Release sidecars are built without debug info and stripped. +On macOS `Cap` itself is the CLI: run as `Cap --cap-cli ` it hands its +arguments to the `cap` crate before any of the app starts, and +`Contents/MacOS/cap-cli` is a small launcher (`crates/cli-launcher`) that +re-executes it that way. The engine ships once instead of twice, and every +shim `cap desktop install-cli` or Settings → CLI created still points at +`cap-cli`. Windows and Linux keep the standalone CLI binary: a Windows GUI +binary has no console to print to, and the AppImage already routes the CLI +through its own `--cap-cli` launcher. + After `tauri bundle`, the release shrinks every artifact without changing a byte of what gets installed: diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 10cdb7b09fb..faa2fdd1282 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -252,6 +252,17 @@ fn app_runtime() -> tokio::runtime::Runtime { } fn main() { + // `Contents/MacOS/cap-cli` re-executes this binary as `Cap --cap-cli `: run the CLI + // before anything of the app starts. + #[cfg(target_os = "macos")] + if std::env::args_os().nth(1).as_deref() == Some(std::ffi::OsStr::new("--cap-cli")) { + let mut args = std::env::args_os(); + let program = args.next(); + args.next(); + cap::run_embedded(program.into_iter().chain(args)); + return; + } + #[cfg(target_os = "linux")] if let Some(threads) = cap_utils::linux_runtime::llvmpipe_thread_count() { // Mesa counts host CPUs inside containers; apply the process limit before logging starts threads. diff --git a/crates/cli-launcher/Cargo.toml b/crates/cli-launcher/Cargo.toml new file mode 100644 index 00000000000..7d1b7835699 --- /dev/null +++ b/crates/cli-launcher/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "cap-cli-launcher" +version = "0.1.0" +edition = "2024" +publish = false + +[lints] +workspace = true diff --git a/crates/cli-launcher/src/main.rs b/crates/cli-launcher/src/main.rs new file mode 100644 index 00000000000..7876ddbd371 --- /dev/null +++ b/crates/cli-launcher/src/main.rs @@ -0,0 +1,38 @@ +//! `Contents/MacOS/cap-cli` in the macOS app. +//! +//! The app binary next to it runs the CLI when its first argument is `--cap-cli`, so the bundle +//! carries one copy of the recording, rendering and export engine instead of two. This launcher +//! keeps the `cap-cli` path that `cap desktop install-cli`, Settings → CLI and every existing +//! `~/.cap/bin/cap` shim point at. + +#[cfg(target_os = "macos")] +fn main() -> std::process::ExitCode { + use std::os::unix::process::CommandExt; + + let mut args = std::env::args_os(); + let program = args.next().unwrap_or_else(|| "cap".into()); + // A shim reaches this binary through a symlink, and `current_exe` reports the symlink. + let app = match std::env::current_exe().and_then(std::fs::canonicalize) { + Ok(exe) => exe.with_file_name("Cap"), + Err(error) => { + eprintln!("error: Could not locate the Cap app: {error}"); + return std::process::ExitCode::FAILURE; + } + }; + let error = std::process::Command::new(&app) + .arg0(program) + .arg("--cap-cli") + .args(args) + .exec(); + eprintln!( + "error: Could not launch the Cap CLI at {}: {error}", + app.display() + ); + std::process::ExitCode::FAILURE +} + +#[cfg(not(target_os = "macos"))] +fn main() -> std::process::ExitCode { + eprintln!("error: cap-cli-launcher only ships in the macOS app"); + std::process::ExitCode::FAILURE +} diff --git a/scripts/build-desktop-binaries.sh b/scripts/build-desktop-binaries.sh index ee08300f9db..a087e7b08d8 100755 --- a/scripts/build-desktop-binaries.sh +++ b/scripts/build-desktop-binaries.sh @@ -40,4 +40,13 @@ build_sidecar() { } build_sidecar "cap-muxer" "cap-muxer" "cap-muxer" -build_sidecar "cap" "cap" "cap-cli" "cap-exporter" +case "$TARGET" in + *-apple-darwin) + # The macOS app binary runs the CLI itself (`Cap --cap-cli`); `cap-cli` is the small + # launcher that re-executes it (crates/cli-launcher), so the engine ships once. + build_sidecar "cap-cli-launcher" "cap-cli-launcher" "cap-cli" + ;; + *) + build_sidecar "cap" "cap" "cap-cli" "cap-exporter" + ;; +esac