diff --git a/flake.lock b/flake.lock index f8553b7..cea29ec 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1749174413, - "narHash": "sha256-urN9UMK5cd1dzhR+Lx0xHeTgBp2MatA5+6g9JaxjuQs=", + "lastModified": 1775126147, + "narHash": "sha256-J0dZU4atgcfo4QvM9D92uQ0Oe1eLTxBVXjJzdEMQpD0=", "owner": "nixos", "repo": "nixpkgs", - "rev": "6ad174a6dc07c7742fc64005265addf87ad08615", + "rev": "8d8c1fa5b412c223ffa47410867813290cdedfef", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index d1697eb..c808bee 100644 --- a/flake.nix +++ b/flake.nix @@ -6,44 +6,75 @@ flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { - self, - nixpkgs, - flake-utils, - }: - flake-utils.lib.eachSystem ["x86_64-linux" "aarch64-linux"] (system: let - pkgs = import nixpkgs { - inherit system; - config.allowUnfree = true; - }; - in { - packages = rec { - patchy-cnb = pkgs.callPackage ./pkgs/patchy-cnb.nix {}; - claude-desktop = pkgs.callPackage ./pkgs/claude-desktop.nix { - inherit patchy-cnb; + outputs = + { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" ] ( + system: + let + pkgs = import nixpkgs { + inherit system; + config.allowUnfree = true; }; - claude-desktop-with-fhs = pkgs.buildFHSEnv { - name = "claude-desktop"; - targetPkgs = pkgs: - with pkgs; [ - docker - glibc - openssl - nodejs - uv + in + { + packages = rec { + node-pty = pkgs.callPackage ./pkgs/node-pty.nix { }; + + claude-desktop = pkgs.callPackage ./pkgs/claude-desktop.nix { + electron = pkgs.electron_40; + inherit node-pty; + }; + + claude-desktop-with-fhs = pkgs.symlinkJoin { + name = "claude-desktop-with-fhs"; + paths = [ + claude-desktop + (pkgs.buildFHSEnv { + name = "claude-desktop-bwrap"; + targetPkgs = + pkgs: with pkgs; [ + docker + glibc + openssl + nodejs + uv + glib + gvfs + xdg-utils + bubblewrap + ]; + runScript = "${claude-desktop}/bin/claude-desktop"; + }) ]; - runScript = "${claude-desktop}/bin/claude-desktop"; - extraInstallCommands = '' - # Copy desktop file from the claude-desktop package - mkdir -p $out/share/applications - cp ${claude-desktop}/share/applications/claude.desktop $out/share/applications/ + postBuild = '' + rm -f $out/bin/claude-desktop + ln -sf $out/bin/claude-desktop-bwrap $out/bin/claude-desktop + ''; + }; + + claude-desktop-shell = pkgs.buildFHSEnv { + name = "claude-desktop-shell"; + targetPkgs = + pkgs: with pkgs; [ + docker + glibc + openssl + nodejs + uv + glib + gvfs + xdg-utils + bubblewrap + ]; + runScript = "bash"; + }; - # Copy icons - mkdir -p $out/share/icons - cp -r ${claude-desktop}/share/icons/* $out/share/icons/ - ''; + default = claude-desktop; }; - default = claude-desktop; - }; - }); + } + ); } diff --git a/pkgs/claude-desktop.nix b/pkgs/claude-desktop.nix index 7037953..b9ee524 100644 --- a/pkgs/claude-desktop.nix +++ b/pkgs/claude-desktop.nix @@ -5,192 +5,256 @@ electron, p7zip, icoutils, - nodePackages, imagemagick, + nodejs, + asar, makeDesktopItem, - makeWrapper, - patchy-cnb, - perl -}: let + python3, + bash, + getent, + node-pty, +}: +let pname = "claude-desktop"; - version = "0.14.10"; - srcExe = fetchurl { - # NOTE: `?v=${version}` doesn't actually request a specific version. It's only being used here as a cache buster. - # In the future, this should more properly query GCP storage to get a specific version. - url = "https://storage.googleapis.com/osprey-downloads-c02f6a0d-347c-492b-a752-3e0651722e97/nest-win-x64/Claude-Setup-x64.exe?v=${version}"; - hash = "sha256-Sn/lvMlfKd7b/utFvCxrkWNDJTug4OOSA4lo9YV8aqk="; - }; -in - stdenvNoCC.mkDerivation rec { - inherit pname version; - - src = ./.; - - nativeBuildInputs = [ - p7zip - nodePackages.asar - makeWrapper - imagemagick - icoutils - perl - ]; - - desktopItem = makeDesktopItem { - name = "claude"; - exec = "claude-desktop %u"; - icon = "claude"; - type = "Application"; - terminal = false; - desktopName = "Claude"; - genericName = "Claude Desktop"; - startupWMClass = "claude"; - categories = [ - "Office" - "Utility" - ]; - mimeTypes = ["x-scheme-handler/claude"]; + version = "1.569.0"; + + srcs = { + x86_64-linux = fetchurl { + url = "https://downloads.claude.ai/releases/win32/x64/${version}/Claude-49894ad878c985b0dd77178b75b353f11481ebf4.exe"; + hash = "sha256-NNbINx7IpfV8aQGSsTS7pkQzEDvs0lTygFhzjvQDHO0="; + }; + aarch64-linux = fetchurl { + url = "https://downloads.claude.ai/releases/win32/arm64/${version}/Claude-49894ad878c985b0dd77178b75b353f11481ebf4.exe"; + hash = "sha256-/ZEQ/hE94Dm3n31pPZqaZg5Uz8LGrxIP9APgcxWye/M="; }; + }; + + srcExe = srcs.${stdenvNoCC.hostPlatform.system} or (throw "Unsupported system: ${stdenvNoCC.hostPlatform.system}"); - buildPhase = '' - runHook preBuild - - # Create temp working directory - mkdir -p $TMPDIR/build - cd $TMPDIR/build - - - # Extract installer exe, and nupkg within it - 7z x -y ${srcExe} - - # List the directory, in case the nupkg filename changes - ls -al . - - 7z x -y "AnthropicClaude-${version}-full.nupkg" - - # Package the icons from claude.exe - wrestool -x -t 14 lib/net45/claude.exe -o claude.ico - icotool -x claude.ico - - for size in 16 24 32 48 64 256; do - mkdir -p $TMPDIR/build/icons/hicolor/"$size"x"$size"/apps - install -Dm 644 claude_*"$size"x"$size"x32.png \ - $TMPDIR/build/icons/hicolor/"$size"x"$size"/apps/claude.png - done - - rm claude.ico - - # Process app.asar files - # We need to replace claude-native-bindings.node in both the - # app.asar package and .unpacked directory - mkdir -p electron-app - cp "lib/net45/resources/app.asar" electron-app/ - cp -r "lib/net45/resources/app.asar.unpacked" electron-app/ - - cd electron-app - asar extract app.asar app.asar.contents - - echo "Using search pattern: '$TARGET_PATTERN' within search base: '$SEARCH_BASE'" - SEARCH_BASE="app.asar.contents/.vite/renderer/main_window/assets" - TARGET_PATTERN="MainWindowPage-*.js" - - echo "Searching for '$TARGET_PATTERN' within '$SEARCH_BASE'..." - # Find the target file recursively (ensure only one matches) - TARGET_FILES=$(find "$SEARCH_BASE" -type f -name "$TARGET_PATTERN") - # Count non-empty lines to get the number of files found - NUM_FILES=$(echo "$TARGET_FILES" | grep -c .) - echo "Found $NUM_FILES matching files" - echo "Target files: $TARGET_FILES" - - echo "##############################################################" - echo "Removing "'!'" from 'if ("'!'"isWindows && isMainWindow) return null;'" - echo "detection flag to to enable title bar" - - echo "Current working directory: '$PWD'" - - echo "Searching for '$TARGET_PATTERN' within '$SEARCH_BASE'..." - # Find the target file recursively (ensure only one matches) - if [ "$NUM_FILES" -eq 0 ]; then - echo "Error: No file matching '$TARGET_PATTERN' found within '$SEARCH_BASE'." >&2 - exit 1 - elif [ "$NUM_FILES" -gt 1 ]; then - echo "Error: Expected exactly one file matching '$TARGET_PATTERN' within '$SEARCH_BASE', but found $NUM_FILES." >&2 - echo "Found files:" >&2 - echo "$TARGET_FILES" >&2 - exit 1 - else - # Exactly one file found - TARGET_FILE="$TARGET_FILES" # Assign the found file path - echo "Found target file: $TARGET_FILE" - - echo "Attempting to replace patterns like 'if(!VAR1 && VAR2)' with 'if(VAR1 && VAR2)' in $TARGET_FILE..." - perl -i -pe \ - 's{if\(!(\w+)\s*&&\s*(\w+)\)}{if($1 && $2)}g' \ - "$TARGET_FILE" - - # Verification: Check if the original pattern structure still exists - if ! grep -q -E '!\w+&&\w+' "$TARGET_FILE"; then - echo "Successfully replaced patterns like '!VAR1&&VAR2' with 'VAR1&&VAR2' in $TARGET_FILE" - else - echo "Warning: Some instances of '!VAR1&&VAR2' might still exist in $TARGET_FILE." >&2 - fi # Verification: Check if the original pattern structure still exists + sourceRoot = lib.cleanSourceWith { + src = ./..; + filter = path: type: + let rel = lib.removePrefix (toString ./.. + "/") path; + in !(lib.hasPrefix "result" rel) + && !(lib.hasPrefix ".git" rel); + }; + + # The unwrapped electron derivation — contains the real ELF binary + electronUnwrapped = electron.passthru.unwrapped or electron; + electronDir = "${electronUnwrapped}/libexec/electron"; + + desktopItem = makeDesktopItem { + name = "claude-desktop"; + exec = "claude-desktop %u"; + icon = "claude-desktop"; + type = "Application"; + terminal = false; + desktopName = "Claude"; + genericName = "Claude Desktop"; + startupWMClass = "Claude"; + categories = [ "Office" "Utility" ]; + mimeTypes = [ "x-scheme-handler/claude" ]; + }; +in +stdenvNoCC.mkDerivation { + inherit pname version; + + src = srcExe; + + nativeBuildInputs = [ + p7zip + nodejs + asar + icoutils + imagemagick + bash + python3 + getent + ]; + + # The exe is not a standard archive — use manual unpack + dontUnpack = true; + dontConfigure = true; + + buildPhase = '' + runHook preBuild + + export HOME=$TMPDIR + + # Copy exe to a writable location for build.sh + cp $src Claude-Setup.exe + + # Run build.sh — handles extraction, patching, icon extraction, asar repacking + # sourceRoot points to the flake repo (contains scripts/) + bash ${sourceRoot}/scripts/build.sh \ + --exe "$(pwd)/Claude-Setup.exe" \ + --source-dir "${sourceRoot}" \ + --node-pty-dir "${node-pty}/lib/node_modules/node-pty" \ + --build nix \ + --clean no + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + #========================================================================== + # Create a custom Electron tree with app resources co-located. + # + # Chromium computes process.resourcesPath from /proc/self/exe, so it + # always points to electron-unwrapped's resources/ dir. When + # ELECTRON_FORCE_IS_PACKAGED=true, the app reads en-US.json from + # resourcesPath at module load time, causing an ENOENT crash. + # + # Solution: copy the Electron ELF binary into our own tree so that + # /proc/self/exe resolves here, then merge resources. + #========================================================================== + electron_tree=$out/lib/claude-desktop/electron + + mkdir -p $electron_tree/resources + + # Copy the ELF binary — MUST be a real copy (not symlink) so + # /proc/self/exe resolves to our tree + cp ${electronDir}/electron $electron_tree/electron + + # Symlink everything else from electron-unwrapped + for item in ${electronDir}/*; do + name=$(basename "$item") + [[ "$name" = "electron" ]] && continue + [[ "$name" = "resources" ]] && continue + ln -s "$item" "$electron_tree/$name" + done + + # Populate resources/ — start with Electron's own (default_app.asar) + for item in ${electronDir}/resources/*; do + ln -s "$item" "$electron_tree/resources/$(basename "$item")" + done + + # Install app.asar and unpacked resources into the merged tree + cp build/electron-app/app.asar $electron_tree/resources/ + cp -r build/electron-app/app.asar.unpacked $electron_tree/resources/ + + # Install tray icons into resources + for tray_icon in build/electron-app/nix-resources/Tray*; do + [[ -f "$tray_icon" ]] && cp "$tray_icon" $electron_tree/resources/ + done + + # Install SSH helpers into resources + if [[ -d build/electron-app/nix-resources/claude-ssh ]]; then + cp -r build/electron-app/nix-resources/claude-ssh \ + $electron_tree/resources/ + fi + + # Install cowork resources (smol-bin, plugin shim) + for cowork_res in build/electron-app/nix-resources/smol-bin.*.vhdx \ + build/electron-app/nix-resources/cowork-plugin-shim.sh; do + if [[ -f "$cowork_res" ]]; then + cp "$cowork_res" $electron_tree/resources/ + echo "Installed cowork resource: $(basename "$cowork_res")" fi - echo "##############################################################" - # exit 1 - - # Replace native bindings - cp ${patchy-cnb}/lib/patchy-cnb.*.node app.asar.contents/node_modules/claude-native/claude-native-binding.node - cp ${patchy-cnb}/lib/patchy-cnb.*.node app.asar.unpacked/node_modules/claude-native/claude-native-binding.node - - # .vite/build/index.js in the app.asar expects the Tray icons to be - # placed inside the app.asar. - mkdir -p app.asar.contents/resources - ls ../lib/net45/resources/ - cp ../lib/net45/resources/Tray* app.asar.contents/resources/ - - # Copy i18n json files - mkdir -p app.asar.contents/resources/i18n - cp ../lib/net45/resources/*.json app.asar.contents/resources/i18n/ - - # Repackage app.asar - asar pack app.asar.contents app.asar - - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - - # Electron directory structure - mkdir -p $out/lib/$pname - cp -r $TMPDIR/build/electron-app/app.asar $out/lib/$pname/ - cp -r $TMPDIR/build/electron-app/app.asar.unpacked $out/lib/$pname/ - - # Install icons - mkdir -p $out/share/icons - cp -r $TMPDIR/build/icons/* $out/share/icons - - # Install .desktop file - mkdir -p $out/share/applications - install -Dm0644 ${desktopItem}/share/applications/claude.desktop $out/share/applications/claude.desktop - - # Create wrapper - mkdir -p $out/bin - makeWrapper ${electron}/bin/electron $out/bin/$pname \ - --add-flags "$out/lib/$pname/app.asar" \ - --add-flags "--openDevTools" \ - --add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations}}" - - runHook postInstall - ''; - - dontUnpack = true; - dontConfigure = true; - - meta = with lib; { - description = "Claude Desktop for Linux"; - license = licenses.unfree; - platforms = platforms.unix; - sourceProvenance = with sourceTypes; [binaryNativeCode]; - mainProgram = pname; - }; - } + done + + # Install locale JSON files into resources + for locale_json in build/claude-extract/lib/net45/resources/*-*.json; do + [[ -f "$locale_json" ]] \ + && cp "$locale_json" $electron_tree/resources/ + done + + # Create the electron wrapper — replicates env setup from stock + # electron wrapper, then execs our custom binary + head -n -1 ${electron}/bin/electron > $electron_tree/electron-wrapper + echo "exec \"$electron_tree/electron\" \"\$@\"" >> $electron_tree/electron-wrapper + chmod +x $electron_tree/electron-wrapper + + # Update CHROME_DEVEL_SANDBOX to point to our tree's chrome-sandbox + substituteInPlace $electron_tree/electron-wrapper \ + --replace-quiet "${electron}/libexec/electron/chrome-sandbox" \ + "$electron_tree/chrome-sandbox" + + #========================================================================== + # Standard install (icons, desktop file, launcher) + #========================================================================== + + # Convenience symlink for resources dir + ln -s $electron_tree/resources $out/lib/claude-desktop/resources + + # Install icons + for size in 16 24 32 48 64 256; do + icon_dir=$out/share/icons/hicolor/"$size"x"$size"/apps + mkdir -p "$icon_dir" + icon=$(find build/ -name "claude_*''${size}x''${size}x32.png" 2>/dev/null | head -1) + if [[ -n "$icon" ]]; then + install -Dm644 "$icon" "$icon_dir/claude-desktop.png" + fi + done + + # Install shared launcher library + install -Dm755 ${sourceRoot}/scripts/launcher-common.sh \ + $out/lib/claude-desktop/launcher-common.sh + + # Install .desktop file + mkdir -p $out/share/applications + install -Dm644 ${desktopItem}/share/applications/* $out/share/applications/ + + # Create launcher script + mkdir -p $out/bin + cat > $out/bin/claude-desktop <<'LAUNCHER' +#!/usr/bin/env bash +# Claude Desktop launcher for NixOS + +electron_exec="ELECTRON_PLACEHOLDER" +app_path="RESOURCES_PLACEHOLDER/app.asar" + +source "LAUNCHER_LIB_PLACEHOLDER" + +# Handle --doctor flag +if [[ "''${1:-}" == '--doctor' ]]; then + run_doctor "$electron_exec" + exit $? +fi + +# Setup logging and environment +setup_logging || exit 1 +setup_electron_env +cleanup_orphaned_cowork_daemon +cleanup_stale_lock +cleanup_stale_cowork_socket + +log_message '--- Claude Desktop Launcher Start (NixOS) ---' +log_message "Timestamp: $(date)" +log_message "Arguments: $@" + +if ! check_display; then + log_message 'No display detected (TTY session)' + echo 'Error: Claude Desktop requires a graphical desktop environment.' >&2 + exit 1 +fi + +detect_display_backend +build_electron_args 'nix' +electron_args+=("$app_path") + +log_message "Executing: $electron_exec ''${electron_args[*]} $*" +"$electron_exec" "''${electron_args[@]}" "$@" >> "$log_file" 2>&1 +exit_code=$? +log_message "Electron exited with code: $exit_code" +exit $exit_code +LAUNCHER + substituteInPlace $out/bin/claude-desktop \ + --replace-fail "ELECTRON_PLACEHOLDER" "$electron_tree/electron-wrapper" \ + --replace-fail "RESOURCES_PLACEHOLDER" "$electron_tree/resources" \ + --replace-fail "LAUNCHER_LIB_PLACEHOLDER" "$out/lib/claude-desktop/launcher-common.sh" + chmod +x $out/bin/claude-desktop + + runHook postInstall + ''; + + meta = with lib; { + description = "Claude Desktop for Linux"; + license = licenses.unfree; + platforms = [ "x86_64-linux" "aarch64-linux" ]; + sourceProvenance = with sourceTypes; [ binaryNativeCode ]; + mainProgram = "claude-desktop"; + }; +} diff --git a/pkgs/node-pty.nix b/pkgs/node-pty.nix new file mode 100644 index 0000000..b4cf835 --- /dev/null +++ b/pkgs/node-pty.nix @@ -0,0 +1,46 @@ +{ + lib, + buildNpmPackage, + fetchFromGitHub, + python3, + node-gyp, +}: + +buildNpmPackage rec { + pname = "node-pty"; + version = "1.1.0"; + + src = fetchFromGitHub { + owner = "microsoft"; + repo = "node-pty"; + rev = "v${version}"; + hash = "sha256-R0QxTw3tNJvW4aEi+GOF0iZhGgI42HTYJih90CdF18I="; + }; + + npmDepsHash = "sha256-HRv/4NO7CHkPs7ld8lx61n2cty0EhmWVrpH/1Vqh+Nk="; + + nativeBuildInputs = [ python3 node-gyp ]; + + # fsevents is macOS-only; strip it from the lockfile to avoid sync errors + postPatch = '' + sed -i '/"fsevents"/d' package-lock.json + ''; + + buildPhase = '' + runHook preBuild + npm run build + node-gyp rebuild + runHook postBuild + ''; + + postInstall = '' + cp -r build $out/lib/node_modules/node-pty/ + ''; + + meta = with lib; { + description = "Fork pseudoterminals in Node.JS"; + homepage = "https://github.com/microsoft/node-pty"; + license = licenses.mit; + platforms = platforms.linux; + }; +} diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..9a1ebba --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,1889 @@ +#!/usr/bin/env bash + +#=============================================================================== +# Claude Desktop Debian Build Script +# Repackages Claude Desktop (Electron app) for Debian/Ubuntu Linux +#=============================================================================== + +# Global variables (set by functions, used throughout) +architecture='' +distro_family='' # debian, rpm, nix, or unknown +claude_download_url='' +claude_exe_sha256='' +claude_exe_filename='' +version='' +release_tag='' # Optional release tag (e.g., v1.3.2+claude1.1.799) for unique package versions +build_format='' # Will be set based on distro if not specified +cleanup_action='yes' +perform_cleanup=false +test_flags_mode=false +local_exe_path='' +node_pty_dir='' +source_dir='' +original_user='' +original_home='' +project_root='' +work_dir='' +app_staging_dir='' +chosen_electron_module_path='' +electron_var='' +electron_var_re='' +asar_exec='' +claude_extract_dir='' +electron_resources_dest='' +node_pty_build_dir='' +final_output_path='' + +# Package metadata (constants) +readonly PACKAGE_NAME='claude-desktop' +readonly MAINTAINER='Claude Desktop Linux Maintainers' +readonly DESCRIPTION='Claude Desktop for Linux' + +#=============================================================================== +# Utility Functions +#=============================================================================== + +check_command() { + if ! command -v "$1" &> /dev/null; then + echo "$1 not found" + return 1 + else + echo "$1 found" + return 0 + fi +} + +section_header() { + echo -e "\033[1;36m--- $1 ---\033[0m" +} + +section_footer() { + echo -e "\033[1;36m--- End $1 ---\033[0m" +} + +verify_sha256() { + local file_path="$1" + local expected_hash="$2" + local label="${3:-file}" + + if [[ -z $expected_hash ]]; then + echo "Warning: No SHA-256 hash for ${label}," \ + 'skipping verification' >&2 + return 0 + fi + + echo "Verifying SHA-256 checksum for ${label}..." + local actual_hash _ + read -r actual_hash _ < <(sha256sum "$file_path") + + if [[ $actual_hash != "$expected_hash" ]]; then + echo "SHA-256 mismatch for ${label}!" >&2 + echo " Expected: $expected_hash" >&2 + echo " Actual: $actual_hash" >&2 + return 1 + fi + + echo "SHA-256 verified: ${label}" +} + +#=============================================================================== +# Setup Functions +#=============================================================================== + +detect_architecture() { + section_header 'Architecture Detection' + echo 'Detecting system architecture...' + + local raw_arch + raw_arch=$(uname -m) || { + echo 'Failed to detect architecture' >&2 + exit 1 + } + echo "Detected machine architecture: $raw_arch" + + case "$raw_arch" in + x86_64) + claude_download_url='https://downloads.claude.ai/releases/win32/x64/1.569.0/Claude-49894ad878c985b0dd77178b75b353f11481ebf4.exe' + claude_exe_sha256='34d6c8371ec8a5f57c690192b134bba64433103becd254f28058738ef4031ced' + architecture='amd64' + claude_exe_filename='Claude-Setup-x64.exe' + echo 'Configured for amd64 (x86_64) build.' + ;; + aarch64) + claude_download_url='https://downloads.claude.ai/releases/win32/arm64/1.569.0/Claude-49894ad878c985b0dd77178b75b353f11481ebf4.exe' + claude_exe_sha256='fd9110fe113de039b79f7d693d9a9a660e54cfc2c6af120ff403e07315b27bf3' + architecture='arm64' + claude_exe_filename='Claude-Setup-arm64.exe' + echo 'Configured for arm64 (aarch64) build.' + ;; + *) + echo "Unsupported architecture: $raw_arch. This script supports x86_64 (amd64) and aarch64 (arm64)." >&2 + exit 1 + ;; + esac + + echo "Target Architecture: $architecture" + section_footer 'Architecture Detection' +} + +detect_distro() { + section_header 'Distribution Detection' + echo 'Detecting Linux distribution family...' + + if [[ -f /etc/debian_version ]]; then + distro_family='debian' + echo "Detected Debian-based distribution" + echo " Debian version: $(cat /etc/debian_version)" + elif [[ -f /etc/fedora-release ]]; then + distro_family='rpm' + echo "Detected Fedora" + echo " $(cat /etc/fedora-release)" + elif [[ -f /etc/redhat-release ]]; then + distro_family='rpm' + echo "Detected Red Hat-based distribution" + echo " $(cat /etc/redhat-release)" + elif [[ -f /etc/NIXOS ]]; then + distro_family='nix' + echo "Detected NixOS" + else + distro_family='unknown' + echo "Warning: Could not detect distribution family" + echo " AppImage build will still work, but native packages (deb/rpm) may not" + fi + + echo "Distribution: $(grep 'PRETTY_NAME' /etc/os-release 2>/dev/null | cut -d'"' -f2 || echo 'Unknown')" + echo "Distribution family: $distro_family" + section_footer 'Distribution Detection' +} + +check_system_requirements() { + # Allow running as root in CI/container environments + if (( EUID == 0 )); then + if [[ -n ${CI:-} || -n ${GITHUB_ACTIONS:-} || -f /.dockerenv ]]; then + echo 'Running as root in CI/container environment (allowed)' + else + echo 'This script should not be run using sudo or as the root user.' >&2 + echo 'It will use sudo when needed for specific actions (may prompt for password).' >&2 + echo 'Please run as a normal user.' >&2 + exit 1 + fi + fi + + original_user=$(whoami) + original_home=$(getent passwd "$original_user" | cut -d: -f6) + if [[ -z $original_home ]]; then + echo "Could not determine home directory for user $original_user." >&2 + exit 1 + fi + echo "Running as user: $original_user (Home: $original_home)" + + # Check for NVM and source it if found + if [[ -d $original_home/.nvm ]]; then + echo "Found NVM installation for user $original_user, checking for Node.js 20+..." + export NVM_DIR="$original_home/.nvm" + if [[ -s $NVM_DIR/nvm.sh ]]; then + # shellcheck disable=SC1091 + \. "$NVM_DIR/nvm.sh" + local node_bin_path='' + node_bin_path=$(nvm which current | xargs dirname 2>/dev/null || \ + find "$NVM_DIR/versions/node" -maxdepth 2 -type d -name 'bin' | sort -V | tail -n 1) + + if [[ -n $node_bin_path && -d $node_bin_path ]]; then + echo "Adding NVM Node bin path to PATH: $node_bin_path" + export PATH="$node_bin_path:$PATH" + else + echo 'Warning: Could not determine NVM Node bin path.' + fi + else + echo 'Warning: nvm.sh script not found or not sourceable.' + fi + fi + + echo 'System Information:' + echo "Distribution: $(grep 'PRETTY_NAME' /etc/os-release 2>/dev/null | cut -d'"' -f2 || echo 'Unknown')" + echo "Distribution family: $distro_family" + echo "Target Architecture: $architecture" +} + +parse_arguments() { + section_header 'Argument Parsing' + + project_root="$(pwd)" + work_dir="$project_root/build" + app_staging_dir="$work_dir/electron-app" + + # Set default build format based on detected distro + case "$distro_family" in + debian) build_format='deb' ;; + rpm) build_format='rpm' ;; + nix) build_format='nix' ;; + *) build_format='appimage' ;; + esac + + while (( $# > 0 )); do + case "$1" in + -b|--build|-c|--clean|-e|--exe|-r|--release-tag|-s|--source-dir|--node-pty-dir) + if [[ -z ${2:-} || $2 == -* ]]; then + echo "Error: Argument for $1 is missing" >&2 + exit 1 + fi + case "$1" in + -b|--build) build_format="$2" ;; + -c|--clean) cleanup_action="$2" ;; + -e|--exe) local_exe_path="$2" ;; + -r|--release-tag) release_tag="$2" ;; + -s|--source-dir) source_dir="$2" ;; + --node-pty-dir) node_pty_dir="$2" ;; + esac + shift 2 + ;; + --test-flags) + test_flags_mode=true + shift + ;; + -h|--help) + echo "Usage: $0 [--build deb|rpm|appimage|nix] [--clean yes|no] [--exe /path/to/installer.exe] [--source-dir /path] [--release-tag TAG] [--test-flags]" + echo ' --build: Specify the build format (deb, rpm, appimage, or nix).' + echo " Default: auto-detected based on distro (current: $build_format)" + echo ' --clean: Specify whether to clean intermediate build files (yes or no). Default: yes' + echo ' --exe: Use a local Claude installer exe instead of downloading' + echo ' --source-dir: Path to repo root for scripts/ and assets (default: project root)' + echo ' --node-pty-dir: Path to pre-built node-pty package (skips npm install)' + echo ' --release-tag: Release tag (e.g., v1.3.2+claude1.1.799) to append wrapper version to package' + echo ' --test-flags: Parse flags, print results, and exit without building.' + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + echo 'Use -h or --help for usage information.' >&2 + exit 1 + ;; + esac + done + + # source_dir is where scripts/assets live (default: project_root) + source_dir="${source_dir:-$project_root}" + + # Validate arguments + build_format="${build_format,,}" + cleanup_action="${cleanup_action,,}" + + if [[ ! -d $source_dir ]]; then + echo "Error: --source-dir path does not exist: $source_dir" >&2 + exit 1 + fi + if [[ -n $node_pty_dir && ! -d $node_pty_dir ]]; then + echo "Error: --node-pty-dir path does not exist: $node_pty_dir" >&2 + exit 1 + fi + + if [[ $build_format != 'deb' && $build_format != 'rpm' && $build_format != 'appimage' && $build_format != 'nix' ]]; then + echo "Invalid build format specified: '$build_format'. Must be 'deb', 'rpm', 'appimage', or 'nix'." >&2 + exit 1 + fi + + # Warn if building native package for wrong distro + if [[ $build_format == 'deb' && $distro_family != 'debian' ]]; then + echo "Warning: Building .deb package on non-Debian system ($distro_family). This may fail." >&2 + elif [[ $build_format == 'rpm' && $distro_family != 'rpm' ]]; then + echo "Warning: Building .rpm package on non-RPM system ($distro_family). This may fail." >&2 + fi + if [[ $cleanup_action != 'yes' && $cleanup_action != 'no' ]]; then + echo "Invalid cleanup option specified: '$cleanup_action'. Must be 'yes' or 'no'." >&2 + exit 1 + fi + + echo "Selected build format: $build_format" + echo "Cleanup intermediate files: $cleanup_action" + + [[ $cleanup_action == 'yes' ]] && perform_cleanup=true + + section_footer 'Argument Parsing' +} + +check_dependencies() { + echo 'Checking dependencies...' + local deps_to_install='' + local common_deps='p7zip wget wrestool icotool convert' + local all_deps="$common_deps" + + # Add format-specific dependencies + case "$build_format" in + deb) all_deps="$all_deps dpkg-deb" ;; + rpm) all_deps="$all_deps rpmbuild" ;; + esac + + # Command-to-package mappings per distro family + declare -A debian_pkgs=( + [p7zip]='p7zip-full' [wget]='wget' [wrestool]='icoutils' + [icotool]='icoutils' [convert]='imagemagick' + [dpkg-deb]='dpkg-dev' [rpmbuild]='rpm' + ) + declare -A rpm_pkgs=( + [p7zip]='p7zip p7zip-plugins' [wget]='wget' [wrestool]='icoutils' + [icotool]='icoutils' [convert]='ImageMagick' + [dpkg-deb]='dpkg' [rpmbuild]='rpm-build' + ) + + local cmd + for cmd in $all_deps; do + if ! check_command "$cmd"; then + case "$distro_family" in + debian) + deps_to_install="$deps_to_install ${debian_pkgs[$cmd]}" + ;; + rpm) + deps_to_install="$deps_to_install ${rpm_pkgs[$cmd]}" + ;; + *) + echo "Warning: Cannot auto-install '$cmd' on unknown distro. Please install manually." >&2 + ;; + esac + fi + done + + if [[ -n $deps_to_install ]]; then + echo "System dependencies needed:$deps_to_install" + + # Determine if we need sudo (skip if already root) + local sudo_cmd='sudo' + if (( EUID == 0 )); then + sudo_cmd='' + echo 'Installing as root (no sudo needed)...' + else + echo 'Attempting to install using sudo...' + # Check if we can sudo without a password first + if sudo -n true 2>/dev/null; then + echo 'Passwordless sudo detected.' + elif ! sudo -v; then + echo 'Failed to validate sudo credentials. Please ensure you can run sudo.' >&2 + exit 1 + fi + fi + + case "$distro_family" in + debian) + if ! $sudo_cmd apt update; then + echo "Failed to run 'apt update'." >&2 + exit 1 + fi + # shellcheck disable=SC2086 + if ! $sudo_cmd apt install -y $deps_to_install; then + echo "Failed to install dependencies using 'apt install'." >&2 + exit 1 + fi + ;; + rpm) + # shellcheck disable=SC2086 + if ! $sudo_cmd dnf install -y $deps_to_install; then + echo "Failed to install dependencies using 'dnf install'." >&2 + exit 1 + fi + ;; + *) + echo "Cannot auto-install dependencies on unknown distro." >&2 + echo "Please install these packages manually: $deps_to_install" >&2 + exit 1 + ;; + esac + echo 'System dependencies installed successfully.' + fi +} + +setup_work_directory() { + rm -rf "$work_dir" + mkdir -p "$work_dir" || exit 1 + mkdir -p "$app_staging_dir" || exit 1 +} + +setup_nodejs() { + section_header 'Node.js Setup' + echo 'Checking Node.js version...' + + local node_version_ok=false + if command -v node &> /dev/null; then + local node_version node_major + node_version=$(node --version | cut -d'v' -f2) + node_major="${node_version%%.*}" + echo "System Node.js version: v$node_version" + + if (( node_major >= 20 )); then + echo "System Node.js version is adequate (v$node_version)" + node_version_ok=true + else + echo "System Node.js version is too old (v$node_version). Need v20+" + fi + else + echo 'Node.js not found in system' + fi + + if [[ $node_version_ok == true ]]; then + section_footer 'Node.js Setup' + return 0 + fi + + # Node.js version inadequate - install locally + echo 'Installing Node.js v20 locally in build directory...' + + local node_arch + case "$architecture" in + amd64) node_arch='x64' ;; + arm64) node_arch='arm64' ;; + *) + echo "Unsupported architecture for Node.js: $architecture" >&2 + exit 1 + ;; + esac + + local node_version_to_install='20.18.1' + local node_tarball="node-v${node_version_to_install}-linux-${node_arch}.tar.xz" + local node_url="https://nodejs.org/dist/v${node_version_to_install}/${node_tarball}" + local node_install_dir="$work_dir/node" + + echo "Downloading Node.js v${node_version_to_install} for ${node_arch}..." + cd "$work_dir" || exit 1 + if ! wget -O "$node_tarball" "$node_url"; then + echo "Failed to download Node.js from $node_url" >&2 + cd "$project_root" || exit 1 + exit 1 + fi + + # Verify against official Node.js checksums + local shasums_url node_expected_sha256 + shasums_url="https://nodejs.org/dist/v${node_version_to_install}/SHASUMS256.txt" + node_expected_sha256=$( + wget -qO- "$shasums_url" \ + | grep -F "$node_tarball" \ + | awk '{print $1}' + ) || true + + if ! verify_sha256 "$work_dir/$node_tarball" \ + "$node_expected_sha256" 'Node.js tarball'; then + cd "$project_root" || exit 1 + exit 1 + fi + + echo 'Extracting Node.js...' + if ! tar -xf "$node_tarball"; then + echo 'Failed to extract Node.js tarball' >&2 + cd "$project_root" || exit 1 + exit 1 + fi + + mv "node-v${node_version_to_install}-linux-${node_arch}" "$node_install_dir" || exit 1 + export PATH="$node_install_dir/bin:$PATH" + + if command -v node &> /dev/null; then + echo "Local Node.js installed successfully: $(node --version)" + else + echo 'Failed to install local Node.js' >&2 + cd "$project_root" || exit 1 + exit 1 + fi + + rm -f "$node_tarball" + cd "$project_root" || exit 1 + section_footer 'Node.js Setup' +} + +setup_electron_asar() { + section_header 'Electron & Asar Handling' + + echo "Ensuring local Electron and Asar installation in $work_dir..." + cd "$work_dir" || exit 1 + + if [[ ! -f package.json ]]; then + echo "Creating temporary package.json in $work_dir for local install..." + echo '{"name":"claude-desktop-build","version":"0.0.1","private":true}' > package.json + fi + + local electron_dist_path="$work_dir/node_modules/electron/dist" + local asar_bin_path="$work_dir/node_modules/.bin/asar" + local install_needed=false + + [[ ! -d $electron_dist_path ]] && echo 'Electron distribution not found.' && install_needed=true + [[ ! -f $asar_bin_path ]] && echo 'Asar binary not found.' && install_needed=true + + if [[ $install_needed == true ]]; then + echo "Installing Electron and Asar locally into $work_dir..." + if ! npm install --no-save electron @electron/asar; then + echo 'Failed to install Electron and/or Asar locally.' >&2 + cd "$project_root" || exit 1 + exit 1 + fi + echo 'Electron and Asar installation command finished.' + else + echo 'Local Electron distribution and Asar binary already present.' + fi + + if [[ -d $electron_dist_path ]]; then + echo "Found Electron distribution directory at $electron_dist_path." + chosen_electron_module_path="$(realpath "$work_dir/node_modules/electron")" + echo "Setting Electron module path for copying to $chosen_electron_module_path." + else + echo "Failed to find Electron distribution directory at '$electron_dist_path' after installation attempt." >&2 + cd "$project_root" || exit 1 + exit 1 + fi + + if [[ -f $asar_bin_path ]]; then + asar_exec="$(realpath "$asar_bin_path")" + echo "Found local Asar binary at $asar_exec." + else + echo "Failed to find Asar binary at '$asar_bin_path' after installation attempt." >&2 + cd "$project_root" || exit 1 + exit 1 + fi + + cd "$project_root" || exit 1 + + if [[ -z $chosen_electron_module_path || ! -d $chosen_electron_module_path ]]; then + echo 'Critical error: Could not resolve a valid Electron module path to copy.' >&2 + exit 1 + fi + + echo "Using Electron module path: $chosen_electron_module_path" + echo "Using asar executable: $asar_exec" + section_footer 'Electron & Asar Handling' +} + +#=============================================================================== +# Download and Extract Functions +#=============================================================================== + +download_claude_installer() { + section_header 'Download the latest Claude executable' + + local claude_exe_path="$work_dir/$claude_exe_filename" + + if [[ -n $local_exe_path ]]; then + echo "Using local Claude installer: $local_exe_path" + if [[ ! -f $local_exe_path ]]; then + echo "Local installer file not found: $local_exe_path" >&2 + exit 1 + fi + cp "$local_exe_path" "$claude_exe_path" || exit 1 + echo 'Local installer copied to build directory' + else + echo "Downloading Claude Desktop installer for $architecture..." + if ! wget -O "$claude_exe_path" "$claude_download_url"; then + echo "Failed to download Claude Desktop installer from $claude_download_url" >&2 + exit 1 + fi + echo "Download complete: $claude_exe_filename" + + if ! verify_sha256 "$claude_exe_path" \ + "$claude_exe_sha256" 'Claude Desktop installer'; then + exit 1 + fi + fi + + echo "Extracting resources from $claude_exe_filename into separate directory..." + claude_extract_dir="$work_dir/claude-extract" + mkdir -p "$claude_extract_dir" || exit 1 + + if ! 7z x -y "$claude_exe_path" -o"$claude_extract_dir"; then + echo 'Failed to extract installer' >&2 + cd "$project_root" || exit 1 + exit 1 + fi + + cd "$claude_extract_dir" || exit 1 + local nupkg_path_relative + nupkg_path_relative=$(find . -maxdepth 1 -name 'AnthropicClaude-*.nupkg' | head -1) + + if [[ -z $nupkg_path_relative ]]; then + echo "Could not find AnthropicClaude nupkg file in $claude_extract_dir" >&2 + cd "$project_root" || exit 1 + exit 1 + fi + echo "Found nupkg: $nupkg_path_relative (in $claude_extract_dir)" + + version=$(echo "$nupkg_path_relative" | LC_ALL=C grep -oP 'AnthropicClaude-\K[0-9]+\.[0-9]+\.[0-9]+(?=-full|-arm64-full)') + if [[ -z $version ]]; then + echo "Could not extract version from nupkg filename: $nupkg_path_relative" >&2 + cd "$project_root" || exit 1 + exit 1 + fi + echo "Detected Claude version: $version" + + # Extract wrapper version from release tag if provided (e.g., v1.3.2+claude1.1.799 -> 1.3.2) + if [[ -n $release_tag ]]; then + local wrapper_version + # Extract version between 'v' and '+claude' (e.g., v1.3.2+claude1.1.799 -> 1.3.2) + wrapper_version=$(echo "$release_tag" | LC_ALL=C grep -oP '^v\K[0-9]+\.[0-9]+\.[0-9]+(?=\+claude)') + if [[ -n $wrapper_version ]]; then + version="${version}-${wrapper_version}" + echo "Package version with wrapper suffix: $version" + else + echo "Warning: Could not extract wrapper version from release tag: $release_tag" >&2 + fi + fi + + if ! 7z x -y "$nupkg_path_relative"; then + echo 'Failed to extract nupkg' >&2 + cd "$project_root" || exit 1 + exit 1 + fi + echo 'Resources extracted from nupkg' + + cd "$project_root" || exit 1 +} + +#=============================================================================== +# Patching Functions +#=============================================================================== + +patch_app_asar() { + echo 'Processing app.asar...' + cp "$claude_extract_dir/lib/net45/resources/app.asar" "$app_staging_dir/" || exit 1 + cp -a "$claude_extract_dir/lib/net45/resources/app.asar.unpacked" "$app_staging_dir/" || exit 1 + cd "$app_staging_dir" || exit 1 + "$asar_exec" extract app.asar app.asar.contents || exit 1 + + # Frame fix wrapper + echo 'Creating BrowserWindow frame fix wrapper...' + local original_main + original_main=$(node -e "const pkg = require('./app.asar.contents/package.json'); console.log(pkg.main);") + echo "Original main entry: $original_main" + + cp "$source_dir/scripts/frame-fix-wrapper.js" app.asar.contents/frame-fix-wrapper.js || exit 1 + + cat > app.asar.contents/frame-fix-entry.js << EOFENTRY +// Load frame fix first +require('./frame-fix-wrapper.js'); +// Then load original main +require('./${original_main}'); +EOFENTRY + + # BrowserWindow frame/titleBarStyle patching is handled at runtime by + # frame-fix-wrapper.js via a Proxy on require('electron'). No sed patches + # needed — the wrapper detects popup vs main windows by their options and + # applies frame:true/false accordingly. + + # Update package.json + echo 'Modifying package.json to load frame fix and add node-pty...' + node -e " +const fs = require('fs'); +const pkg = require('./app.asar.contents/package.json'); +pkg.originalMain = pkg.main; +pkg.main = 'frame-fix-entry.js'; +pkg.optionalDependencies = pkg.optionalDependencies || {}; +pkg.optionalDependencies['node-pty'] = '^1.0.0'; +fs.writeFileSync('./app.asar.contents/package.json', JSON.stringify(pkg, null, 2)); +console.log('Updated package.json: main entry and node-pty dependency'); +" + + # Create stub native module + echo 'Creating stub native module...' + mkdir -p app.asar.contents/node_modules/@ant/claude-native || exit 1 + cp "$source_dir/scripts/claude-native-stub.js" \ + app.asar.contents/node_modules/@ant/claude-native/index.js || exit 1 + + mkdir -p app.asar.contents/resources/i18n || exit 1 + cp "$claude_extract_dir/lib/net45/resources/"*-*.json app.asar.contents/resources/i18n/ || exit 1 + + # Copy tray icons into asar so both packaged (process.resourcesPath) + # and unpackaged (app.getAppPath()) code paths can find them + cp "$claude_extract_dir/lib/net45/resources/Tray"* app.asar.contents/resources/ 2>/dev/null || \ + echo 'Warning: No tray icon files found for asar inclusion' + + # Patch title bar detection + patch_titlebar_detection + + # Extract electron module variable name for tray patches + extract_electron_variable + + # Fix incorrect nativeTheme variable references + fix_native_theme_references + + # Patch tray menu handler + patch_tray_menu_handler + + # Patch tray icon selection + patch_tray_icon_selection + + # Patch menuBarEnabled to default to true when unset + patch_menu_bar_default + + # Patch quick window + patch_quick_window + + # Add Linux Claude Code support + patch_linux_claude_code + + # Patch Cowork mode for Linux (TypeScript VM client + Unix socket) + patch_cowork_linux + + # Copy cowork VM service daemon for Linux Cowork mode + echo 'Installing cowork VM service daemon...' + cp "$source_dir/scripts/cowork-vm-service.js" \ + app.asar.contents/cowork-vm-service.js || exit 1 + echo 'Cowork VM service daemon installed' +} + +patch_titlebar_detection() { + echo '##############################################################' + echo "Removing '!' from 'if (\"!\"isWindows && isMainWindow) return null;'" + echo 'detection flag to enable title bar' + + local search_base='app.asar.contents/.vite/renderer/main_window/assets' + local target_pattern='MainWindowPage-*.js' + + echo "Searching for '$target_pattern' within '$search_base'..." + local target_files + mapfile -t target_files < <(find "$search_base" -type f -name "$target_pattern") + local num_files=${#target_files[@]} + + case $num_files in + 0) + echo "Error: No file matching '$target_pattern' found within '$search_base'." >&2 + exit 1 + ;; + 1) + local target_file="${target_files[0]}" + echo "Found target file: $target_file" + sed -i -E 's/if\(!([a-zA-Z]+)[[:space:]]*&&[[:space:]]*([a-zA-Z]+)\)/if(\1 \&\& \2)/g' "$target_file" + + if grep -q -E 'if\(![a-zA-Z]+[[:space:]]*&&[[:space:]]*[a-zA-Z]+\)' "$target_file"; then + echo "Error: Failed to replace patterns in $target_file." >&2 + exit 1 + fi + echo "Successfully replaced patterns in $target_file" + ;; + *) + echo "Error: Expected exactly one file matching '$target_pattern' within '$search_base', but found $num_files." >&2 + exit 1 + ;; + esac + echo '##############################################################' +} + +extract_electron_variable() { + echo 'Extracting electron module variable name...' + local index_js='app.asar.contents/.vite/build/index.js' + + electron_var=$(grep -oP '\$?\w+(?=\s*=\s*require\("electron"\))' \ + "$index_js" | head -1) + if [[ -z $electron_var ]]; then + electron_var=$(grep -oP '(?<=new )\$?\w+(?=\.Tray\b)' \ + "$index_js" | head -1) + fi + if [[ -z $electron_var ]]; then + echo 'Failed to extract electron variable name' >&2 + cd "$project_root" || exit 1 + exit 1 + fi + electron_var_re="${electron_var//\$/\\$}" + echo " Found electron variable: $electron_var" + echo '##############################################################' +} + +fix_native_theme_references() { + echo 'Fixing incorrect nativeTheme variable references...' + local index_js='app.asar.contents/.vite/build/index.js' + + local wrong_refs + mapfile -t wrong_refs < <( + grep -oP '\$?\w+(?=\.nativeTheme)' "$index_js" \ + | sort -u \ + | grep -Fxv "$electron_var" || true + ) + + if (( ${#wrong_refs[@]} == 0 )); then + echo ' All nativeTheme references are correct' + echo '##############################################################' + return + fi + + local ref ref_re + for ref in "${wrong_refs[@]}"; do + echo " Replacing: $ref.nativeTheme -> $electron_var.nativeTheme" + ref_re="${ref//\$/\\$}" + sed -i -E \ + "s/${ref_re}\.nativeTheme/${electron_var_re}.nativeTheme/g" \ + "$index_js" + done + echo '##############################################################' +} + +patch_tray_menu_handler() { + echo 'Patching tray menu handler...' + local index_js='app.asar.contents/.vite/build/index.js' + + local tray_func tray_var first_const + tray_func=$(grep -oP \ + 'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' "$index_js") + if [[ -z $tray_func ]]; then + echo 'Failed to extract tray menu function name' >&2 + cd "$project_root" || exit 1 + exit 1 + fi + echo " Found tray function: $tray_func" + + tray_var=$(grep -oP \ + "\}\);let \K\w+(?==null;(?:async )?function ${tray_func})" \ + "$index_js") + if [[ -z $tray_var ]]; then + echo 'Failed to extract tray variable name' >&2 + cd "$project_root" || exit 1 + exit 1 + fi + echo " Found tray variable: $tray_var" + + sed -i "s/function ${tray_func}(){/async function ${tray_func}(){/g" \ + "$index_js" + + first_const=$(grep -oP \ + "async function ${tray_func}\(\)\{.*?const \K\w+(?==)" \ + "$index_js" | head -1) + if [[ -z $first_const ]]; then + echo 'Failed to extract first const in function' >&2 + cd "$project_root" || exit 1 + exit 1 + fi + echo " Found first const variable: $first_const" + + # Add mutex guard to prevent concurrent tray rebuilds + if ! grep -q "${tray_func}._running" "$index_js"; then + sed -i "s/async function ${tray_func}(){/async function ${tray_func}(){if(${tray_func}._running)return;${tray_func}._running=true;setTimeout(()=>${tray_func}._running=false,1500);/g" \ + "$index_js" + echo " Added mutex guard to ${tray_func}()" + fi + + # Add DBus cleanup delay after tray destroy + if ! grep -q "await new Promise.*setTimeout" "$index_js" \ + | grep -q "$tray_var"; then + sed -i "s/${tray_var}\&\&(${tray_var}\.destroy(),${tray_var}=null)/${tray_var}\&\&(${tray_var}.destroy(),${tray_var}=null,await new Promise(r=>setTimeout(r,250)))/g" \ + "$index_js" + echo " Added DBus cleanup delay after $tray_var.destroy()" + fi + + echo 'Tray menu handler patched' + echo '##############################################################' + + # Skip tray updates during startup (3 second window) + echo 'Patching nativeTheme handler for startup delay...' + if ! grep -q '_trayStartTime' "$index_js"; then + sed -i -E \ + "s/(${electron_var_re}\.nativeTheme\.on\(\s*\"updated\"\s*,\s*\(\)\s*=>\s*\{)/let _trayStartTime=Date.now();\1/g" \ + "$index_js" + sed -i -E \ + "s/\((\w+\([^)]*\))\s*,\s*${tray_func}\(\)\s*,/(\1,Date.now()-_trayStartTime>3e3\&\&${tray_func}(),/g" \ + "$index_js" + echo ' Added startup delay check (3 second window)' + fi + echo '##############################################################' +} + +patch_tray_icon_selection() { + echo 'Patching tray icon selection for Linux visibility...' + local index_js='app.asar.contents/.vite/build/index.js' + local dark_check="${electron_var_re}.nativeTheme.shouldUseDarkColors" + + if grep -qP ':\$?\w+="TrayIconTemplate\.png"' "$index_js"; then + sed -i -E \ + "s/:(\\\$?\w+)=\"TrayIconTemplate\.png\"/:\1=${dark_check}?\"TrayIconTemplate-Dark.png\":\"TrayIconTemplate.png\"/g" \ + "$index_js" + echo 'Patched tray icon selection for Linux theme support' + else + echo 'Tray icon selection pattern not found or already patched' + fi + echo '##############################################################' +} + +patch_menu_bar_default() { + echo 'Patching menuBarEnabled to default to true when unset...' + local index_js='app.asar.contents/.vite/build/index.js' + + local menu_bar_var + menu_bar_var=$(grep -oP \ + 'const \K\w+(?=\s*=\s*\w+\("menuBarEnabled"\))' \ + "$index_js" | head -1) + if [[ -z $menu_bar_var ]]; then + echo ' Could not extract menuBarEnabled variable name' + echo '##############################################################' + return + fi + echo " Found menuBarEnabled variable: $menu_bar_var" + + # Change !!var to var!==false so undefined defaults to true + if grep -qP ",\s*!!${menu_bar_var}\s*\)" "$index_js"; then + sed -i -E \ + "s/,\s*!!${menu_bar_var}\s*\)/,${menu_bar_var}!==false)/g" \ + "$index_js" + echo ' Patched menuBarEnabled to default to true' + else + echo ' menuBarEnabled pattern not found or already patched' + fi + echo '##############################################################' +} + +patch_quick_window() { + if ! grep -q 'e.blur(),e.hide()' app.asar.contents/.vite/build/index.js; then + sed -i 's/e.hide()/e.blur(),e.hide()/' app.asar.contents/.vite/build/index.js + echo 'Added blur() call to fix quick window submit issue' + fi +} + +patch_linux_claude_code() { + local index_js='app.asar.contents/.vite/build/index.js' + if grep -q 'process.platform==="linux".*linux-arm64.*linux-x64' "$index_js"; then + echo 'Linux claude code binary support already present' + return + fi + + # New format (Claude >= 1.1.3541): getHostPlatform includes arch detection for win32 + # Pattern: if(process.platform==="win32")return e==="arm64"?"win32-arm64":"win32-x64";throw new Error(...) + if grep -qP 'if\(process\.platform==="win32"\)return \w+==="arm64"\?"win32-arm64":"win32-x64";throw' "$index_js"; then + sed -i -E 's/if\(process\.platform==="win32"\)return (\w+)==="arm64"\?"win32-arm64":"win32-x64";throw/if(process.platform==="win32")return \1==="arm64"?"win32-arm64":"win32-x64";if(process.platform==="linux")return \1==="arm64"?"linux-arm64":"linux-x64";throw/' "$index_js" + echo 'Added linux claude code support (new arch-aware format)' + # Old format (Claude <= 1.1.3363): no arch detection for win32 + elif grep -q 'if(process.platform==="win32")return"win32-x64";' "$index_js"; then + sed -i 's/if(process.platform==="win32")return"win32-x64";/if(process.platform==="win32")return"win32-x64";if(process.platform==="linux")return process.arch==="arm64"?"linux-arm64":"linux-x64";/' "$index_js" + echo 'Added linux claude code support (legacy format)' + else + echo 'Warning: Could not find getHostPlatform pattern to patch for Linux claude code support' + fi +} + +patch_cowork_linux() { + echo 'Patching Cowork mode for Linux...' + local index_js='app.asar.contents/.vite/build/index.js' + + if ! grep -q 'vmClient (TypeScript)' "$index_js"; then + echo ' Cowork mode code not found in this version, skipping' + echo '##############################################################' + return + fi + + # All complex patches are done via node to avoid shell escaping issues + # with minified JavaScript. Uses unique string anchors and dynamic + # variable extraction to be version-agnostic per CLAUDE.md guidelines. + if ! INDEX_JS="$index_js" SVC_PATH="cowork-vm-service.js" \ + node << 'COWORK_PATCH' +const fs = require('fs'); +const indexJs = process.env.INDEX_JS; +let code = fs.readFileSync(indexJs, 'utf8'); +let patchCount = 0; + +// Helper: extract a balanced block starting at a delimiter. +// Returns the substring from open to close (inclusive), or null. +// Works for {} [] () by specifying the open char. +function extractBlock(str, startIdx, open = '{') { + const close = { '{': '}', '[': ']', '(': ')' }[open]; + const blockStart = str.indexOf(open, startIdx); + if (blockStart === -1) return null; + let depth = 1; + let pos = blockStart + 1; + while (depth > 0 && pos < str.length) { + if (str[pos] === open) depth++; + else if (str[pos] === close) depth--; + pos++; + } + return depth === 0 ? str.substring(blockStart, pos) : null; +} + +// ============================================================ +// Patch 1: Platform check - allow Linux through fz() +// Pattern: VAR!=="darwin"&&VAR!=="win32" (unique in platform gate) +// Anchor: appears near 'unsupported_platform' code value +// ============================================================ +const platformGateRe = /(\w+)(\s*!==\s*"darwin"\s*&&\s*)\1(\s*!==\s*"win32")/g; +const origCode = code; +code = code.replace(platformGateRe, (match, varName, mid, end) => { + // Only patch the instance near the "unsupported_platform" code value + const matchIdx = origCode.indexOf(match); + const nearbyText = origCode.substring(matchIdx, matchIdx + 200); + if (nearbyText.includes('unsupported_platform') || nearbyText.includes('Unsupported platform')) { + return `${varName}${mid}${varName}${end}&&${varName}!=="linux"`; + } + return match; +}); +if (code !== origCode) { + console.log(' Patched platform check to allow Linux'); + patchCount++; +} else { + // Try without backreference (in case minifier uses different var names) + const simpleRe = /(!=="darwin"\s*&&\s*\w+\s*!=="win32")([\s\S]{0,200}unsupported_platform)/; + const simpleMatch = code.match(simpleRe); + if (simpleMatch) { + const varMatch = simpleMatch[0].match(/(\w+)\s*!==\s*"win32"/); + if (varMatch) { + code = code.replace(simpleMatch[1], + simpleMatch[1] + '&&' + varMatch[1] + '!=="linux"'); + console.log(' Patched platform check to allow Linux (fallback)'); + patchCount++; + } + } +} +if (code === origCode) { + console.error('FATAL: Failed to patch cowork platform gate for Linux.'); + console.error('The app will crash at startup without this patch.'); + console.error('The platform check pattern or nearby anchor text may have changed.'); + process.exit(1); +} + +// ============================================================ +// Patch 2: Module loading - use TypeScript VM client on Linux +// Anchor: unique string "vmClient (TypeScript)" +// Extracts the win32 platform variable, adds Linux OR condition +// ============================================================ +const vmClientLogMatch = code.match(/(\w+)(\s*\?\s*"vmClient \(TypeScript\)")/); +if (vmClientLogMatch) { + const win32Var = vmClientLogMatch[1]; + + // 2a: Patch the log/description line + // FROM: WIN32VAR?"vmClient (TypeScript)" + // TO: (WIN32VAR||process.platform==="linux")?"vmClient (TypeScript)" + // Use negative lookbehind to avoid double-patching + const logRe = new RegExp( + '(?\s*setTimeout\(\1,\s*(\w+)\)\)/ + ); + if (retryMatch) { + const retryStr = retryMatch[0]; + const retryOffset = searchRegion.indexOf(retryStr); + const retryAbsIdx = newServiceErrorIdx + retryOffset; + // Inject auto-launch before the retry delay + // Service script is in app.asar.unpacked/ (not inside asar, since + // child_process cannot execute scripts from inside an asar). + // Uses fork() instead of spawn() because process.execPath in Electron + // is the Electron binary - spawn would trigger "file open" handling + // instead of executing the script as Node.js. + const svcPath = process.env.SVC_PATH || 'cowork-vm-service.js'; + // Extract the enclosing function name (Ma or whatever it's + // minified to) so the dedup guard attaches to it + const funcSearchStart = Math.max(0, newServiceErrorIdx - 2000); + const funcRegion = code.substring(funcSearchStart, newServiceErrorIdx); + // The function is defined as: async function NAME(t,e){...for(let r=0;r<=LIMIT;r++) + const funcNameRe = /async function (\w+)\s*\(\s*\w+\s*,\s*\w+\s*\)\s*\{[\s\S]*?for\s*\(\s*let/g; + let funcMatch; + let retryFuncName = null; + while ((funcMatch = funcNameRe.exec(funcRegion)) !== null) { + retryFuncName = funcMatch[1]; + } + const svcLaunchedGuard = retryFuncName + ? retryFuncName + '._svcLaunched' + : '_globalSvcLaunched'; + const autoLaunch = + 'process.platform==="linux"&&!' + svcLaunchedGuard + '&&(' + svcLaunchedGuard + '=true,' + + '(()=>{try{' + + 'const _d=require("path").join(process.resourcesPath,' + + '"app.asar.unpacked","' + svcPath + '");' + + 'if(require("fs").existsSync(_d)){' + + 'const _c=require("child_process").fork(_d,[],' + + '{detached:true,stdio:"ignore",env:{...process.env,' + + 'ELECTRON_RUN_AS_NODE:"1"}});_c.unref()}' + + '}catch(_e){console.error("[cowork-autolaunch]",_e)}})()),'; + code = code.substring(0, retryAbsIdx) + + autoLaunch + code.substring(retryAbsIdx); + console.log(' Added service daemon auto-launch on Linux'); + patchCount++; + } else { + console.log(' WARNING: Could not find retry delay for auto-launch patch'); + } +} else { + console.log(' WARNING: Could not find VM service error string for auto-launch'); +} + +// ============================================================ +// Patch 7: Skip Windows-specific smol-bin.vhdx copy on Linux +// The code already checks: if(process.platform==="win32") +// No change needed - win32-gated code is skipped on Linux. +// ============================================================ + +// ============================================================ +// Patch 8: VM download tmpdir fix for Linux +// On Linux, os.tmpdir() returns /tmp which is often a small +// tmpfs (3-4GB). The VM rootfs download decompresses to ~9GB, +// causing ENOSPC. Patch to use the bundle directory (on real +// disk) instead of tmpfs for the download temp files. +// Anchor: unique string "wvm-" in mkdtemp call +// Strategy: find the bundle dir variable from nearby mkdir(), +// then replace tmpdir() with that variable in the mkdtemp call. +// ============================================================ +{ + // Find: MKDTEMP(PATH.join(OS.tmpdir(), "wvm-")) + // The bundle dir var is used in mkdir(VAR, ...) just before + const mkdtempRe = /(\w+)\.mkdtemp\(\s*(\w+)\.join\(\s*(\w+)\.tmpdir\(\)\s*,\s*"wvm-"\s*\)\s*\)/; + const mkdtempMatch = code.match(mkdtempRe); + if (mkdtempMatch) { + const [fullMatch, fsVar, pathVar, osVar] = mkdtempMatch; + // Find the bundle dir variable: mkdir(VAR, { recursive before wvm- + const mkdtempIdx = code.indexOf(fullMatch); + const searchStart = Math.max(0, mkdtempIdx - 2000); + const before = code.substring(searchStart, mkdtempIdx); + // Look for: mkdir(VARNAME, { recursive + const mkdirRe = /(\w+)\.mkdir\(\s*(\w+)\s*,\s*\{\s*recursive/g; + let bundleVar = null; + let lastMkdir; + while ((lastMkdir = mkdirRe.exec(before)) !== null) { + bundleVar = lastMkdir[2]; + } + if (bundleVar) { + // Replace os.tmpdir() with the bundle dir variable + // On Linux, use the bundle dir; on other platforms keep tmpdir + const replacement = + `${fsVar}.mkdtemp(${pathVar}.join(` + + `process.platform==="linux"?${bundleVar}:${osVar}.tmpdir(),` + + `"wvm-"))`; + code = code.substring(0, mkdtempIdx) + replacement + + code.substring(mkdtempIdx + fullMatch.length); + console.log(' Patched VM download temp dir to use bundle path on Linux'); + patchCount++; + } else { + console.log(' WARNING: Could not find bundle dir variable for tmpdir patch'); + } + } else { + console.log(' WARNING: Could not find mkdtemp("wvm-") for tmpdir patch'); + } +} + +// ============================================================ +// Patch 9: Copy smol-bin VHDX on Linux +// The win32 block copies smol-bin then calls _.configure() +// (Windows HCS setup) which causes "Request timed out" on +// Linux (#315). Inject a separate Linux block after the win32 +// block that only does the smol-bin copy. +// Variable names are extracted dynamically from the win32 block +// since minified names change between releases (#344). +// ============================================================ +{ + const anchor = '"[VM:start] Windows VM service configured"'; + const anchorIdx = code.indexOf(anchor); + if (anchorIdx !== -1) { + // Find the "}" closing the win32 if-block after the anchor + const closingBrace = code.indexOf('}', anchorIdx + anchor.length); + if (closingBrace !== -1) { + // Extract minified variable names from the win32 block + // Search backwards from anchor to find the win32 block + const regionStart = Math.max(0, anchorIdx - 1000); + const region = code.substring(regionStart, anchorIdx); + + // path var: VAR.join(process.resourcesPath, + const pathMatch = region.match( + /(\w+)\.join\(\s*process\.resourcesPath\s*,/ + ); + // fs var: VAR.existsSync( + const fsMatch = region.match(/(\w+)\.existsSync\(/); + // logger var: VAR.info("[VM:start] + const logMatch = region.match( + /(\w+)\.info\(\s*[`"]\[VM:start\]/ + ); + // stream/pipeline var: VAR.pipeline( + const streamMatch = region.match(/(\w+)\.pipeline\(/); + // arch function: const VAR=FUNC(), used in smol-bin + const archMatch = region.match( + /const\s+(\w+)\s*=\s*(\w+)\(\)\s*,\s*\w+\s*=\s*\w+\.join/ + ); + // bundlePath var: PATH.join(VAR,"smol-bin.vhdx") + const bundleMatch = region.match( + /\.join\(\s*(\w+)\s*,\s*"smol-bin\.vhdx"\s*\)/ + ); + + if (pathMatch && fsMatch && logMatch && + streamMatch && archMatch && bundleMatch) { + const pathVar = pathMatch[1]; + const fsVar = fsMatch[1]; + const logVar = logMatch[1]; + const streamVar = streamMatch[1]; + const archFunc = archMatch[2]; + const bundleVar = bundleMatch[1]; + + const linuxBlock = + 'if(process.platform==="linux"){' + + 'const _la=' + archFunc + '(),' + + '_ls=' + pathVar + '.join(process.resourcesPath,' + + '`smol-bin.${_la}.vhdx`),' + + '_ld=' + pathVar + '.join(' + bundleVar + + ',"smol-bin.vhdx");' + + fsVar + '.existsSync(_ls)?' + + '(' + logVar + '.info(' + + '`[VM:start] Copying smol-bin.${_la}' + + '.vhdx to bundle (Linux)`),' + + 'await ' + streamVar + '.pipeline(' + + fsVar + '.createReadStream(_ls),' + + fsVar + '.createWriteStream(_ld)),' + + logVar + '.info(' + + '`[VM:start] smol-bin.${_la}' + + '.vhdx copied successfully`))' + + ':' + logVar + '.warn(' + + '`[VM:start] smol-bin.${_la}' + + '.vhdx not found at ${_ls}`)' + + '}'; + code = code.substring(0, closingBrace + 1) + + linuxBlock + + code.substring(closingBrace + 1); + console.log(' Injected Linux smol-bin copy block (skips _.configure)'); + console.log(` vars: path=${pathVar} fs=${fsVar} log=${logVar} stream=${streamVar} arch=${archFunc} bundle=${bundleVar}`); + patchCount++; + } else { + const missing = []; + if (!pathMatch) missing.push('path'); + if (!fsMatch) missing.push('fs'); + if (!logMatch) missing.push('logger'); + if (!streamMatch) missing.push('stream'); + if (!archMatch) missing.push('arch'); + if (!bundleMatch) missing.push('bundlePath'); + console.log(` WARNING: Could not extract minified variable(s): ${missing.join(', ')}`); + } + } else { + console.log(' WARNING: Could not find closing brace after Windows VM service anchor'); + } + } else { + console.log(' WARNING: Could not find Windows VM service anchor for smol-bin patch'); + } +} + +fs.writeFileSync(indexJs, code); +console.log(` Applied ${patchCount} cowork patches`); +if (patchCount < 5) { + console.log(' WARNING: Some patches failed - Cowork mode may not work'); +} +COWORK_PATCH + then + echo 'WARNING: Cowork Linux patches failed' >&2 + echo 'Cowork mode may not be available on Linux' >&2 + fi + + echo '##############################################################' +} + +install_node_pty() { + section_header 'Installing node-pty for terminal support' + + local pty_src_dir='' + + if [[ -n $node_pty_dir ]]; then + # Use pre-built node-pty (e.g. from Nix) + echo "Using pre-built node-pty from $node_pty_dir" + pty_src_dir="$node_pty_dir" + else + # Build node-pty from npm + node_pty_build_dir="$work_dir/node-pty-build" + mkdir -p "$node_pty_build_dir" || exit 1 + cd "$node_pty_build_dir" || exit 1 + echo '{"name":"node-pty-build","version":"1.0.0","private":true}' > package.json + + echo 'Installing node-pty (this compiles native module)...' + if npm install node-pty 2>&1; then + echo 'node-pty installed successfully' + pty_src_dir="$node_pty_build_dir/node_modules/node-pty" + else + echo 'Failed to install node-pty - terminal features may not work' + fi + fi + + if [[ -n $pty_src_dir && -d $pty_src_dir ]]; then + echo 'Copying node-pty JavaScript files into app.asar.contents...' + mkdir -p "$app_staging_dir/app.asar.contents/node_modules/node-pty" || exit 1 + cp -r "$pty_src_dir/lib" \ + "$app_staging_dir/app.asar.contents/node_modules/node-pty/" || exit 1 + cp "$pty_src_dir/package.json" \ + "$app_staging_dir/app.asar.contents/node_modules/node-pty/" || exit 1 + echo 'node-pty JavaScript files copied' + elif [[ -z $pty_src_dir ]]; then + echo 'node-pty source directory not set' + else + echo "node-pty directory not found: $pty_src_dir" + fi + + cd "$app_staging_dir" || exit 1 + section_footer 'node-pty installation' +} + +finalize_app_asar() { + "$asar_exec" pack app.asar.contents app.asar || exit 1 + + mkdir -p "$app_staging_dir/app.asar.unpacked/node_modules/@ant/claude-native" || exit 1 + cp "$source_dir/scripts/claude-native-stub.js" \ + "$app_staging_dir/app.asar.unpacked/node_modules/@ant/claude-native/index.js" || exit 1 + + # Copy cowork VM service daemon (must be unpacked for child_process.fork) + echo 'Copying cowork VM service daemon to unpacked directory...' + cp "$source_dir/scripts/cowork-vm-service.js" \ + "$app_staging_dir/app.asar.unpacked/cowork-vm-service.js" || exit 1 + echo 'Cowork VM service daemon copied to unpacked' + + # Copy node-pty native binaries + local pty_release_dir='' + if [[ -n $node_pty_dir && -d $node_pty_dir/build/Release ]]; then + pty_release_dir="$node_pty_dir/build/Release" + elif [[ -n $node_pty_build_dir && -d $node_pty_build_dir/node_modules/node-pty/build/Release ]]; then + pty_release_dir="$node_pty_build_dir/node_modules/node-pty/build/Release" + fi + + if [[ -n $pty_release_dir ]]; then + echo 'Copying node-pty native binaries to unpacked directory...' + mkdir -p "$app_staging_dir/app.asar.unpacked/node_modules/node-pty/build/Release" || exit 1 + cp -r "$pty_release_dir/"* \ + "$app_staging_dir/app.asar.unpacked/node_modules/node-pty/build/Release/" || exit 1 + chmod +x "$app_staging_dir/app.asar.unpacked/node_modules/node-pty/build/Release/"* 2>/dev/null || true + echo 'node-pty native binaries copied' + else + echo 'node-pty native binaries not found - terminal features may not work' + fi +} + +#=============================================================================== +# Staging Functions +#=============================================================================== + +stage_electron() { + echo 'Copying chosen electron installation to staging area...' + mkdir -p "$app_staging_dir/node_modules/" || exit 1 + local electron_dir_name + electron_dir_name=$(basename "$chosen_electron_module_path") + echo "Copying from $chosen_electron_module_path to $app_staging_dir/node_modules/" + cp -a "$chosen_electron_module_path" "$app_staging_dir/node_modules/" || exit 1 + + local staged_electron_bin="$app_staging_dir/node_modules/$electron_dir_name/dist/electron" + if [[ -f $staged_electron_bin ]]; then + echo "Setting executable permission on staged Electron binary: $staged_electron_bin" + chmod +x "$staged_electron_bin" || exit 1 + else + echo "Warning: Staged Electron binary not found at expected path: $staged_electron_bin" + fi + + # Copy Electron locale files + local electron_resources_src="$chosen_electron_module_path/dist/resources" + electron_resources_dest="$app_staging_dir/node_modules/$electron_dir_name/dist/resources" + if [[ -d $electron_resources_src ]]; then + echo 'Copying Electron locale resources...' + mkdir -p "$electron_resources_dest" || exit 1 + cp -a "$electron_resources_src"/* "$electron_resources_dest/" || exit 1 + echo 'Electron locale resources copied' + else + echo "Warning: Electron resources directory not found at $electron_resources_src" + fi +} + +process_icons() { + section_header 'Icon Processing' + + cd "$claude_extract_dir" || exit 1 + local exe_path='lib/net45/claude.exe' + if [[ ! -f $exe_path ]]; then + echo "Cannot find claude.exe at expected path: $claude_extract_dir/$exe_path" >&2 + cd "$project_root" || exit 1 + exit 1 + fi + + echo "Extracting application icons from $exe_path..." + if ! wrestool -x -t 14 "$exe_path" -o claude.ico; then + echo 'Failed to extract icons from exe' >&2 + cd "$project_root" || exit 1 + exit 1 + fi + + if ! icotool -x claude.ico; then + echo 'Failed to convert icons' >&2 + cd "$project_root" || exit 1 + exit 1 + fi + cp claude_*.png "$work_dir/" || exit 1 + echo "Application icons extracted and copied to $work_dir" + + cd "$project_root" || exit 1 + + # Process tray icons + local claude_locale_src="$claude_extract_dir/lib/net45/resources" + echo 'Copying and processing tray icon files for Linux...' + if [[ ! -d $claude_locale_src ]]; then + echo "Warning: Claude resources directory not found at $claude_locale_src" + section_footer 'Icon Processing' + return + fi + + cp "$claude_locale_src/Tray"* "$electron_resources_dest/" 2>/dev/null || \ + echo 'Warning: No tray icon files found' + + # Find ImageMagick command + local magick_cmd='' + command -v magick &> /dev/null && magick_cmd='magick' + [[ -z $magick_cmd ]] && command -v convert &> /dev/null && magick_cmd='convert' + + if [[ -z $magick_cmd ]]; then + echo 'Warning: ImageMagick not found - tray icons may appear invisible' + echo 'Tray icon files copied (unprocessed)' + section_footer 'Icon Processing' + return + fi + + echo "Processing tray icons for Linux visibility (using $magick_cmd)..." + local icon_file icon_name + for icon_file in "$electron_resources_dest"/TrayIconTemplate*.png; do + [[ ! -f $icon_file ]] && continue + icon_name=$(basename "$icon_file") + if "$magick_cmd" "$icon_file" -channel A -fx 'a>0?1:0' +channel \ + "PNG32:$icon_file" 2>/dev/null; then + echo " Processed $icon_name (100% opaque)" + else + echo " Failed to process $icon_name" + fi + done + echo 'Tray icon files copied and processed' + + section_footer 'Icon Processing' +} + +copy_locale_files() { + local claude_locale_src="$claude_extract_dir/lib/net45/resources" + echo 'Copying Claude locale JSON files to Electron resources directory...' + if [[ -d $claude_locale_src ]]; then + cp "$claude_locale_src/"*-*.json "$electron_resources_dest/" || exit 1 + echo 'Claude locale JSON files copied to Electron resources directory' + else + echo "Warning: Claude locale source directory not found at $claude_locale_src" + fi + + echo "app.asar processed and staged in $app_staging_dir" +} + +copy_ssh_helpers() { + section_header 'SSH Helpers' + + local ssh_src="$claude_extract_dir/lib/net45/resources/claude-ssh" + local ssh_dest="$electron_resources_dest/claude-ssh" + local binary_name="claude-ssh-linux-$architecture" + + if [[ ! -d "$ssh_src" ]]; then + echo "Warning: SSH helpers not found at $ssh_src" + section_footer 'SSH Helpers' + return + fi + + mkdir -p "$ssh_dest" || exit 1 + cp "$ssh_src/version.txt" "$ssh_dest/" || exit 1 + cp "$ssh_src/$binary_name" "$ssh_dest/" || exit 1 + chmod +x "$ssh_dest/$binary_name" + + echo "Copied SSH helper files:" + echo " version.txt" + echo " $binary_name" + + section_footer 'SSH Helpers' +} + +copy_cowork_resources() { + section_header 'Cowork Resources' + + local resources_src="$claude_extract_dir/lib/net45/resources" + + # Copy cowork-plugin-shim.sh (used by app for MCP plugin sandboxing) + local shim_src="$resources_src/cowork-plugin-shim.sh" + if [[ -f $shim_src ]]; then + cp "$shim_src" "$electron_resources_dest/cowork-plugin-shim.sh" + chmod +x "$electron_resources_dest/cowork-plugin-shim.sh" + echo "Copied cowork-plugin-shim.sh" + else + echo "Warning: cowork-plugin-shim.sh not found at $shim_src" + fi + + # Copy smol-bin VHDX (contains SDK binaries for KVM guest VM). + # The app copies this from resources to the bundle dir at startup + # (win32-gated; our index.js patch extends this to Linux). + # App looks for smol-bin.{arch}.vhdx where arch is x64 or arm64. + local smol_arch='x64' + if [[ $architecture == 'arm64' ]]; then + smol_arch='arm64' + fi + local smol_vhdx="$resources_src/smol-bin.${smol_arch}.vhdx" + if [[ -f $smol_vhdx ]]; then + cp "$smol_vhdx" \ + "$electron_resources_dest/smol-bin.${smol_arch}.vhdx" + echo "Copied smol-bin.${smol_arch}.vhdx" + else + echo "Warning: smol-bin VHDX not found at $smol_vhdx" + echo "KVM Cowork will rely on virtiofs for SDK access" + fi + + section_footer 'Cowork Resources' +} + +#=============================================================================== +# Packaging Functions +#=============================================================================== + +run_packaging() { + section_header 'Call Packaging Script' + + if [[ $build_format == 'nix' ]]; then + echo 'Nix build mode - skipping packaging (Nix derivation handles installation)' + section_footer 'Call Packaging Script' + return 0 + fi + + local output_path='' + local script_name file_pattern pkg_file + + case "$build_format" in + deb) + script_name='build-deb-package.sh' + file_pattern="${PACKAGE_NAME}_${version}_${architecture}.deb" + ;; + rpm) + script_name='build-rpm-package.sh' + file_pattern="${PACKAGE_NAME}-${version}*.rpm" + ;; + appimage) + script_name='build-appimage.sh' + file_pattern="${PACKAGE_NAME}-${version}-${architecture}.AppImage" + ;; + esac + + if [[ $build_format == 'deb' || $build_format == 'rpm' ]]; then + echo "Calling ${build_format^^} packaging script for $architecture..." + chmod +x "scripts/$script_name" || exit 1 + if ! "scripts/$script_name" \ + "$version" "$architecture" "$work_dir" "$app_staging_dir" \ + "$PACKAGE_NAME" "$MAINTAINER" "$DESCRIPTION"; then + echo "${build_format^^} packaging script failed." >&2 + exit 1 + fi + + pkg_file=$(find "$work_dir" -maxdepth 1 -name "$file_pattern" | head -n 1) + echo "${build_format^^} Build complete!" + if [[ -n $pkg_file && -f $pkg_file ]]; then + output_path="./$(basename "$pkg_file")" + mv "$pkg_file" "$output_path" || exit 1 + echo "Package created at: $output_path" + else + echo "Warning: Could not determine final .${build_format} file path." + output_path='Not Found' + fi + + elif [[ $build_format == 'appimage' ]]; then + echo "Calling AppImage packaging script for $architecture..." + chmod +x scripts/build-appimage.sh || exit 1 + if ! scripts/build-appimage.sh \ + "$version" "$architecture" "$work_dir" "$app_staging_dir" "$PACKAGE_NAME"; then + echo 'AppImage packaging script failed.' >&2 + exit 1 + fi + + local appimage_file + appimage_file=$(find "$work_dir" -maxdepth 1 -name "${PACKAGE_NAME}-${version}-${architecture}.AppImage" | head -n 1) + echo 'AppImage Build complete!' + if [[ -n $appimage_file && -f $appimage_file ]]; then + output_path="./$(basename "$appimage_file")" + mv "$appimage_file" "$output_path" || exit 1 + echo "Package created at: $output_path" + + section_header 'Generate .desktop file for AppImage' + local desktop_file="./${PACKAGE_NAME}-appimage.desktop" + echo "Generating .desktop file for AppImage at $desktop_file..." + cat > "$desktop_file" << EOF +[Desktop Entry] +Name=Claude (AppImage) +Comment=Claude Desktop (AppImage Version $version) +Exec=$(basename "$output_path") %u +Icon=claude-desktop +Type=Application +Terminal=false +Categories=Office;Utility;Network; +MimeType=x-scheme-handler/claude; +StartupWMClass=Claude +X-AppImage-Version=$version +X-AppImage-Name=Claude Desktop (AppImage) +EOF + echo '.desktop file generated.' + else + echo 'Warning: Could not determine final .AppImage file path.' + output_path='Not Found' + fi + fi + + # Store for print_next_steps + final_output_path="$output_path" +} + +cleanup_build() { + section_header 'Cleanup' + if [[ $perform_cleanup != true ]]; then + echo "Skipping cleanup of intermediate build files in $work_dir." + return + fi + + echo "Cleaning up intermediate build files in $work_dir..." + if rm -rf "$work_dir"; then + echo "Cleanup complete ($work_dir removed)." + else + echo 'Cleanup command failed.' + fi +} + +print_next_steps() { + echo -e '\n\033[1;34m====== Next Steps ======\033[0m' + + case "$build_format" in + deb|rpm) + if [[ $final_output_path != 'Not Found' && -e $final_output_path ]]; then + local pkg_type install_cmd alt_cmd + if [[ $build_format == 'deb' ]]; then + pkg_type='Debian' + install_cmd="sudo apt install $final_output_path" + alt_cmd="sudo dpkg -i $final_output_path" + else + pkg_type='RPM' + install_cmd="sudo dnf install $final_output_path" + alt_cmd="sudo rpm -i $final_output_path" + fi + echo -e "To install the $pkg_type package, run:" + echo -e " \033[1;32m$install_cmd\033[0m" + echo -e " (or \`$alt_cmd\`)" + else + echo -e "${build_format^^} package file not found. Cannot provide installation instructions." + fi + ;; + appimage) + if [[ $final_output_path != 'Not Found' && -e $final_output_path ]]; then + echo -e "AppImage created at: \033[1;36m$final_output_path\033[0m" + echo -e '\n\033[1;33mIMPORTANT:\033[0m This AppImage requires \033[1;36mGear Lever\033[0m for proper desktop integration' + # shellcheck disable=SC2016 # backticks intentional for display + echo -e 'and to handle the `claude://` login process correctly.' + echo -e '\nTo install Gear Lever:' + echo -e ' 1. Install via Flatpak:' + echo -e ' \033[1;32mflatpak install flathub it.mijorus.gearlever\033[0m' + echo -e ' 2. Integrate your AppImage with just one click:' + echo -e ' - Open Gear Lever' + echo -e " - Drag and drop \033[1;36m$final_output_path\033[0m into Gear Lever" + echo -e " - Click 'Integrate' to add it to your app menu" + if [[ ${GITHUB_ACTIONS:-} == 'true' ]]; then + echo -e '\n This AppImage includes embedded update information!' + else + echo -e '\n This locally-built AppImage does not include update information.' + echo -e ' For automatic updates, download release versions: https://github.com/aaddrick/claude-desktop-debian/releases' + fi + else + echo -e 'AppImage file not found. Cannot provide usage instructions.' + fi + ;; + esac + + echo -e '\033[1;34m======================\033[0m' +} + +#=============================================================================== +# Main Execution +#=============================================================================== + +main() { + # Phase 1: Setup + detect_architecture + detect_distro + check_system_requirements + parse_arguments "$@" + + # Early exit for test mode + if [[ $test_flags_mode == true ]]; then + echo '--- Test Flags Mode Enabled ---' + echo "Build Format: $build_format" + echo "Clean Action: $cleanup_action" + echo 'Exiting without build.' + exit 0 + fi + + if [[ $build_format != 'nix' ]]; then + check_dependencies + fi + setup_work_directory + + if [[ $build_format != 'nix' ]]; then + setup_nodejs + setup_electron_asar + else + # Nix provides node and asar in PATH + asar_exec=$(command -v asar) + if [[ -z $asar_exec ]]; then + echo 'Error: asar not found in PATH (expected Nix to provide it)' >&2 + exit 1 + fi + fi + + # Phase 2: Download and extract + if [[ $build_format == 'nix' && -z $local_exe_path ]]; then + echo 'Error: --exe is required when --build nix is specified' >&2 + exit 1 + fi + download_claude_installer + + # Phase 3: Patch and prepare + patch_app_asar + install_node_pty + finalize_app_asar + if [[ $build_format != 'nix' ]]; then + stage_electron + copy_locale_files + else + # Nix installPhase handles Electron staging and locale files. + # Set a resources destination so process_icons and copy_ssh_helpers + # have somewhere to write; the Nix installPhase picks them up. + electron_resources_dest="$app_staging_dir/nix-resources" + mkdir -p "$electron_resources_dest" || exit 1 + fi + process_icons + copy_ssh_helpers + copy_cowork_resources + + cd "$project_root" || exit 1 + + # Phase 4: Package + run_packaging + + # Phase 5: Cleanup and finish + cleanup_build + + echo 'Build process finished.' + if [[ $build_format != 'nix' ]]; then + print_next_steps + fi +} + +# Run main with all script arguments +main "$@" + +exit 0 diff --git a/scripts/claude-native-stub.js b/scripts/claude-native-stub.js new file mode 100644 index 0000000..2da1af0 --- /dev/null +++ b/scripts/claude-native-stub.js @@ -0,0 +1,84 @@ +// Stub implementation of claude-native for Linux +// Uses Electron's native Linux support where possible instead of no-ops +const KeyboardKey = { Backspace: 43, Tab: 280, Enter: 261, Shift: 272, Control: 61, Alt: 40, CapsLock: 56, Escape: 85, Space: 276, PageUp: 251, PageDown: 250, End: 83, Home: 154, LeftArrow: 175, UpArrow: 282, RightArrow: 262, DownArrow: 81, Delete: 79, Meta: 187 }; +Object.freeze(KeyboardKey); + +// Helper: get the focused BrowserWindow (lazy-loaded to avoid circular deps) +// Filters destroyed windows from fallback to avoid errors like +// flashFrame() on a destroyed window or getIsMaximized() on a popup. +// Note: isVisible() is intentionally NOT checked — flashFrame() must work +// on minimized (non-visible) windows, which is its primary use case. +function getWindow() { + try { + const { BrowserWindow } = require('electron'); + const focused = BrowserWindow.getFocusedWindow(); + if (focused) return focused; + // TODO: Fallback may return a popup window; callers like + // getIsMaximized() may behave unexpectedly on popups. + const win = BrowserWindow.getAllWindows().find( + (w) => !w.isDestroyed() + ); + return win || null; + } catch (e) { + console.warn('[Claude Native Stub] getWindow() failed:', e); + return null; + } +} + +// AuthRequest stub - not available on Linux, will cause fallback to system browser +class AuthRequest { + static isAvailable() { + return false; + } + + async start(url, scheme, windowHandle) { + throw new Error('AuthRequest not available on Linux'); + } + + cancel() { + // no-op + } +} + +module.exports = { + getWindowsVersion: () => "10.0.0", + setWindowEffect: () => {}, + removeWindowEffect: () => {}, + + // Functional on Linux via Electron's native support + getIsMaximized: () => { + const win = getWindow(); + return win ? win.isMaximized() : false; + }, + + // Fixes: #149 - KDE Plasma: Window demands attention + // flashFrame is natively supported on Linux Electron. + // frame-fix-wrapper.js auto-clears on window focus. + flashFrame: (flash) => { + const win = getWindow(); + if (win) win.flashFrame(typeof flash === 'boolean' ? flash : true); + }, + clearFlashFrame: () => { + const win = getWindow(); + if (win) win.flashFrame(false); + }, + + showNotification: () => {}, + + // Progress bar is natively supported on Linux (Unity/KDE/GNOME) + setProgressBar: (progress) => { + const win = getWindow(); + if (win && typeof progress === 'number') { + win.setProgressBar(Math.max(0, Math.min(1, progress))); + } + }, + clearProgressBar: () => { + const win = getWindow(); + if (win) win.setProgressBar(-1); + }, + + setOverlayIcon: () => {}, + clearOverlayIcon: () => {}, + KeyboardKey, + AuthRequest +}; diff --git a/scripts/cowork-vm-service.js b/scripts/cowork-vm-service.js new file mode 100644 index 0000000..256f648 --- /dev/null +++ b/scripts/cowork-vm-service.js @@ -0,0 +1,2142 @@ +#!/usr/bin/env node + +/** + * Linux Cowork VM Service Daemon + * + * Replaces the Windows cowork-vm-service for Linux. Listens on a Unix domain + * socket using the same length-prefixed JSON protocol as the Windows named pipe. + * + * Architecture: VMManager (dispatcher) + pluggable backends + * - HostBackend: Run processes directly on host (no isolation) + * - BwrapBackend: Bubblewrap namespace sandbox + * - KvmBackend: QEMU/KVM virtual machine with vsock communication + * + * Backend selection (auto-detected or overridden via COWORK_VM_BACKEND env): + * 1. bwrap — if bwrap is installed and functional (default) + * 2. kvm — if /dev/kvm, qemu-system-x86_64, and /dev/vhost-vsock + * are available (rootfs checked at startVM time) + * 3. host — fallback, no isolation + * + * Protocol: + * Transport: Unix domain socket at $XDG_RUNTIME_DIR/cowork-vm-service.sock + * Framing: 4-byte big-endian length prefix + JSON payload + * Request: { method: "methodName", params: {...} } + * Response: { success: true, result: {...} } or { success: false, error: "..." } + * Events: { type: "stdout"|"stderr"|"exit"|"error"|"networkStatus"|"apiReachability", ... } + */ + +const net = require('net'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const crypto = require('crypto'); +const { spawn: spawnProcess, execSync, execFileSync } = require('child_process'); + +// ============================================================ +// Configuration +// ============================================================ + +const SOCKET_PATH = (process.env.XDG_RUNTIME_DIR || '/tmp') + + '/cowork-vm-service.sock'; +const DEBUG = process.env.COWORK_VM_DEBUG === '1' || + process.env.CLAUDE_LINUX_DEBUG === '1'; +const LOG_PREFIX = '[cowork-vm-service]'; + +// Backend override: set COWORK_VM_BACKEND to "host", "bwrap", or "kvm" +// to force a specific backend instead of auto-detection. +const BACKEND_OVERRIDE = process.env.COWORK_VM_BACKEND || null; + +// The daemon is forked with stdio:'ignore', so console output goes nowhere. +// Write logs to a file so they're accessible for debugging. +const LOG_FILE = path.join( + process.env.HOME || '/tmp', + '.config', 'Claude', 'logs', 'cowork_vm_daemon.log' +); +function formatArgs(args) { + return args.map(a => typeof a === 'string' ? a : JSON.stringify(a)) + .join(' '); +} + +function writeLog(level, args) { + const ts = new Date().toISOString(); + const msg = `${ts} [${level}] ${LOG_PREFIX} ${formatArgs(args)}\n`; + try { + fs.appendFileSync(LOG_FILE, msg); + } catch (_) { + // Ignore write errors (dir may not exist yet) + } +} + +function log(...args) { + if (!DEBUG) return; + writeLog('debug', args); + console.log(LOG_PREFIX, ...args); +} + +function logError(...args) { + writeLog('error', args); + console.error(LOG_PREFIX, ...args); +} + +// ============================================================ +// Length-Prefixed JSON Protocol (matches Windows pipe protocol) +// ============================================================ + +/** + * Write a length-prefixed JSON message to a socket. + * Format: 4 bytes big-endian length + JSON bytes + */ +function writeMessage(socket, message) { + const json = JSON.stringify(message); + const jsonBuf = Buffer.from(json, 'utf8'); + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32BE(jsonBuf.length, 0); + socket.write(Buffer.concat([lenBuf, jsonBuf])); +} + +/** + * Parse a length-prefixed JSON message from a buffer. + * Returns { message, remaining } or null if incomplete. + */ +function parseMessage(buffer) { + if (buffer.length < 4) return null; + const len = buffer.readUInt32BE(0); + if (buffer.length < 4 + len) return null; + const json = buffer.subarray(4, 4 + len).toString('utf8'); + const remaining = Buffer.from(buffer.subarray(4 + len)); + return { message: JSON.parse(json), remaining }; +} + +// ============================================================ +// Shared Helpers (used by multiple backends) +// ============================================================ + +/** + * Keys to strip from spawned process environments. + * CLAUDECODE triggers "cannot be launched inside another session". + * ELECTRON_* are Electron internals that break child processes. + */ +const BLOCKED_ENV_KEYS = new Set([ + 'CLAUDECODE', 'ELECTRON_RUN_AS_NODE', 'ELECTRON_NO_ASAR', +]); + +/** + * Filter environment variables, removing blocked keys and optional prefixes. + */ +function filterEnv(source, stripPrefixes = []) { + const result = {}; + for (const [k, v] of Object.entries(source)) { + if (BLOCKED_ENV_KEYS.has(k)) continue; + if (stripPrefixes.some(p => k.startsWith(p))) continue; + result[k] = v; + } + return result; +} + +// ============================================================ +// Guest-Path Translation +// ============================================================ + +/** + * Translate a VM guest path (/sessions/{id}/mnt/{name}[/rest]) to a host + * path using mountMap. Returns the translated path, or null on failure. + */ +function translateGuestPath(guestPath, mountMap) { + if (!guestPath || !guestPath.startsWith('/sessions/')) return null; + if (!mountMap || Object.keys(mountMap).length === 0) return null; + + const match = guestPath.match( + /^\/sessions\/[^/]+\/mnt\/([^/]+)(\/.*)?$/ + ); + if (!match) return null; + + const mountName = match[1]; + const rest = match[2] || ''; + + // Electron's ta() normalizer strips leading dots, so try both + // "skills" and ".skills" style lookups. + const hostBase = mountMap[mountName] + || mountMap['.' + mountName] + || mountMap[mountName.replace(/^\./, '')]; + if (!hostBase) { + log(`translateGuestPath: no mapping for "${mountName}"`); + return null; + } + + const translated = rest ? path.join(hostBase, rest) : hostBase; + const normalized = path.resolve(translated); + + // Prevent path traversal outside the mount base + if (normalized !== hostBase && + !normalized.startsWith(hostBase + path.sep)) { + log(`translateGuestPath: traversal blocked: ${guestPath} -> ${normalized}`); + return null; + } + + log(`translateGuestPath: ${guestPath} -> ${normalized}`); + return normalized; +} + +/** + * Build a mount-name -> host-path mapping from mountBinds (prior + * mountPath() calls) and additionalMounts (spawn params). + * additionalMounts entries take precedence over mountBinds. + */ +function buildMountMap(additionalMounts, mountBinds) { + const map = {}; + + if (mountBinds) { + for (const [name, hostPath] of mountBinds) { + map[name] = hostPath; + } + } + + if (additionalMounts) { + const homeDir = os.homedir(); + for (const [name, info] of Object.entries(additionalMounts)) { + if (!info || !info.path) continue; + const resolved = path.resolve( + path.join(homeDir, info.path) + ); + if (resolved !== homeDir && + !resolved.startsWith(homeDir + path.sep)) { + log(`buildMountMap: rejecting "${name}" — resolves outside home: ${resolved}`); + continue; + } + map[name] = resolved; + } + } + + return map; +} + +/** + * Build a merged environment for a spawned process. Combines filtered + * daemon env with app-provided env, and translates CLAUDE_CONFIG_DIR + * guest paths using mountMap. + */ +function buildSpawnEnv(appEnv, mountMap) { + const mergedEnv = { + ...filterEnv(process.env, ['CLAUDE_CODE_']), + ...filterEnv(appEnv || {}), + TERM: 'xterm-256color', + }; + + // Translate CLAUDE_CONFIG_DIR from guest path to host path, or + // remove it so Claude Code falls back to ~/.claude/. + if (mergedEnv.CLAUDE_CONFIG_DIR && + mergedEnv.CLAUDE_CONFIG_DIR.startsWith('/sessions/')) { + const translated = translateGuestPath( + mergedEnv.CLAUDE_CONFIG_DIR, mountMap + ); + if (translated) { + log(`buildSpawnEnv: translated CLAUDE_CONFIG_DIR: ${mergedEnv.CLAUDE_CONFIG_DIR} -> ${translated}`); + mergedEnv.CLAUDE_CONFIG_DIR = translated; + } else { + log(`buildSpawnEnv: removing VM guest CLAUDE_CONFIG_DIR: ${mergedEnv.CLAUDE_CONFIG_DIR}`); + delete mergedEnv.CLAUDE_CONFIG_DIR; + } + } + + return mergedEnv; +} + +/** + * Translate args that reference VM guest paths (/sessions/...) to host + * paths using mountMap. If translation fails, the flag pair is removed. + */ +function cleanSpawnArgs(rawArgs, mountMap) { + const cleanArgs = []; + const guestPathFlags = new Set(['--add-dir', '--plugin-dir']); + for (let i = 0; i < rawArgs.length; i++) { + if (guestPathFlags.has(rawArgs[i]) && + i + 1 < rawArgs.length && + rawArgs[i + 1].startsWith('/sessions/')) { + const flag = rawArgs[i]; + let hostPath = translateGuestPath( + rawArgs[i + 1], mountMap + ); + if (hostPath) { + // --plugin-dir needs the plugin root, not a skills/ + // subdirectory — walk up to find it. + if (flag === '--plugin-dir') { + hostPath = resolvePluginRoot( + hostPath, os.homedir() + ); + } + log(`cleanSpawnArgs: translated ${flag} ${rawArgs[i + 1]} -> ${hostPath}`); + cleanArgs.push(flag, hostPath); + } else { + log(`cleanSpawnArgs: removing ${flag} ${rawArgs[i + 1]} (no host mapping)`); + } + i++; + continue; + } + cleanArgs.push(rawArgs[i]); + } + return cleanArgs; +} + +/** + * Walk up from pluginPath (at most 3 levels) looking for the plugin + * root (contains .claude-plugin/plugin.json or manifest.json). + * Will not walk above mountBase. Returns pluginPath if no root found. + */ +function resolvePluginRoot(pluginPath, mountBase) { + let candidate = pluginPath; + for (let i = 0; i < 3; i++) { + try { + const hasPluginJson = fs.existsSync( + path.join(candidate, '.claude-plugin', 'plugin.json') + ); + const hasManifest = fs.existsSync( + path.join(candidate, 'manifest.json') + ); + if (hasPluginJson || hasManifest) { + if (candidate !== pluginPath) { + log(`resolvePluginRoot: adjusted ${pluginPath} -> ${candidate}`); + } + return candidate; + } + } catch (_) { + break; + } + const parent = path.dirname(candidate); + if (parent === candidate) break; + if (mountBase && + parent !== mountBase && + !parent.startsWith(mountBase + path.sep)) break; + candidate = parent; + } + return pluginPath; +} + +/** + * Resolve the working directory from spawn params. Translates guest + * paths using mountMap, falls back to homedir if translation fails + * or the directory does not exist. + */ +function resolveWorkDir(cwd, sharedCwdPath, mountMap) { + let workDir = cwd || os.homedir(); + if (sharedCwdPath) { + workDir = path.join(os.homedir(), sharedCwdPath); + } else if (cwd && cwd.startsWith('/sessions/')) { + const translated = translateGuestPath(cwd, mountMap || {}); + if (translated) { + log(`resolveWorkDir: translated "${cwd}" -> "${translated}"`); + workDir = translated; + } else { + log(`resolveWorkDir: cwd is VM guest path "${cwd}", using home dir`); + workDir = os.homedir(); + } + } + + if (!fs.existsSync(workDir)) { + log(`resolveWorkDir: cwd "${workDir}" does not exist, using home dir`); + workDir = os.homedir(); + } + + return workDir; +} + +/** + * Resolve the SDK binary path from subpath and version. + * Returns the path if found and executable, null otherwise. + */ +function resolveSdkBinary(sdkSubpath, version, label) { + if (!sdkSubpath || !version) return null; + const candidatePath = path.join( + os.homedir(), sdkSubpath, version, 'claude' + ); + try { + fs.accessSync(candidatePath, fs.constants.X_OK); + log(`${label}: SDK binary found: ${candidatePath}`); + return candidatePath; + } catch (e) { + log(`${label}: SDK binary not found: ${candidatePath}`); + return null; + } +} + +/** + * Resolve the actual command binary to execute. + * Priority: 1) SDK binary from installSdk, 2) command path, 3) which + * Returns { command, error } — error is set if command not found. + */ +function resolveCommand(command, sdkBinaryPath) { + if (sdkBinaryPath && fs.existsSync(sdkBinaryPath)) { + log(`resolveCommand: using SDK binary: ${sdkBinaryPath}`); + return { command: sdkBinaryPath, error: null }; + } + + if (fs.existsSync(command)) { + return { command, error: null }; + } + + const basename = path.basename(command); + try { + const resolved = execFileSync('which', [basename], + { encoding: 'utf-8' }).trim(); + log(`resolveCommand: resolved via which: ${resolved}`); + return { command: resolved, error: null }; + } catch (e) { + return { command: null, error: `${command} not found` }; + } +} + +// ============================================================ +// Backend Base Class +// ============================================================ + +/** + * Base class documenting the interface all backends must implement. + * Each backend receives an emitEvent callback for broadcasting events + * (stdout, stderr, exit, error, networkStatus, etc.) to subscribers. + */ +class BackendBase { + constructor(emitEvent) { + /** @type {function} Callback to broadcast events to subscribers */ + this.emitEvent = emitEvent; + } + + /** One-time initialization with VM config */ + async init(config) { + throw new Error('Not implemented: init'); + } + + /** Start the VM/sandbox/nothing */ + async startVM(params) { + throw new Error('Not implemented: startVM'); + } + + /** Stop everything */ + async stopVM() { + throw new Error('Not implemented: stopVM'); + } + + /** Return { running: bool } */ + isRunning() { + throw new Error('Not implemented: isRunning'); + } + + /** Return { connected: bool } */ + isGuestConnected() { + throw new Error('Not implemented: isGuestConnected'); + } + + /** Spawn a process */ + async spawn(params) { + throw new Error('Not implemented: spawn'); + } + + /** Kill a process */ + async kill(params) { + throw new Error('Not implemented: kill'); + } + + /** Write to process stdin */ + async writeStdin(params) { + throw new Error('Not implemented: writeStdin'); + } + + /** Check if process is running, return { running: bool } */ + isProcessRunning(params) { + throw new Error('Not implemented: isProcessRunning'); + } + + /** Handle mount requests */ + async mountPath(params) { + throw new Error('Not implemented: mountPath'); + } + + /** Read a file */ + async readFile(params) { + throw new Error('Not implemented: readFile'); + } + + /** Handle SDK installation */ + async installSdk(params) { + throw new Error('Not implemented: installSdk'); + } + + /** Handle OAuth */ + async addApprovedOauthToken(params) { + throw new Error('Not implemented: addApprovedOauthToken'); + } +} + +// ============================================================ +// LocalBackend — Shared logic for host-local backends +// ============================================================ + +/** + * Common base for backends that run processes locally (Host and Bwrap). + * Provides shared implementations of process management, file I/O, + * SDK installation, and lifecycle methods. Subclasses override + * startVM(), stopVM(), spawn(), and mountPath() as needed. + */ +class LocalBackend extends BackendBase { + constructor(emitEvent, backendName) { + super(emitEvent); + this.backendName = backendName; + this.config = { memoryMB: 8192, cpuCount: 4 }; + this.running = false; + this.guestConnected = false; + this.sdkBinaryPath = null; + this.processes = new Map(); + } + + async init(config) { + if (config.memoryMB !== undefined) { + this.config.memoryMB = config.memoryMB; + } + if (config.cpuCount !== undefined) { + this.config.cpuCount = config.cpuCount; + } + log(`${this.backendName} configured:`, this.config); + } + + isRunning() { + return { running: this.running }; + } + + isGuestConnected() { + return { connected: this.guestConnected }; + } + + /** + * Spawn a local process. Subclasses call this with the resolved + * command and args to get consistent event wiring. + * @param {string} id - Process identifier + * @param {string} spawnCmd - Command to execute + * @param {string[]} spawnArgs - Arguments array + * @param {string} workDir - Working directory + * @param {object} env - Environment variables + */ + _spawnLocal(id, spawnCmd, spawnArgs, workDir, env) { + const proc = spawnProcess(spawnCmd, spawnArgs, { + cwd: workDir, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + log(`${this.backendName} spawn: pid=${proc.pid}`); + this.processes.set(id, proc); + + proc.stdout.on('data', (data) => { + this.emitEvent({ type: 'stdout', id, data: data.toString() }); + }); + + proc.stderr.on('data', (data) => { + this.emitEvent({ type: 'stderr', id, data: data.toString() }); + }); + + proc.on('exit', (exitCode, signal) => { + log(`${this.backendName}: process ${id} exited: code=${exitCode}, signal=${signal}`); + this.processes.delete(id); + this.emitEvent({ type: 'exit', id, exitCode, signal }); + }); + + proc.on('error', (err) => { + this.emitEvent({ type: 'error', id, message: err.message }); + }); + + return proc; + } + + /** + * Resolve command and prepare environment/args for spawning. + * Returns null and emits error events if command not found. + * Builds a mount map to translate VM guest paths in args, env, and cwd. + */ + _prepareSpawn(params) { + const { id, name, command, args, cwd, env, + sharedCwdPath, additionalMounts } = params; + + log(`${this.backendName} spawn: id=${id}, name=${name}, command=${command}`); + + const mountMap = buildMountMap( + additionalMounts, this.mountBinds + ); + // Store for readFile() — last spawn wins (single-session in practice) + this.lastMountMap = mountMap; + + if (Object.keys(mountMap).length > 0) { + log(`${this.backendName} spawn: mountMap=${JSON.stringify(mountMap)}`); + } + + const workDir = resolveWorkDir(cwd, sharedCwdPath, mountMap); + const resolved = resolveCommand(command, this.sdkBinaryPath); + + if (resolved.error) { + this.emitEvent({ + type: 'stderr', id, + data: `Error: ${resolved.error}\n`, + }); + this.emitEvent({ + type: 'exit', id, exitCode: 127, signal: null, + }); + return null; + } + + return { + id, + name, + actualCommand: resolved.command, + cleanArgs: cleanSpawnArgs(args || [], mountMap), + mergedEnv: buildSpawnEnv(env, mountMap), + workDir, + mountMap, + }; + } + + _killAllProcesses(killSignal) { + for (const [id, proc] of this.processes) { + try { + if (proc.kill) proc.kill(killSignal); + } catch (e) { + log(`${this.backendName}: error killing process ${id}:`, e.message); + } + } + this.processes.clear(); + } + + _setDisconnected() { + this.running = false; + this.guestConnected = false; + this.emitEvent({ type: 'networkStatus', status: 'disconnected' }); + } + + async kill(params) { + const { id, signal } = params; + const proc = this.processes.get(id); + if (proc) { + try { + proc.kill(signal || 'SIGTERM'); + } catch (e) { + log(`${this.backendName}: kill failed for ${id}:`, e.message); + } + } + return {}; + } + + async writeStdin(params) { + const { id, data } = params; + const proc = this.processes.get(id); + if (proc && proc.stdin && !proc.stdin.destroyed) { + proc.stdin.write(data); + } + return {}; + } + + isProcessRunning(params) { + return { running: !!this.processes.get(params.id) }; + } + + async readFile(params) { + const { filePath } = params; + log(`${this.backendName} readFile: ${filePath}`); + + let resolved; + if (filePath && filePath.startsWith('/sessions/')) { + resolved = translateGuestPath( + filePath, this.lastMountMap || {} + ); + if (!resolved) { + return { error: `Cannot translate guest path: ${filePath}` }; + } + log(`${this.backendName} readFile: translated ${filePath} -> ${resolved}`); + } else { + resolved = path.resolve(filePath); + } + + const home = os.homedir(); + if (!resolved.startsWith(home + path.sep) && resolved !== home) { + return { error: 'Access denied: path outside home directory' }; + } + try { + const content = fs.readFileSync(resolved, 'utf8'); + return { content }; + } catch (e) { + return { error: e.message }; + } + } + + async installSdk(params) { + const { sdkSubpath, version } = params; + log(`${this.backendName} installSdk: ${sdkSubpath}@${version}`); + const resolved = resolveSdkBinary( + sdkSubpath, version, this.backendName + ); + if (resolved) this.sdkBinaryPath = resolved; + return {}; + } + + async addApprovedOauthToken(params) { + log(`${this.backendName}: addApprovedOauthToken`); + return {}; + } +} + +// ============================================================ +// HostBackend — Run processes directly on host (no isolation) +// ============================================================ + +class HostBackend extends LocalBackend { + constructor(emitEvent) { + super(emitEvent, 'HostBackend'); + } + + async startVM(params) { + if (this.running) { + log('HostBackend: already running'); + return {}; + } + + this.running = true; + + // Simulate async guest connection + setTimeout(() => { + this.guestConnected = true; + this.emitEvent({ + type: 'networkStatus', + status: 'connected', + }); + log('HostBackend: guest connected'); + }, 500); + + return {}; + } + + async stopVM() { + log('HostBackend: stopVM'); + this._killAllProcesses('SIGTERM'); + this._setDisconnected(); + return {}; + } + + async spawn(params) { + const prepared = this._prepareSpawn(params); + if (!prepared) return {}; + + const { id, actualCommand, cleanArgs, mergedEnv, workDir } = prepared; + + log(`HostBackend spawn: command=${actualCommand}, args=${JSON.stringify(cleanArgs)}`); + log(`HostBackend spawn: cwd=${workDir}`); + + this._spawnLocal(id, actualCommand, cleanArgs, workDir, mergedEnv); + return {}; + } + + async mountPath(params) { + const { subpath } = params; + log(`HostBackend mountPath: ${subpath}`); + const guestPath = path.join('/', subpath || ''); + return { guestPath }; + } +} + +// ============================================================ +// BwrapBackend — Bubblewrap namespace sandbox +// ============================================================ + +class BwrapBackend extends LocalBackend { + constructor(emitEvent) { + super(emitEvent, 'BwrapBackend'); + this.mountBinds = new Map(); // mountName -> hostPath + } + + async startVM(params) { + if (this.running) { + log('BwrapBackend: already running'); + return {}; + } + + // bwrap is process-level sandboxing; no VM to start + this.running = true; + this.guestConnected = true; + this.emitEvent({ + type: 'networkStatus', + status: 'connected', + }); + log('BwrapBackend: started (sandbox ready)'); + return {}; + } + + async stopVM() { + log('BwrapBackend: stopVM'); + this._killAllProcesses('SIGKILL'); + this.mountBinds.clear(); + this._setDisconnected(); + return {}; + } + + async spawn(params) { + const prepared = this._prepareSpawn(params); + if (!prepared) return {}; + + const { id, name, actualCommand } = prepared; + const { additionalMounts } = params; + const mountMap = this.lastMountMap || {}; + + // Guest paths (/sessions/...) exist inside our bwrap sandbox, + // so pass args and env through as-is (no guest->host translation). + const rawArgs = params.args || []; + const mergedEnv = { + ...filterEnv(process.env, ['CLAUDE_CODE_']), + ...filterEnv(params.env || {}), + TERM: 'xterm-256color', + }; + + // Build a minimal sandbox: empty tmpfs root with only the + // necessary system paths bound in read-only. This avoids + // exposing the real home directory and allows creating the + // /sessions/ guest path structure that claude-code-vm expects. + const bwrapArgs = [ + '--tmpfs', '/', + '--ro-bind', '/usr', '/usr', + '--ro-bind', '/etc', '/etc', + '--dev', '/dev', + '--proc', '/proc', + '--tmpfs', '/tmp', + '--tmpfs', '/run', + ]; + + // Handle /bin, /lib, /lib64, /sbin: on merged-usr distros + // (Fedora, recent Debian/Ubuntu) these are symlinks into /usr. + // On others they are real directories needing separate mounts. + for (const dir of ['/bin', '/lib', '/lib64', '/sbin']) { + try { + const target = fs.readlinkSync(dir); + bwrapArgs.push('--symlink', target, dir); + } catch (_) { + if (fs.existsSync(dir)) { + bwrapArgs.push('--ro-bind', dir, dir); + } + } + } + + // Preserve DNS resolution: /etc/resolv.conf is often a symlink + // to /run/systemd/resolve/stub-resolv.conf which --tmpfs /run + // wipes out. Bind-mount the resolved target back in. + try { + const resolvedConf = fs.realpathSync('/etc/resolv.conf'); + if (resolvedConf.startsWith('/run/')) { + const resolvedDir = path.dirname(resolvedConf); + bwrapArgs.push('--ro-bind', resolvedDir, resolvedDir); + } + } catch (e) { + log('BwrapBackend: could not resolve /etc/resolv.conf:', e.message); + } + + // Bind the SDK binary read-only + const sdkDir = path.dirname(actualCommand); + bwrapArgs.push('--ro-bind', sdkDir, sdkDir); + + // Create home directory (needed for ~ expansion) but don't + // expose real home contents. + const homeDir = os.homedir(); + bwrapArgs.push('--dir', homeDir); + + // Create /sessions//mnt/ guest path structure and mount + // host directories at guest paths, matching the KVM backend + // layout. The claude-code-vm binary translates all paths to + // /sessions/ internally, so these must exist inside the sandbox. + const sessionMnt = `/sessions/${name}/mnt`; + bwrapArgs.push('--dir', `/sessions/${name}`); + bwrapArgs.push('--dir', sessionMnt); + + for (const [mountName, hostPath] of Object.entries(mountMap)) { + try { + // Fix #342: upstream fs-extra can create .mcpb-cache + // as a self-referential symlink after repeated sessions. + // Detect and remove before mkdir so the bind mount works. + try { + const st = fs.lstatSync(hostPath); + if (st.isSymbolicLink()) { + const target = fs.readlinkSync(hostPath); + const resolved = path.resolve( + path.dirname(hostPath), target + ); + if (resolved === hostPath) { + log(`BwrapBackend spawn: removing self-referential symlink: ${hostPath}`); + fs.unlinkSync(hostPath); + } + } + } catch { /* ENOENT is fine — path doesn't exist yet */ } + if (!fs.existsSync(hostPath)) { + fs.mkdirSync(hostPath, { recursive: true }); + } + } catch (e) { + log(`BwrapBackend spawn: could not create ${hostPath}: ${e.message}`); + continue; + } + const guestPath = `${sessionMnt}/${mountName}`; + const mode = additionalMounts?.[mountName]?.mode; + const bindType = mode === 'ro' ? '--ro-bind' : '--bind'; + bwrapArgs.push(bindType, hostPath, guestPath); + log(`BwrapBackend spawn: mount ${mountName}: ${hostPath} -> ${guestPath} (${mode || 'rw'})`); + } + + // Namespace isolation + actual command + bwrapArgs.push( + '--unshare-pid', + '--die-with-parent', + '--new-session', + '--', + actualCommand, + ...rawArgs, + ); + + // Use the primary user mount as cwd (first non-dotfile, non-uploads mount) + const primaryMount = Object.keys(mountMap).find( + n => !n.startsWith('.') && n !== 'uploads', + ); + const guestWorkDir = primaryMount + ? `${sessionMnt}/${primaryMount}` + : sessionMnt; + + log(`BwrapBackend spawn: bwrap args=${JSON.stringify(bwrapArgs)}`); + log(`BwrapBackend spawn: cwd=${guestWorkDir}`); + + // Use host-side cwd for Node's spawn (guest paths don't exist + // on host). bwrap --chdir sets the actual cwd inside the sandbox. + this._spawnLocal(id, 'bwrap', + ['--chdir', guestWorkDir, ...bwrapArgs], + os.homedir(), mergedEnv); + return {}; + } + + async mountPath(params) { + const { subpath, mountName } = params; + log(`BwrapBackend mountPath: ${mountName} -> ${subpath}`); + const hostPath = path.join('/', subpath || ''); + // Store for --bind on next spawn + this.mountBinds.set(mountName || subpath, hostPath); + return { guestPath: hostPath }; + } +} + +// ============================================================ +// KvmBackend — QEMU/KVM virtual machine +// ============================================================ + +const VM_BASE_DIR = path.join(os.homedir(), '.local/share/claude-desktop/vm'); +const VM_SESSION_DIR = path.join(VM_BASE_DIR, 'sessions'); +const VSOCK_GUEST_PORT = 51234; // 0xC822 — matches guest sdk-daemon +const HOME_SHARE_MOUNT_TAG = 'claudeshared'; +const HOME_SHARE_GUEST_MOUNT = '/mnt/.virtiofs-root'; +const QMP_CAPABILITIES = JSON.stringify({ execute: 'qmp_capabilities' }); + +/** Event types forwarded from the guest sdk-daemon to subscribers. */ +const FORWARDED_EVENTS = new Set([ + 'stdout', 'stderr', 'exit', 'networkStatus', 'apiReachability', + 'ready', 'startupStep', +]); + +class KvmBackend extends BackendBase { + constructor(emitEvent) { + super(emitEvent); + this.config = { memoryMB: 8192, cpuCount: 4 }; + this.running = false; + this.guestConnected = false; + this.qemuProcess = null; + this.virtiofsdProcess = null; + this.homeShareType = null; // 'virtiofs', '9p', or null + this.socatProcess = null; + this.sessionDir = null; + this.monitorSock = null; + this.bridgeSock = null; + this.guestCid = null; + this.sdkBinaryPath = null; + this._qmpAvailable = true; + this.processes = new Map(); // id -> bridge connection state + } + + async init(config) { + if (config.memoryMB !== undefined) { + this.config.memoryMB = config.memoryMB; + } + if (config.cpuCount !== undefined) { + this.config.cpuCount = config.cpuCount; + } + + // Ensure VM directory exists + fs.mkdirSync(VM_BASE_DIR, { recursive: true }); + + // Convert VHDX to qcow2 if present in VM_BASE_DIR (manual + // placement). The main conversion happens in startVM() using + // the app-provided bundlePath. + const vhdxPath = path.join(VM_BASE_DIR, 'rootfs.vhdx'); + const qcow2Path = path.join(VM_BASE_DIR, 'rootfs.qcow2'); + if (fs.existsSync(vhdxPath) && !fs.existsSync(qcow2Path)) { + log('KvmBackend: converting VHDX to qcow2...'); + try { + execFileSync('qemu-img', [ + 'convert', '-f', 'vhdx', '-O', 'qcow2', + vhdxPath, qcow2Path + ], { stdio: 'pipe', timeout: 300000 }); + log('KvmBackend: VHDX conversion complete'); + } catch (e) { + logError('KvmBackend: VHDX conversion failed:', e.message); + throw new Error(`VHDX conversion failed: ${e.message}`); + } + } + + log('KvmBackend configured:', this.config); + } + + _allocateCid() { + // Allocate a unique guest CID starting at 3 (0-2 are reserved) + // Check /dev/vhost-vsock is available and pick next free CID + let cid = 3; + const cidFile = path.join(VM_BASE_DIR, '.next_cid'); + try { + cid = parseInt(fs.readFileSync(cidFile, 'utf8').trim(), 10); + if (isNaN(cid) || cid < 3) cid = 3; + } catch (_) { + // First run, start at 3 + } + const next = cid >= 65535 ? 3 : cid + 1; + fs.writeFileSync(cidFile, String(next)); + return cid; + } + + async startVM(params) { + if (this.running) { + log('KvmBackend: already running'); + return {}; + } + + this.bundlePath = params.bundlePath || VM_BASE_DIR; + const memoryGB = params.memoryGB || + Math.ceil(this.config.memoryMB / 1024); + const cpuCount = this.config.cpuCount; + + this.emitEvent({ + type: 'startupStep', + step: 'prepare_session', status: 'running', + }); + + // The app downloads VM images (rootfs.vhdx, vmlinuz, initrd) + // to bundlePath (~/.config/Claude/vm_bundles/claudevm.bundle/). + // Convert VHDX to qcow2 if needed (the app downloads VHDX + // format using the win32 manifest entries). + const bundleDir = this.bundlePath; + const vhdxPath = path.join(bundleDir, 'rootfs.vhdx'); + const qcow2Path = path.join(bundleDir, 'rootfs.qcow2'); + if (fs.existsSync(vhdxPath) && !fs.existsSync(qcow2Path)) { + log('KvmBackend: converting rootfs.vhdx to qcow2...'); + try { + execFileSync('qemu-img', [ + 'convert', '-f', 'vhdx', '-O', 'qcow2', + vhdxPath, qcow2Path + ], { stdio: 'pipe', timeout: 300000 }); + log('KvmBackend: rootfs conversion complete'); + } catch (e) { + logError('KvmBackend: rootfs conversion failed:', + e.message); + throw new Error( + `rootfs conversion failed: ${e.message}`); + } + } + + // Fall back: check VM_BASE_DIR if bundle has no rootfs + const basePath = fs.existsSync(qcow2Path) + ? qcow2Path + : path.join(VM_BASE_DIR, 'rootfs.qcow2'); + if (!fs.existsSync(basePath)) { + throw new Error( + `rootfs not found in ${bundleDir} or ${VM_BASE_DIR}`); + } + + // Create session directory + const sessionId = crypto.randomUUID(); + this.sessionDir = path.join(VM_SESSION_DIR, sessionId); + fs.mkdirSync(this.sessionDir, { recursive: true }); + + // Create overlay disk + const overlayPath = path.join(this.sessionDir, 'overlay.qcow2'); + try { + execFileSync('qemu-img', [ + 'create', '-f', 'qcow2', '-b', basePath, + '-F', 'qcow2', overlayPath + ], { stdio: 'pipe' }); + } catch (e) { + logError('KvmBackend: overlay creation failed:', e.message); + throw new Error(`Overlay creation failed: ${e.message}`); + } + + // Allocate guest CID + this.guestCid = this._allocateCid(); + this.monitorSock = path.join(this.sessionDir, 'qmp.sock'); + this.bridgeSock = path.join(this.sessionDir, 'bridge.sock'); + + const vmlinuzPath = path.join(bundleDir, 'vmlinuz'); + const initrdPath = path.join(bundleDir, 'initrd'); + + // Start home directory share for guest VM. + // Try virtiofsd first (best performance), fall back to virtio-9p + // (built into QEMU, no daemon needed, works unprivileged). + const virtiofsSock = path.join(this.sessionDir, 'virtiofs.sock'); + try { + this.virtiofsdProcess = spawnProcess('virtiofsd', [ + `--socket-path=${virtiofsSock}`, + '-o', `source=${os.homedir()}`, + '-o', 'cache=auto', + ], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + this.virtiofsdProcess.on('error', (err) => { + log('KvmBackend: virtiofsd error:', err.message); + this.virtiofsdProcess = null; + }); + log(`KvmBackend: virtiofsd started, socket=${virtiofsSock}`); + + // Wait for virtiofsd to create its socket before starting QEMU + const vfsWaitStart = Date.now(); + while (!fs.existsSync(virtiofsSock) && + Date.now() - vfsWaitStart < 5000) { + await new Promise(r => setTimeout(r, 100)); + } + if (fs.existsSync(virtiofsSock)) { + log('KvmBackend: virtiofsd socket ready ' + + `(${Date.now() - vfsWaitStart}ms)`); + this.homeShareType = 'virtiofs'; + } else { + log('KvmBackend: virtiofsd socket not ready ' + + 'after 5s, will try virtio-9p fallback'); + this.virtiofsdProcess.kill(); + this.virtiofsdProcess = null; + } + } catch (e) { + log(`KvmBackend: virtiofsd not available: ${e.message}`); + this.virtiofsdProcess = null; + } + + // Fallback: use virtio-9p if virtiofsd failed. virtio-9p is + // built into QEMU — no external daemon, no privileges needed. + // Lower performance than virtiofs but works everywhere. + if (!this.virtiofsdProcess) { + log('KvmBackend: using virtio-9p for home directory share'); + this.homeShareType = '9p'; + } + + // Build QEMU arguments + // When virtiofs is used, QEMU requires shared memory backend for + // vhost-user-fs-pci. Use memory-backend-memfd with share=on. + const useSharedMem = this.homeShareType === 'virtiofs'; + const qemuArgs = [ + '-enable-kvm', + ...(useSharedMem + ? ['-object', `memory-backend-memfd,id=mem,size=${memoryGB}G,share=on`, + '-numa', 'node,memdev=mem', + '-m', `${memoryGB}G`] + : ['-m', `${memoryGB}G`]), + '-cpu', 'host', + '-smp', String(cpuCount), + '-nographic', + ]; + + // Kernel and initrd (if available) + if (fs.existsSync(vmlinuzPath)) { + qemuArgs.push('-kernel', vmlinuzPath); + if (fs.existsSync(initrdPath)) { + qemuArgs.push('-initrd', initrdPath); + } + qemuArgs.push( + '-append', 'root=LABEL=cloudimg-rootfs console=ttyS0 quiet' + ); + } + + // Disk (rootfs overlay → /dev/vda) + qemuArgs.push( + '-drive', `file=${overlayPath},format=qcow2,if=virtio` + ); + + // Session disk (→ /dev/vdb, formatted by guest sdk-daemon) + const sessionDiskPath = path.join(this.sessionDir, 'sessiondata.qcow2'); + try { + execFileSync('qemu-img', [ + 'create', '-f', 'qcow2', sessionDiskPath, '2G' + ], { stdio: 'pipe' }); + qemuArgs.push( + '-drive', `file=${sessionDiskPath},format=qcow2,if=virtio` + ); + log(`KvmBackend: session disk created at ${sessionDiskPath}`); + } catch (e) { + logError('KvmBackend: session disk creation failed:', e.message); + } + + // smol-bin disk (contains SDK binaries → /dev/vdc, detected + // by guest via blkid). The app copies smol-bin.vhdx from + // resources to bundleDir at startup. Convert to qcow2 if needed. + const smolVhdx = path.join(bundleDir, 'smol-bin.vhdx'); + const smolQcow2 = path.join(bundleDir, 'smol-bin.qcow2'); + if (fs.existsSync(smolVhdx) && !fs.existsSync(smolQcow2)) { + log('KvmBackend: converting smol-bin.vhdx to qcow2...'); + try { + execFileSync('qemu-img', [ + 'convert', '-f', 'vhdx', '-O', 'qcow2', + smolVhdx, smolQcow2 + ], { stdio: 'pipe', timeout: 60000 }); + log('KvmBackend: smol-bin conversion complete'); + } catch (e) { + log(`KvmBackend: smol-bin conversion failed: ${e.message}`); + } + } + // Check bundle dir first, then VM_BASE_DIR. + // Not fatal if missing — SDK can be accessed via virtiofs. + const smolBinPath = + [bundleDir, VM_BASE_DIR] + .map(d => path.join(d, 'smol-bin.qcow2')) + .find(p => fs.existsSync(p)); + if (smolBinPath) { + qemuArgs.push( + '-drive', + `file=${smolBinPath},format=qcow2,if=virtio,readonly=on` + ); + log(`KvmBackend: smol-bin attached from ${smolBinPath}`); + } else { + log('KvmBackend: smol-bin.qcow2 not found — ' + + 'SDK will be accessed via virtiofs if available'); + } + + // vsock + qemuArgs.push( + '-device', `vhost-vsock-pci,guest-cid=${this.guestCid}` + ); + + // QMP monitor + qemuArgs.push( + '-qmp', `unix:${this.monitorSock},server,nowait` + ); + + // Network + qemuArgs.push( + '-netdev', 'user,id=net0', + '-device', 'virtio-net-pci,netdev=net0' + ); + + // Home directory share device + if (this.homeShareType === 'virtiofs') { + // virtiofs: high performance, requires virtiofsd daemon + qemuArgs.push( + '-chardev', `socket,id=virtiofs,path=${virtiofsSock}`, + '-device', + `vhost-user-fs-pci,chardev=virtiofs,tag=${HOME_SHARE_MOUNT_TAG}`, + ); + } else if (this.homeShareType === '9p') { + // virtio-9p: built into QEMU, no daemon, works unprivileged. + // security_model=none: like passthrough but ignores chown + // failures — designed for unprivileged QEMU operation. + qemuArgs.push( + '-virtfs', + `local,path=${os.homedir()},mount_tag=${HOME_SHARE_MOUNT_TAG}` + + ',security_model=none,id=hostshare', + ); + } + + // Start QEMU + this.emitEvent({ + type: 'startupStep', + step: 'start_vm', status: 'running', + }); + log(`KvmBackend: starting QEMU with CID ${this.guestCid}`); + this.qemuProcess = spawnProcess('qemu-system-x86_64', qemuArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + this.qemuProcess.on('error', (err) => { + logError('KvmBackend: QEMU error:', err.message); + this.running = false; + this.guestConnected = false; + this.emitEvent({ type: 'networkStatus', status: 'disconnected' }); + }); + + this.qemuProcess.on('exit', (code, signal) => { + log(`KvmBackend: QEMU exited: code=${code}, signal=${signal}`); + this.running = false; + this.guestConnected = false; + this.emitEvent({ type: 'networkStatus', status: 'disconnected' }); + }); + + this.qemuProcess.stderr.on('data', (data) => { + log(`KvmBackend QEMU stderr: ${data.toString().trim()}`); + }); + + this.running = true; + + // Connect to QMP monitor and send capabilities + await this._connectQmp(); + + // Wait for guest sdk-daemon to connect via vsock bridge + // (_waitForGuest starts both the bridge server and socat listener) + this.emitEvent({ + type: 'startupStep', + step: 'wait_for_guest', status: 'running', + }); + await this._waitForGuest(); + + this.emitEvent({ + type: 'startupStep', + step: 'wait_for_guest', + status: this.guestConnected ? 'completed' : 'failed', + }); + + return {}; + } + + async _connectQmp() { + const timeout = 30000; + const start = Date.now(); + + return new Promise((resolve) => { + const tryConnect = () => { + if (Date.now() - start > timeout) { + logError('KvmBackend: QMP connection timeout — VM control limited'); + this._qmpAvailable = false; + resolve(); + return; + } + + if (!fs.existsSync(this.monitorSock)) { + setTimeout(tryConnect, 200); + return; + } + + const qmpClient = net.createConnection( + this.monitorSock, () => { + log('KvmBackend: QMP connected'); + } + ); + + let qmpBuffer = ''; + qmpClient.on('data', (data) => { + qmpBuffer += data.toString(); + // Wait for QMP greeting, then send capabilities + if (qmpBuffer.includes('"QMP"')) { + qmpClient.write(QMP_CAPABILITIES + '\n'); + qmpBuffer = ''; + } + if (qmpBuffer.includes('"return"')) { + log('KvmBackend: QMP capabilities negotiated'); + this._qmpClient = qmpClient; + resolve(); + } + }); + + qmpClient.on('error', (err) => { + log('KvmBackend: QMP connect error:', err.message); + setTimeout(tryConnect, 500); + }); + }; + + // Give QEMU a moment to create the socket + setTimeout(tryConnect, 500); + }); + } + + _startVsockBridge() { + // The guest sdk-daemon connects TO the host (CID=2) on the vsock port. + // We listen on vsock and forward to a local Unix bridge socket so that + // _forwardToGuest can connect to the bridge to reach the guest daemon. + // + // Direction: guest → vsock:51234 → socat → bridge.sock + // _forwardToGuest → bridge.sock → socat → vsock → guest + // + // socat listens on the vsock port for the guest's outbound connection + // and bridges it to a Unix socket that we can use for bidirectional RPC. + try { + this.socatProcess = spawnProcess('socat', [ + `VSOCK-LISTEN:${VSOCK_GUEST_PORT},reuseaddr,fork`, + `UNIX-CONNECT:${this.bridgeSock}`, + ], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + this.socatProcess.on('error', (err) => { + log('KvmBackend: socat error:', err.message); + }); + + log(`KvmBackend: socat vsock listener started on port ${VSOCK_GUEST_PORT}`); + } catch (e) { + logError('KvmBackend: failed to start socat:', e.message); + } + } + + _startBridgeServer() { + // Create a Unix socket server that accepts connections from socat + // (guest→vsock→socat→bridge.sock) and from _forwardToGuest. + // The first inbound connection from socat is the guest sdk-daemon. + return new Promise((resolve) => { + this._bridgeServer = net.createServer((conn) => { + if (!this.guestConnected) { + log('KvmBackend: guest connected via vsock bridge'); + this.guestConnected = true; + this._guestConn = conn; + this._guestBuffer = Buffer.alloc(0); + + conn.on('data', (data) => { + this._handleGuestData(data); + }); + + conn.on('error', (err) => { + logError('KvmBackend: guest connection error:', err.message); + this.guestConnected = false; + this._guestConn = null; + }); + + conn.on('close', () => { + log('KvmBackend: guest connection closed'); + this.guestConnected = false; + this._guestConn = null; + }); + + this.emitEvent({ + type: 'networkStatus', + status: 'connected', + }); + resolve(); + } + }); + + this._bridgeServer.listen(this.bridgeSock, () => { + log(`KvmBackend: bridge server listening on ${this.bridgeSock}`); + }); + + this._bridgeServer.on('error', (err) => { + logError('KvmBackend: bridge server error:', err.message); + }); + }); + } + + _handleGuestData(data) { + // Parse and route incoming messages from guest sdk-daemon + this._guestBuffer = Buffer.concat([this._guestBuffer, data]); + while (true) { + const parsed = parseMessage(this._guestBuffer); + if (!parsed) break; + this._guestBuffer = parsed.remaining; + const msg = parsed.message; + + // Log all guest messages as decoded JSON for debugging + log('KvmBackend: guest message:', JSON.stringify(msg).substring(0, 500)); + + if (FORWARDED_EVENTS.has(msg.type)) { + this.emitEvent(msg); + } else if (msg.type === 'event' && FORWARDED_EVENTS.has(msg.event)) { + // Guest sends {type:"event", event:"networkStatus", params:{...}} + this.emitEvent({ type: msg.event, ...msg.params }); + } else if (msg.type === 'response' || msg.success !== undefined) { + // Response to a request we sent — route to pending callback + // Guest sends {type:"response", id:"1", result:{success:true}} + if (msg.error) { + log(`KvmBackend: guest response ERROR for id=${msg.id}:`, JSON.stringify(msg.error)); + } + if (this._pendingCallbacks && msg.id !== undefined) { + const cb = this._pendingCallbacks.get(String(msg.id)); + if (cb) { + this._pendingCallbacks.delete(String(msg.id)); + cb(msg.result || msg); + } + } + } else { + log('KvmBackend: unhandled guest message:', JSON.stringify(msg)); + } + } + } + + async _waitForGuest() { + const timeout = 90000; + const start = Date.now(); + + // Start the bridge Unix socket server, then start socat to listen on + // vsock. The guest sdk-daemon will connect after boot. + const bridgeReady = this._startBridgeServer(); + this._startVsockBridge(); + + // Wait for guest to connect (or timeout) + return Promise.race([ + bridgeReady, + new Promise((resolve) => { + const checkTimeout = () => { + if (Date.now() - start > timeout) { + logError('KvmBackend: guest readiness timeout'); + resolve(); + return; + } + if (this.guestConnected) { + resolve(); + return; + } + setTimeout(checkTimeout, 1000); + }; + setTimeout(checkTimeout, 2000); + }), + ]); + } + + _sendQmpCommand(command) { + return new Promise((resolve, reject) => { + if (!this._qmpClient || this._qmpClient.destroyed) { + reject(new Error('QMP not connected')); + return; + } + + let responseBuffer = ''; + let timer; + const onData = (data) => { + responseBuffer += data.toString(); + try { + const parsed = JSON.parse(responseBuffer); + clearTimeout(timer); + this._qmpClient.removeListener('data', onData); + resolve(parsed); + } catch (_) { + // Incomplete JSON, keep buffering + } + }; + + this._qmpClient.on('data', onData); + this._qmpClient.write( + JSON.stringify({ execute: command }) + '\n' + ); + + timer = setTimeout(() => { + this._qmpClient.removeListener('data', onData); + reject(new Error('QMP command timeout')); + }, 10000); + }); + } + + async _ensureSdkInstalled() { + if (!this._pendingSdkInstall || !this.guestConnected) return; + try { + log('KvmBackend: installing SDK in guest'); + await this._forwardToGuest({ + method: 'installSdk', params: this._pendingSdkInstall + }); + } catch (e) { + log(`KvmBackend: installSdk forward failed: ${e.message}`); + } + // Clear regardless of success/failure to avoid infinite retries + this._pendingSdkInstall = null; + } + + _forwardToGuest(request) { + return new Promise((resolve, reject) => { + if (!this._guestConn || !this.guestConnected) { + reject(new Error('Guest not connected')); + return; + } + + // Assign a unique ID if not present, so we can match responses + if (request.id === undefined) { + if (!this._nextRequestId) this._nextRequestId = 1; + request.id = String(this._nextRequestId++); + } + + if (!this._pendingCallbacks) { + this._pendingCallbacks = new Map(); + } + + const timer = setTimeout(() => { + this._pendingCallbacks.delete(request.id); + reject(new Error('Guest communication timeout')); + }, 30000); + + this._pendingCallbacks.set(request.id, (response) => { + clearTimeout(timer); + resolve(response); + }); + + try { + // Guest expects {type:"request", method:..., params:..., id:...} + const wireMsg = { type: 'request', ...request }; + log('KvmBackend: forwarding to guest:', JSON.stringify(wireMsg).substring(0, 200)); + writeMessage(this._guestConn, wireMsg); + } catch (err) { + clearTimeout(timer); + this._pendingCallbacks.delete(request.id); + reject(err); + } + }); + } + + async stopVM() { + log('KvmBackend: stopVM'); + + // 1. ACPI shutdown via QMP + try { + await this._sendQmpCommand('system_powerdown'); + log('KvmBackend: ACPI shutdown sent'); + } catch (e) { + log('KvmBackend: ACPI shutdown failed:', e.message); + } + + // 2. Wait 10s, then force quit via QMP + await new Promise((resolve) => { + const checkExit = () => { + if (!this.qemuProcess || this.qemuProcess.exitCode !== null) { + resolve(); + return; + } + // Force quit after waiting + this._sendQmpCommand('quit').catch(() => {}); + setTimeout(() => { + resolve(); + }, 3000); + }; + setTimeout(checkExit, 10000); + }); + + // 3. SIGKILL if still running + if (this.qemuProcess && this.qemuProcess.exitCode === null) { + try { + this.qemuProcess.kill('SIGKILL'); + log('KvmBackend: QEMU force killed'); + } catch (e) { + log('KvmBackend: QEMU kill error:', e.message); + } + } + + // 4. Kill helper processes and close connections + const cleanup = (obj, method) => { + if (!obj) return; + try { obj[method](); } catch (_) {} + }; + cleanup(this.virtiofsdProcess, 'kill'); + cleanup(this.socatProcess, 'kill'); + cleanup(this._qmpClient, 'destroy'); + cleanup(this._guestConn, 'destroy'); + cleanup(this._bridgeServer, 'close'); + this.virtiofsdProcess = null; + this.homeShareType = null; + this.socatProcess = null; + this._qmpClient = null; + this._guestConn = null; + this._bridgeServer = null; + + // 5. Clean up session directory + if (this.sessionDir) { + try { + fs.rmSync(this.sessionDir, { recursive: true, force: true }); + log(`KvmBackend: cleaned up session dir: ${this.sessionDir}`); + } catch (e) { + log('KvmBackend: session cleanup error:', e.message); + } + this.sessionDir = null; + } + + this.running = false; + this.guestConnected = false; + this.qemuProcess = null; + this.emitEvent({ type: 'networkStatus', status: 'disconnected' }); + return {}; + } + + isRunning() { + return { running: this.running }; + } + + isGuestConnected() { + return { connected: this.guestConnected }; + } + + async spawn(params) { + const { id } = params; + log(`KvmBackend spawn: id=${id}, forwarding to guest`); + + // Ensure SDK is installed in the guest before spawning + await this._ensureSdkInstalled(); + + try { + const result = await this._forwardToGuest({ + method: 'spawn', params + }); + // Track that this process exists in the guest. + // Events (stdout/stderr/exit) flow back through the + // single guest connection → _handleGuestData → emitEvent. + this.processes.set(id, { remote: true }); + + return result.result || {}; + } catch (e) { + logError(`KvmBackend: spawn forward failed: ${e.message}`); + this.emitEvent({ + type: 'stderr', id, + data: `Error: Failed to spawn in VM: ${e.message}\n`, + }); + this.emitEvent({ + type: 'exit', id, exitCode: 1, + signal: null, + }); + return {}; + } + } + + async kill(params) { + log(`KvmBackend kill: id=${params.id}`); + try { + await this._forwardToGuest({ method: 'kill', params }); + } catch (e) { + log(`KvmBackend: kill forward failed: ${e.message}`); + } + return {}; + } + + async writeStdin(params) { + // Guest RPC treats stdin as a notification (fire-and-forget), + // not a request. Sending as type:"request" returns "unknown method". + if (!this._guestConn || !this.guestConnected) { + log('KvmBackend: writeStdin: guest not connected'); + return {}; + } + try { + writeMessage(this._guestConn, { + type: 'notification', method: 'stdin', params, + }); + } catch (e) { + log(`KvmBackend: writeStdin failed: ${e.message}`); + } + return {}; + } + + isProcessRunning(params) { + const { id } = params; + return { running: this.processes.has(id) }; + } + + async mountPath(params) { + const { subpath, mountName } = params; + log(`KvmBackend mountPath: ${mountName} -> ${subpath}`); + + if (this.homeShareType) { + // Home share active (virtiofs or 9p) — guest accesses + // host files via the shared mount + const guestPath = + path.join(HOME_SHARE_GUEST_MOUNT, subpath || ''); + return { guestPath }; + } + + // No home share — return host path with a warning + const hostPath = path.join('/', subpath || ''); + log('KvmBackend: no home share, returning host path'); + return { guestPath: hostPath }; + } + + async readFile(params) { + const { filePath } = params; + log(`KvmBackend readFile: ${filePath}`); + + // Try forwarding to guest first + if (this.guestConnected) { + try { + const result = await this._forwardToGuest({ + method: 'readFile', params + }); + if (result.result) return result.result; + } catch (e) { + log(`KvmBackend: guest readFile failed, trying host: ${e.message}`); + } + } + + // Fallback: read from host + const resolved = path.resolve(filePath); + const home = os.homedir(); + if (!resolved.startsWith(home + path.sep) && resolved !== home) { + return { error: 'Access denied: path outside home directory' }; + } + try { + const content = fs.readFileSync(resolved, 'utf8'); + return { content }; + } catch (e) { + return { error: e.message }; + } + } + + async installSdk(params) { + const { sdkSubpath, version } = params; + log(`KvmBackend installSdk: ${sdkSubpath}@${version}`); + const resolved = resolveSdkBinary( + sdkSubpath, version, 'KvmBackend' + ); + if (resolved) { + this.sdkBinaryPath = resolved; + // Compute the guest-side path via home share mount + const homeDir = os.homedir(); + const relPath = path.relative(homeDir, resolved); + if (relPath.startsWith('..')) { + log('KvmBackend: SDK path is outside home dir,' + + ` cannot map to guest: ${resolved}`); + } else { + this.guestSdkPath = path.join( + HOME_SHARE_GUEST_MOUNT, relPath + ); + log(`KvmBackend: guest SDK path: ${this.guestSdkPath}`); + } + } + // Forward to guest so it can prepare the SDK (or defer until spawn) + this._pendingSdkInstall = params; + if (this.guestConnected) { + await this._ensureSdkInstalled(); + } else { + log('KvmBackend: guest not connected yet, will install SDK before spawn'); + } + return {}; + } + + async addApprovedOauthToken(params) { + log('KvmBackend: addApprovedOauthToken'); + // Forward to guest if connected + if (this.guestConnected) { + try { + await this._forwardToGuest({ + method: 'addApprovedOauthToken', params + }); + } catch (e) { + log('KvmBackend: OAuth forward failed:', e.message); + } + } + return {}; + } +} + +// ============================================================ +// Backend Detection +// ============================================================ + +function detectBackend(emitEvent) { + const override = BACKEND_OVERRIDE; + if (override) { + log(`Backend override: ${override}`); + switch (override.toLowerCase()) { + case 'kvm': + return new KvmBackend(emitEvent); + case 'bwrap': + return new BwrapBackend(emitEvent); + case 'host': + return new HostBackend(emitEvent); + default: + logError(`Unknown backend override "${override}", falling back to auto-detect`); + } + } + + // Auto-detect: try bwrap first, then KVM, then host. + try { + execFileSync('which', ['bwrap'], { stdio: 'pipe' }); + execFileSync('bwrap', ['--ro-bind', '/', '/', 'true'], { + stdio: 'pipe', timeout: 5000 + }); + log('Backend: bwrap'); + // Hint for users upgrading from KVM-first auto-detection + try { + fs.accessSync('/dev/kvm', fs.constants.R_OK | fs.constants.W_OK); + log('Note: KVM is available but bwrap is now the default. ' + + 'Set COWORK_VM_BACKEND=kvm for full VM isolation.'); + } catch (_) { /* KVM not available, no hint needed */ } + return new BwrapBackend(emitEvent); + } catch (e) { + log(`bwrap not available: ${e.message}`); + } + + // Note: rootfs is NOT checked here — the app downloads it to + // bundlePath which isn't known until startVM(). The rootfs + // check happens at startVM time instead. + try { + fs.accessSync('/dev/kvm', fs.constants.R_OK | fs.constants.W_OK); + execFileSync('which', ['qemu-system-x86_64'], { stdio: 'pipe' }); + fs.accessSync('/dev/vhost-vsock', fs.constants.R_OK); + log('Backend: kvm (all requirements met)'); + return new KvmBackend(emitEvent); + } catch (e) { + log(`KVM not available: ${e.message}`); + } + + log('Backend: host (no isolation)'); + return new HostBackend(emitEvent); +} + +// ============================================================ +// VMManager — Thin Dispatcher +// ============================================================ + +class VMManager { + constructor() { + this.eventSubscribers = new Set(); + this.backend = detectBackend((event) => this.broadcastEvent(event)); + } + + // --- Configuration --- + + configure(params) { + const config = {}; + if (params.memoryMB !== undefined) config.memoryMB = params.memoryMB; + if (params.cpuCount !== undefined) config.cpuCount = params.cpuCount; + // init is async but configure is sync in the protocol — + // fire-and-forget is fine for config + this.backend.init(config).catch((e) => { + logError('Backend init error:', e.message); + }); + log('Configured:', params); + return {}; + } + + // --- VM Lifecycle (delegate to backend) --- + + async createVM(params) { + log(`createVM: bundle=${params.bundlePath}`); + return {}; + } + + async startVM(params) { + return this.backend.startVM(params); + } + + async stopVM() { + return this.backend.stopVM(); + } + + isRunning() { + return this.backend.isRunning(); + } + + isGuestConnected() { + return this.backend.isGuestConnected(); + } + + // --- Process Management (delegate to backend) --- + + async spawn(params) { + return this.backend.spawn(params); + } + + async kill(params) { + return this.backend.kill(params); + } + + async writeStdin(params) { + return this.backend.writeStdin(params); + } + + isProcessRunning(params) { + return this.backend.isProcessRunning(params); + } + + // --- File System (delegate to backend) --- + + async mountPath(params) { + return this.backend.mountPath(params); + } + + async readFile(params) { + return this.backend.readFile(params); + } + + // --- SDK Management (delegate to backend) --- + + async installSdk(params) { + return this.backend.installSdk(params); + } + + // --- OAuth (delegate to backend) --- + + async addApprovedOauthToken(params) { + return this.backend.addApprovedOauthToken(params); + } + + // --- Debug Logging --- + + setDebugLogging(params) { + const { enabled } = params; + log(`setDebugLogging: ${enabled}`); + return {}; + } + + // --- Events (managed by VMManager, not backend) --- + + subscribeEvents(socket) { + this.eventSubscribers.add(socket); + socket.on('close', () => { + this.eventSubscribers.delete(socket); + }); + return {}; + } + + broadcastEvent(event) { + for (const socket of this.eventSubscribers) { + try { + writeMessage(socket, event); + } catch (e) { + log('Failed to send event:', e.message); + this.eventSubscribers.delete(socket); + } + } + } +} + +// ============================================================ +// Method Dispatch +// ============================================================ + +const vm = new VMManager(); + +const METHODS = { + configure: (params) => vm.configure(params), + createVM: (params) => vm.createVM(params), + startVM: (params) => vm.startVM(params), + stopVM: () => vm.stopVM(), + isRunning: () => vm.isRunning(), + isGuestConnected: () => vm.isGuestConnected(), + spawn: (params) => vm.spawn(params), + kill: (params) => vm.kill(params), + writeStdin: (params) => vm.writeStdin(params), + isProcessRunning: (params) => vm.isProcessRunning(params), + mountPath: (params) => vm.mountPath(params), + readFile: (params) => vm.readFile(params), + installSdk: (params) => vm.installSdk(params), + addApprovedOauthToken: (params) => vm.addApprovedOauthToken(params), + setDebugLogging: (params) => vm.setDebugLogging(params), + subscribeEvents: (params, socket) => vm.subscribeEvents(socket), +}; + +async function handleRequest(request, socket) { + const { method, params } = request; + // Redact env block (may contain API keys/tokens) + if (params) { + const { env, ...rest } = params; + const summary = JSON.stringify(rest).substring(0, 2000) + + (env ? ' [env: redacted]' : ''); + log(`Request: ${method}`, summary); + } else { + log(`Request: ${method}`); + } + + const handler = METHODS[method]; + if (!handler) { + return { success: false, error: `Unknown method: ${method}` }; + } + + try { + const result = await handler(params || {}, socket); + return { success: true, result: result || {} }; + } catch (e) { + logError(`Method ${method} failed:`, e.message); + return { success: false, error: e.message }; + } +} + +// ============================================================ +// Socket Server +// ============================================================ + +function cleanupSocket() { + try { + if (fs.existsSync(SOCKET_PATH)) { + fs.unlinkSync(SOCKET_PATH); + } + } catch (e) { + // Ignore cleanup errors + } +} + +function startServer() { + // Clean up stale socket + cleanupSocket(); + + const server = net.createServer((socket) => { + log('Client connected'); + let buffer = Buffer.alloc(0); + + socket.on('data', async (data) => { + buffer = Buffer.concat([buffer, data]); + + // Process all complete messages in buffer + let parsed; + try { + parsed = parseMessage(buffer); + } catch (e) { + logError('Parse error:', e.message); + buffer = Buffer.alloc(0); + return; + } + + while (parsed) { + buffer = parsed.remaining; + const response = await handleRequest(parsed.message, socket); + // Echo back request id so persistent-connection clients + // can match responses to pending requests. + if (parsed.message.id !== undefined) { + response.id = parsed.message.id; + } + writeMessage(socket, response); + + try { + parsed = parseMessage(buffer); + } catch (e) { + logError('Parse error:', e.message); + buffer = Buffer.alloc(0); + return; + } + } + }); + + socket.on('error', (err) => { + if (err.code !== 'ECONNRESET' && err.code !== 'EPIPE') { + log('Socket error:', err.message); + } + }); + + socket.on('close', () => { + log('Client disconnected'); + }); + }); + + server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + logError('Socket already in use:', SOCKET_PATH); + logError('Another instance may be running. Exiting.'); + process.exit(1); + } + logError('Server error:', err.message); + }); + + server.listen(SOCKET_PATH, () => { + // Set socket permissions (owner-only access) + try { + fs.chmodSync(SOCKET_PATH, 0o700); + } catch (e) { + // Non-fatal + } + log(`Listening on ${SOCKET_PATH}`); + console.log(`${LOG_PREFIX} Service started on ${SOCKET_PATH}`); + }); + + // Graceful shutdown + const shutdown = () => { + log('Shutting down...'); + vm.stopVM().catch(() => {}).finally(() => { + server.close(); + cleanupSocket(); + process.exit(0); + }); + }; + + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); + process.on('uncaughtException', (err) => { + logError('Uncaught exception:', err); + shutdown(); + }); +} + +// ============================================================ +// Entry Point +// ============================================================ + +// Always clean up stale socket and start. The app's retry wrapper has a +// dedup flag (_svcLaunched) preventing duplicate daemon launches, so a +// simple synchronous cleanup avoids the race condition where an async +// connection test delays startup while the app is already retrying. +cleanupSocket(); +startServer(); diff --git a/scripts/frame-fix-wrapper.js b/scripts/frame-fix-wrapper.js new file mode 100644 index 0000000..404bed1 --- /dev/null +++ b/scripts/frame-fix-wrapper.js @@ -0,0 +1,371 @@ +// Inject frame fix before main app loads +const Module = require('module'); +const path = require('path'); +const originalRequire = Module.prototype.require; + +console.log('[Frame Fix] Wrapper loaded'); + +// Fix process.resourcesPath to match the actual location of app.asar. +// In Nix builds, electron is a separate store path so process.resourcesPath +// points to the Electron package's resources dir, not where our tray icons +// and app.asar.unpacked live. Deriving from __dirname (the asar root) gives +// the correct path; for deb/AppImage builds the values already match. +const derivedResourcesPath = path.dirname(__dirname); +if (derivedResourcesPath !== process.resourcesPath) { + console.log('[Frame Fix] Correcting process.resourcesPath'); + console.log('[Frame Fix] Was:', process.resourcesPath); + console.log('[Frame Fix] Now:', derivedResourcesPath); + process.resourcesPath = derivedResourcesPath; +} + +// Menu bar visibility mode, controlled by CLAUDE_MENU_BAR env var: +// 'auto' - hidden by default, Alt toggles visibility (current default) +// 'visible' - always visible, Alt does not toggle (stable layout) +// 'hidden' - always hidden, Alt does not toggle +// Also accepts boolean-style aliases: 1/true/yes/on -> visible, 0/false/no/off -> hidden +const VALID_MENU_BAR_MODES = ['auto', 'visible', 'hidden']; +const MENU_BAR_ALIASES = { + '1': 'visible', 'true': 'visible', 'yes': 'visible', 'on': 'visible', + '0': 'hidden', 'false': 'hidden', 'no': 'hidden', 'off': 'hidden', +}; +const rawMenuBarMode = (process.env.CLAUDE_MENU_BAR || 'auto').toLowerCase(); +const resolvedMode = MENU_BAR_ALIASES[rawMenuBarMode] || rawMenuBarMode; +const MENU_BAR_MODE = VALID_MENU_BAR_MODES.includes(resolvedMode) ? resolvedMode : 'auto'; +if (resolvedMode !== rawMenuBarMode) { + console.log(`[Frame Fix] CLAUDE_MENU_BAR '${process.env.CLAUDE_MENU_BAR}' resolved to '${resolvedMode}'`); +} else if (resolvedMode !== MENU_BAR_MODE) { + console.warn(`[Frame Fix] Unknown CLAUDE_MENU_BAR value '${process.env.CLAUDE_MENU_BAR}', falling back to 'auto'. Valid: ${VALID_MENU_BAR_MODES.join(', ')}, or 0/1/true/false/yes/no/on/off`); +} +console.log(`[Frame Fix] Menu bar mode: ${MENU_BAR_MODE}`); + +// Detect if a window intends to be frameless (popup/Quick Entry/About) +// Quick Entry: titleBarStyle:"", skipTaskbar:true, transparent:true, resizable:false +// About: titleBarStyle:"", skipTaskbar:true, resizable:false +// Main: titleBarStyle:"", titleBarOverlay:false(linux), resizable (has minWidth) +// The main window has minWidth set; popups do not. +function isPopupWindow(options) { + if (!options) return false; + if (options.frame === false) return true; + if (options.titleBarStyle === '' && !options.minWidth) return true; + return false; +} + +// CSS injection for Linux scrollbar styling +// Respects both light and dark themes via prefers-color-scheme +const LINUX_CSS = ` + /* Scrollbar styling - thin, unobtrusive, adapts to theme */ + ::-webkit-scrollbar { width: 8px; height: 8px; } + ::-webkit-scrollbar-track { background: transparent; } + ::-webkit-scrollbar-thumb { + background: rgba(128, 128, 128, 0.3); + border-radius: 4px; + transition: background 0.15s ease; + } + ::-webkit-scrollbar-thumb:hover { + background: rgba(128, 128, 128, 0.55); + } + @media (prefers-color-scheme: dark) { + ::-webkit-scrollbar-thumb { + background: rgba(200, 200, 200, 0.2); + } + ::-webkit-scrollbar-thumb:hover { + background: rgba(200, 200, 200, 0.4); + } + } +`; + +// Build the patched BrowserWindow class and Menu interceptor once, +// on first require('electron'), then reuse via Proxy on every access. +let PatchedBrowserWindow = null; +let patchedSetApplicationMenu = null; +let electronModule = null; + +Module.prototype.require = function(id) { + const result = originalRequire.apply(this, arguments); + + if (id === 'electron') { + // Build patches once from the real electron module + if (!PatchedBrowserWindow) { + electronModule = result; + const OriginalBrowserWindow = result.BrowserWindow; + const OriginalMenu = result.Menu; + + PatchedBrowserWindow = class BrowserWindowWithFrame extends OriginalBrowserWindow { + constructor(options) { + console.log('[Frame Fix] BrowserWindow constructor called'); + let popup = false; + if (process.platform === 'linux') { + options = options || {}; + const originalFrame = options.frame; + popup = isPopupWindow(options); + + if (popup) { + // Popup/Quick Entry windows: keep frameless for proper UX + options.frame = false; + // Remove macOS-specific titlebar options that don't apply on Linux + delete options.titleBarStyle; + delete options.titleBarOverlay; + console.log('[Frame Fix] Popup detected, keeping frameless'); + } else { + // Main window: force native frame + options.frame = true; + // Menu bar behavior depends on CLAUDE_MENU_BAR mode: + // 'auto' (default): hidden, Alt toggles + // 'visible'/'hidden': no Alt toggle + options.autoHideMenuBar = (MENU_BAR_MODE === 'auto'); + // Remove custom titlebar options + delete options.titleBarStyle; + delete options.titleBarOverlay; + console.log(`[Frame Fix] Modified frame from ${originalFrame} to true`); + } + } + super(options); + + if (process.platform === 'linux') { + // Hide menu bar after window creation (unless user wants it visible) + if (MENU_BAR_MODE !== 'visible') { + this.setMenuBarVisibility(false); + } + + // Inject CSS for Linux scrollbar styling + this.webContents.on('did-finish-load', () => { + this.webContents.insertCSS(LINUX_CSS).catch(() => {}); + }); + + // In 'hidden' mode, suppress Alt toggle by re-hiding + // on every show event. In 'auto' mode, let + // autoHideMenuBar handle the toggle natively. + if (MENU_BAR_MODE === 'hidden') { + this.on('show', () => { + this.setMenuBarVisibility(false); + }); + } + + if (!popup) { + // Directly set child view bounds to match content size. + // This bypasses Chromium's stale LayoutManagerBase cache + // (only invalidated via _NET_WM_STATE atom changes, which + // KWin corner-snap/quick-tile never sets). Instead of + // monkey-patching getContentBounds() (which causes drag + // resize jitter at ~60Hz), we only act on discrete state + // changes. Fixes: #239 + const fixChildBounds = () => { + if (this.isDestroyed()) return false; + const children = this.contentView?.children; + if (!children?.length) return false; + const [cw, ch] = this.getContentSize(); + if (cw <= 0 || ch <= 0) return false; + const cur = children[0].getBounds(); + if (cur.width !== cw || cur.height !== ch) { + children[0].setBounds({ x: 0, y: 0, width: cw, height: ch }); + return true; + } + return false; + }; + + // Geometry settles in stages after state changes. + // Three passes at 0/16/150ms cover immediate, next-frame, + // and compositor-animation-complete timing. + const fixAfterStateChange = () => { + fixChildBounds(); + setTimeout(fixChildBounds, 16); + setTimeout(fixChildBounds, 150); + }; + + // Suppresses resize/moved→fixAfterStateChange cascade + // during jiggle. Without this, each setSize triggers the + // resize handler, creating 6+ unnecessary timer callbacks. + let jiggling = false; + + // Track interactive (user-drag) resizing. will-resize + // only fires for user-initiated drags, not programmatic + // setSize() or WM-initiated resizes. On Wayland compositors + // where will-resize may not fire, the guard stays false — + // safe because jiggle only triggers from armed pairs. + let userResizing = false; + let userResizeTimer = null; + this.on('will-resize', () => { + userResizing = true; + if (userResizeTimer) clearTimeout(userResizeTimer); + userResizeTimer = setTimeout(() => { userResizing = false; }, 300); + }); + + // Debounced 1px jiggle for workspace switches where tile + // size is unchanged (bounds match but compositor cache is + // stale). Only called from armed-pair handlers, never + // from resize/maximize. Same pattern as ready-to-show + // but debounced and guarded. + // INVARIANT: debounce (100ms) must exceed jiggle duration + // (50ms) to prevent overlapping jiggles on rapid workspace + // switching. Do not reduce debounce below jiggle timeout. + let jiggleTimer = null; + const jiggleIfStale = () => { + if (jiggleTimer) clearTimeout(jiggleTimer); + jiggleTimer = setTimeout(() => { + jiggleTimer = null; + if (this.isDestroyed() || userResizing) return; + if (!fixChildBounds()) { + jiggling = true; + const [w, h] = this.getSize(); + this.setSize(w + 1, h); + setTimeout(() => { + if (!this.isDestroyed()) { + this.setSize(w, h); + fixChildBounds(); + } + jiggling = false; + }, 50); + } + }, 100); + }; + + for (const evt of ['maximize', 'unmaximize', + 'enter-full-screen', 'leave-full-screen']) { + this.on(evt, fixAfterStateChange); + } + + // KWin corner-snap/quick-tile emits 'moved' but not + // 'maximize'/'unmaximize'. Guard with a size-change check + // so normal window drags (position-only) are ignored. + let lastSize = [0, 0]; + this.on('moved', () => { + if (this.isDestroyed() || jiggling) return; + const [w, h] = this.getSize(); + if (w !== lastSize[0] || h !== lastSize[1]) { + lastSize = [w, h]; + fixAfterStateChange(); + } + }); + + // Tiling WMs (Hyprland, i3, sway) emit 'resize' on + // workspace switches with stale getContentBounds() + // cache. The size-change guard in fixChildBounds() + // prevents unnecessary work during drag resize. + // Fixes: #323 + this.on('resize', () => { + if (!jiggling) fixAfterStateChange(); + }); + + // ready-to-show fires once per window lifecycle + this.once('ready-to-show', () => { + if (MENU_BAR_MODE !== 'visible') { + this.setMenuBarVisibility(false); + } + // One-time jiggle for initial layout. Fixes: #84 + const [w, h] = this.getSize(); + this.setSize(w + 1, h + 1); + setTimeout(() => { + if (this.isDestroyed()) return; + this.setSize(w, h); + fixAfterStateChange(); + }, 50); + }); + + // Tiling WMs signal workspace switches via blur/focus + // (Hyprland) or hide/show pairs. Jiggle only fires + // when fixChildBounds() finds no mismatch (stale + // compositor cache on same-size workspace switch). + // Fixes: #323 + const armPair = (armEvt, fireEvt) => { + let armed = false; + this.on(armEvt, () => { armed = true; }); + this.on(fireEvt, () => { + if (armed) { + armed = false; + jiggleIfStale(); + } + }); + }; + + this.on('focus', () => { + this.flashFrame(false); // Fixes: #149 + }); + armPair('blur', 'focus'); + armPair('hide', 'show'); + } + + console.log('[Frame Fix] Linux patches applied'); + } + } + }; + + // Copy static methods and properties from original + for (const key of Object.getOwnPropertyNames(OriginalBrowserWindow)) { + if (key !== 'prototype' && key !== 'length' && key !== 'name') { + try { + const descriptor = Object.getOwnPropertyDescriptor(OriginalBrowserWindow, key); + if (descriptor) { + Object.defineProperty(PatchedBrowserWindow, key, descriptor); + } + } catch (e) { + // Ignore errors for non-configurable properties + } + } + } + + // Intercept Menu.setApplicationMenu to hide menu bar on Linux. + // In 'hidden' mode, force-hide after every menu update. + // In 'auto' mode, only hide initially (autoHideMenuBar handles + // Alt toggle — re-hiding here would break that). Fixes: #321 + const originalSetAppMenu = OriginalMenu.setApplicationMenu.bind(OriginalMenu); + patchedSetApplicationMenu = function(menu) { + console.log('[Frame Fix] Intercepting setApplicationMenu'); + originalSetAppMenu(menu); + if (process.platform === 'linux' && MENU_BAR_MODE === 'hidden') { + for (const win of PatchedBrowserWindow.getAllWindows()) { + if (win.isDestroyed()) continue; + win.setMenuBarVisibility(false); + } + console.log('[Frame Fix] Menu bar hidden on all windows'); + } + }; + + // Register Ctrl+Q as a global shortcut to quit the app. + // The upstream menu has CmdOrCtrl+Q but Electron doesn't fire + // menu accelerators when the menu bar is hidden/auto-hide on + // Linux. This ensures Ctrl+Q always works. Fixes: #321 + const registerQuitShortcut = () => { + try { + if (!result.globalShortcut.isRegistered('CommandOrControl+Q')) { + result.globalShortcut.register('CommandOrControl+Q', () => { + console.log('[Frame Fix] Ctrl+Q pressed, quitting'); + result.app.quit(); + }); + console.log('[Frame Fix] Ctrl+Q quit shortcut registered'); + } + } catch (e) { + console.log('[Frame Fix] Failed to register Ctrl+Q shortcut:', e.message); + } + }; + if (result.app.isReady()) { + registerQuitShortcut(); + } else { + result.app.once('ready', registerQuitShortcut); + } + + console.log('[Frame Fix] Patches built successfully'); + } + + // Return a Proxy that intercepts property access on the electron module. + // This is needed because electron's exports use non-configurable getters, + // so we cannot directly reassign module.BrowserWindow. + return new Proxy(result, { + get(target, prop, receiver) { + if (prop === 'BrowserWindow') return PatchedBrowserWindow; + if (prop === 'Menu') { + // Return a proxy for Menu that intercepts setApplicationMenu + const originalMenu = target.Menu; + return new Proxy(originalMenu, { + get(menuTarget, menuProp) { + if (menuProp === 'setApplicationMenu') return patchedSetApplicationMenu; + return Reflect.get(menuTarget, menuProp); + } + }); + } + return Reflect.get(target, prop, receiver); + } + }); + } + + return result; +}; diff --git a/scripts/launcher-common.sh b/scripts/launcher-common.sh new file mode 100755 index 0000000..b265cec --- /dev/null +++ b/scripts/launcher-common.sh @@ -0,0 +1,651 @@ +#!/usr/bin/env bash +# Common launcher functions for Claude Desktop (AppImage and deb) +# This file is sourced by both launchers to avoid code duplication + +# Setup logging directory and file +# Sets: log_dir, log_file +setup_logging() { + log_dir="${XDG_CACHE_HOME:-$HOME/.cache}/claude-desktop-debian" + mkdir -p "$log_dir" || return 1 + log_file="$log_dir/launcher.log" +} + +# Log a message to the log file +# Usage: log_message "message" +log_message() { + echo "$1" >> "$log_file" +} + +# Detect display backend (Wayland vs X11) +# Sets: is_wayland, use_x11_on_wayland +detect_display_backend() { + # Detect if Wayland is running + is_wayland=false + [[ -n "${WAYLAND_DISPLAY:-}" ]] && is_wayland=true + + # Default: Use X11/XWayland on Wayland for global hotkey support + # Set CLAUDE_USE_WAYLAND=1 to use native Wayland (global hotkeys disabled) + use_x11_on_wayland=true + [[ "${CLAUDE_USE_WAYLAND:-}" == '1' ]] && use_x11_on_wayland=false + + # Fixes: #226 - Auto-detect compositors that require native Wayland + # Only Niri is auto-forced: it has no XWayland support. + # Sway and Hyprland have working XWayland, so users on those + # compositors who want native Wayland can set CLAUDE_USE_WAYLAND=1. + # XDG_CURRENT_DESKTOP can be colon-separated (e.g. "niri:GNOME"); + # glob matching with *niri* handles this correctly. + if [[ $is_wayland == true && $use_x11_on_wayland == true ]]; then + local desktop="${XDG_CURRENT_DESKTOP:-}" + desktop="${desktop,,}" + + if [[ -n "${NIRI_SOCKET:-}" || "$desktop" == *niri* ]]; then + log_message "Niri detected - forcing native Wayland" + use_x11_on_wayland=false + fi + fi +} + +# Check if we have a valid display (not running from TTY) +# Returns: 0 if display available, 1 if not +check_display() { + [[ -n $DISPLAY || -n $WAYLAND_DISPLAY ]] +} + +# Build Electron arguments array based on display backend +# Requires: is_wayland, use_x11_on_wayland to be set +# (call detect_display_backend first) +# Sets: electron_args array +# Arguments: $1 = "appimage" or "deb" (affects --no-sandbox behavior) +build_electron_args() { + local package_type="${1:-deb}" + + electron_args=() + + # AppImage always needs --no-sandbox due to FUSE constraints + [[ $package_type == 'appimage' ]] && electron_args+=('--no-sandbox') + + # Disable CustomTitlebar for better Linux integration + electron_args+=('--disable-features=CustomTitlebar') + + # X11 session - no special flags needed + if [[ $is_wayland != true ]]; then + log_message 'X11 session detected' + return + fi + + # Wayland: deb/nix packages need --no-sandbox in both modes + [[ $package_type == 'deb' || $package_type == 'nix' ]] \ + && electron_args+=('--no-sandbox') + + if [[ $use_x11_on_wayland == true ]]; then + # Default: Use X11 via XWayland for global hotkey support + log_message 'Using X11 backend via XWayland (for global hotkey support)' + electron_args+=('--ozone-platform=x11') + else + # Native Wayland mode (user opted in via CLAUDE_USE_WAYLAND=1) + log_message 'Using native Wayland backend (global hotkeys may not work)' + electron_args+=('--enable-features=UseOzonePlatform,WaylandWindowDecorations') + electron_args+=('--ozone-platform=wayland') + electron_args+=('--enable-wayland-ime') + electron_args+=('--wayland-text-input-version=3') + fi +} + +# Kill orphaned cowork-vm-service daemon processes. +# After a crash or unclean shutdown the cowork daemon may outlive the +# main Electron UI process. The orphaned daemon holds LevelDB locks +# in ~/.config/Claude/Local Storage/ which cause new launches to +# detect a "main instance" and silently quit. +# Must run BEFORE cleanup_stale_lock / cleanup_stale_cowork_socket +# so that stale files left behind by the daemon can be cleaned up. +cleanup_orphaned_cowork_daemon() { + local cowork_pids + cowork_pids=$(pgrep -f 'cowork-vm-service\.js' 2>/dev/null) \ + || return 0 + + # Check if a Claude Desktop UI process is also running. + # Any claude-desktop electron process that is NOT the cowork + # daemon indicates the app is alive and the daemon is expected. + local pid cmdline + for pid in $(pgrep -f 'claude-desktop' 2>/dev/null); do + cmdline=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null) \ + || continue + [[ $cmdline == *cowork-vm-service* ]] && continue + # Found a non-daemon claude-desktop process — not orphaned + return 0 + done + + # No UI process found — daemon is orphaned, terminate it + for pid in $cowork_pids; do + kill "$pid" 2>/dev/null || true + done + log_message "Killed orphaned cowork-vm-service daemon (PIDs: $cowork_pids)" +} + +# Clean up stale SingletonLock if the owning process is no longer running. +# Electron uses requestSingleInstanceLock() which silently quits if the lock +# is held. A stale lock (from a crash or unclean update) blocks all launches +# with no user-facing error message. +# The lock is a symlink whose target is "hostname-PID". +cleanup_stale_lock() { + local config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/Claude" + local lock_file="$config_dir/SingletonLock" + + [[ -L $lock_file ]] || return 0 + + local lock_target + lock_target="$(readlink "$lock_file" 2>/dev/null)" || return 0 + + local lock_pid="${lock_target##*-}" + + # Validate that we extracted a numeric PID + [[ $lock_pid =~ ^[0-9]+$ ]] || return 0 + + if kill -0 "$lock_pid" 2>/dev/null; then + # Process is still running — lock is valid + return 0 + fi + + rm -f "$lock_file" + log_message "Removed stale SingletonLock (PID $lock_pid no longer running)" +} + +# Clean up stale cowork-vm-service socket if no daemon is listening. +# The service daemon creates a Unix socket at +# $XDG_RUNTIME_DIR/cowork-vm-service.sock. After a crash or unclean +# shutdown, the socket file persists but nothing is listening, causing +# ECONNREFUSED instead of ENOENT when the app tries to connect. +cleanup_stale_cowork_socket() { + local sock="${XDG_RUNTIME_DIR:-/tmp}/cowork-vm-service.sock" + + [[ -S $sock ]] || return 0 + + if command -v socat &>/dev/null; then + # Try connecting — if refused, the socket is stale + if socat -u OPEN:/dev/null UNIX-CONNECT:"$sock" 2>/dev/null; then + return 0 + fi + else + # No socat: fall back to age-based check (>24h = stale) + if [[ -z $(find "$sock" -mmin +1440 2>/dev/null) ]]; then + return 0 + fi + log_message "No socat available; removing old socket (>24h)" + fi + + rm -f "$sock" + log_message "Removed stale cowork-vm-service socket" +} + +# Set common environment variables +setup_electron_env() { + # ELECTRON_FORCE_IS_PACKAGED makes app.isPackaged return true, which + # causes the Claude app to resolve resources via process.resourcesPath. + # The Nix derivation creates a custom Electron tree with the binary + # copied and app resources co-located in resources/, so resourcesPath + # naturally points to the right place on all package types. + export ELECTRON_FORCE_IS_PACKAGED=true + export ELECTRON_USE_SYSTEM_TITLE_BAR=1 +} + +#=============================================================================== +# Doctor Diagnostics +#=============================================================================== + +# Color helpers (disabled when stdout is not a terminal) +_doctor_colors() { + if [[ -t 1 ]]; then + _green='\033[0;32m' + _red='\033[0;31m' + _yellow='\033[0;33m' + _bold='\033[1m' + _reset='\033[0m' + else + _green='' _red='' _yellow='' _bold='' _reset='' + fi +} + +# Return the distro ID from /etc/os-release +_cowork_distro_id() { + local id='unknown' + if [[ -f /etc/os-release ]]; then + local line + while IFS= read -r line; do + if [[ $line == ID=* ]]; then + id="${line#ID=}" + id="${id//\"/}" + break + fi + done < /etc/os-release + fi + printf '%s' "$id" +} + +# Return a distro-specific install command for a cowork tool +# Usage: _cowork_pkg_hint +_cowork_pkg_hint() { + local distro="$1" + local tool="$2" + local pkg_cmd + + # Determine package manager command + case "$distro" in + debian|ubuntu) pkg_cmd='sudo apt install' ;; + fedora) pkg_cmd='sudo dnf install' ;; + arch) pkg_cmd='sudo pacman -S' ;; + *) + printf '%s' "Install $tool using your package manager" + return + ;; + esac + + # Map tool name to distro-specific package(s) + local pkg + case "$tool" in + qemu) + case "$distro" in + debian|ubuntu) pkg='qemu-system-x86 qemu-utils' ;; + fedora) pkg='qemu-kvm qemu-img' ;; + arch) pkg='qemu-full' ;; + esac + ;; + *) pkg="$tool" ;; + esac + + printf '%s' "$pkg_cmd $pkg" +} + +_pass() { echo -e "${_green}[PASS]${_reset} $*"; } +_fail() { + echo -e "${_red}[FAIL]${_reset} $*" + _doctor_failures=$((_doctor_failures + 1)) +} +_warn() { echo -e "${_yellow}[WARN]${_reset} $*"; } +_info() { echo -e " $*"; } + +# Run all diagnostic checks and print results +# Arguments: $1 = electron path (optional, for package-specific checks) +run_doctor() { + local electron_path="${1:-}" + local _doctor_failures=0 + _doctor_colors + + echo -e "${_bold}Claude Desktop Diagnostics${_reset}" + echo '================================' + echo + + # -- Installed package version -- + if command -v dpkg-query &>/dev/null; then + local pkg_version + pkg_version=$(dpkg-query -W -f='${Version}' \ + claude-desktop 2>/dev/null) || true + if [[ -n $pkg_version ]]; then + _pass "Installed version: $pkg_version" + else + _warn 'claude-desktop not found via dpkg (AppImage?)' + fi + fi + + # -- Display server -- + if [[ -n "${WAYLAND_DISPLAY:-}" ]]; then + _pass "Display server: Wayland (WAYLAND_DISPLAY=$WAYLAND_DISPLAY)" + local desktop="${XDG_CURRENT_DESKTOP:-unknown}" + _info "Desktop: $desktop" + if [[ "${CLAUDE_USE_WAYLAND:-}" == '1' ]]; then + _info 'Mode: native Wayland (CLAUDE_USE_WAYLAND=1)' + else + _info 'Mode: X11 via XWayland (default, for global hotkey support)' + _info 'Tip: Set CLAUDE_USE_WAYLAND=1 for native Wayland' + _info ' (disables global hotkeys)' + fi + elif [[ -n "${DISPLAY:-}" ]]; then + _pass "Display server: X11 (DISPLAY=$DISPLAY)" + else + _fail "No display server detected" \ + "(DISPLAY and WAYLAND_DISPLAY are unset)" + _info 'Fix: Run from within an X11 or Wayland session, not a TTY' + fi + + # -- Menu bar mode -- + local menu_bar_mode="${CLAUDE_MENU_BAR:-}" + if [[ -n $menu_bar_mode ]]; then + local resolved_mode="${menu_bar_mode,,}" + # Resolve boolean-style aliases + case "$resolved_mode" in + 1|true|yes|on) resolved_mode='visible' ;; + 0|false|no|off) resolved_mode='hidden' ;; + esac + case "$resolved_mode" in + auto|visible|hidden) + _pass "Menu bar mode: $resolved_mode" \ + "(CLAUDE_MENU_BAR=$menu_bar_mode)" + ;; + *) + _warn "Unknown CLAUDE_MENU_BAR: '$menu_bar_mode'" + _info 'Will fall back to auto' + _info 'Valid values: auto, visible, hidden' \ + '(or 0/1/true/false/yes/no/on/off)' + ;; + esac + else + _info 'Menu bar mode: auto (default, Alt toggles visibility)' + fi + + # -- Electron binary -- + if [[ -n $electron_path && -x $electron_path ]]; then + # Use --no-sandbox and strip ANSI/app output to get just the version + local electron_version + electron_version=$( + "$electron_path" --no-sandbox --version 2>/dev/null \ + | head -1 \ + | sed 's/\x1b\[[0-9;]*m//g' + ) || true + # Only accept version strings that look like "vNN.NN.NN" + if [[ $electron_version =~ ^v[0-9]+\.[0-9]+ ]]; then + _pass "Electron: $electron_version ($electron_path)" + else + _pass "Electron: found at $electron_path" + fi + elif [[ -n $electron_path ]]; then + _fail "Electron binary not found at $electron_path" + _info 'Fix: Reinstall claude-desktop package' + elif command -v electron &>/dev/null; then + local sys_electron_ver + sys_electron_ver=$(electron --version 2>/dev/null) || true + _pass "Electron: ${sys_electron_ver:-found} (system)" + else + _fail 'Electron binary not found' + _info 'Fix: Reinstall claude-desktop package' + fi + + # -- Chrome sandbox permissions -- + local sandbox_paths=( + '/usr/lib/claude-desktop/node_modules/electron/dist/chrome-sandbox' + ) + # Also check relative to the provided electron path + if [[ -n $electron_path ]]; then + local electron_dir + electron_dir=$(dirname "$electron_path") + sandbox_paths+=("$electron_dir/chrome-sandbox") + fi + local sandbox_checked=false + for sandbox_path in "${sandbox_paths[@]}"; do + if [[ -f $sandbox_path ]]; then + sandbox_checked=true + local sandbox_perms sandbox_owner + sandbox_perms=$(stat -c '%a' "$sandbox_path" 2>/dev/null) || true + sandbox_owner=$(stat -c '%U' "$sandbox_path" 2>/dev/null) || true + if [[ $sandbox_perms == '4755' && $sandbox_owner == 'root' ]]; then + _pass "Chrome sandbox: permissions OK ($sandbox_path)" + else + _fail "Chrome sandbox: perms=${sandbox_perms:-?},\ + owner=${sandbox_owner:-?}" + _info "Fix: sudo chown root:root $sandbox_path" + _info " sudo chmod 4755 $sandbox_path" + fi + break + fi + done + if [[ $sandbox_checked == false ]]; then + _warn 'Chrome sandbox not found (expected for AppImage)' + fi + + # -- SingletonLock -- + local config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/Claude" + local lock_file="$config_dir/SingletonLock" + if [[ -L $lock_file ]]; then + local lock_target lock_pid + lock_target="$(readlink "$lock_file" 2>/dev/null)" || true + lock_pid="${lock_target##*-}" + if [[ $lock_pid =~ ^[0-9]+$ ]] && kill -0 "$lock_pid" 2>/dev/null; then + _pass "SingletonLock: held by running process (PID $lock_pid)" + else + _warn "SingletonLock: stale lock found" \ + "(PID $lock_pid is not running)" + _info "Fix: rm '$lock_file'" + fi + else + _pass 'SingletonLock: no lock file (OK)' + fi + + # -- MCP config -- + local mcp_config="$config_dir/claude_desktop_config.json" + if [[ -f $mcp_config ]]; then + if command -v python3 &>/dev/null; then + if python3 -c \ + "import json,sys; json.load(open(sys.argv[1]))" \ + "$mcp_config" 2>/dev/null; then + _pass "MCP config: valid JSON ($mcp_config)" + # Check if any MCP servers are configured + local server_count + server_count=$(python3 -c " +import json,sys +with open(sys.argv[1]) as f: + cfg = json.load(f) +servers = cfg.get('mcpServers', {}) +print(len(servers)) +" "$mcp_config" 2>/dev/null) || server_count='0' + _info "MCP servers configured: $server_count" + else + _fail "MCP config: invalid JSON" + _info "Fix: Check $mcp_config for syntax errors" + _info "Tip: python3 -m json.tool '$mcp_config' to see the error" + fi + elif command -v node &>/dev/null; then + if node -e \ + "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))" \ + "$mcp_config" 2>/dev/null; then + _pass "MCP config: valid JSON ($mcp_config)" + else + _fail "MCP config: invalid JSON" + _info "Fix: Check $mcp_config for syntax errors" + fi + else + _warn "MCP config: exists but cannot validate" \ + "(no python3 or node available)" + fi + else + _info "MCP config: not found at $mcp_config (OK if not using MCP)" + fi + + # -- Node.js (needed by MCP servers) -- + if command -v node &>/dev/null; then + local node_version + node_version=$(node --version 2>/dev/null) || true + local node_major="${node_version#v}" + node_major="${node_major%%.*}" + if ((node_major >= 20)); then + _pass "Node.js: $node_version" + elif ((node_major >= 1)); then + _warn "Node.js: $node_version (v20+ recommended for MCP servers)" + _info 'Fix: Update Node.js to v20 or later' + fi + _info "Path: $(command -v node)" + else + _warn 'Node.js: not found (required for MCP servers)' + _info 'Fix: Install Node.js v20+ from https://nodejs.org' + fi + + # -- Desktop integration -- + local desktop_file='/usr/share/applications/claude-desktop.desktop' + if [[ -f $desktop_file ]]; then + _pass "Desktop entry: $desktop_file" + else + _warn 'Desktop entry not found (expected for AppImage installs)' + fi + + # -- Disk space -- + local config_disk_avail + config_disk_avail=$(df -BM --output=avail "$config_dir" 2>/dev/null \ + | tail -1 | tr -d ' M') || true + if [[ -n $config_disk_avail ]]; then + if ((config_disk_avail < 100)); then + _fail "Disk space: ${config_disk_avail}MB free on config partition" + _info 'Fix: Free up disk space' + elif ((config_disk_avail < 500)); then + _warn "Disk space: ${config_disk_avail}MB free" \ + "on config partition (low)" + else + _pass "Disk space: ${config_disk_avail}MB free" + fi + fi + + # -- Cowork Mode -- + echo + echo -e "${_bold}Cowork Mode${_reset}" + echo '----------------' + + # Detect distro for package hints + local _distro_id + _distro_id=$(_cowork_distro_id) + + # Bubblewrap (default backend) + if command -v bwrap &>/dev/null; then + _pass 'bubblewrap: found' + else + _warn 'bubblewrap: not found' + _info \ + "Fix: $(_cowork_pkg_hint "$_distro_id" bubblewrap)" + fi + + # Warn on missing KVM deps only when explicitly requested; + # otherwise just inform since bwrap is the default. + local _kvm_active=false + [[ ${COWORK_VM_BACKEND-} == [Kk][Vv][Mm] ]] && _kvm_active=true + local _kvm_issue=_info + $_kvm_active && _kvm_issue=_warn + + # KVM backend (opt-in via COWORK_VM_BACKEND=kvm) + if [[ -e /dev/kvm ]]; then + if [[ -r /dev/kvm && -w /dev/kvm ]]; then + _pass 'KVM: accessible' + else + "$_kvm_issue" 'KVM: /dev/kvm exists but not accessible' + if $_kvm_active; then + _info "Fix: sudo usermod -aG kvm $USER" + _info '(Log out and back in after running this)' + fi + fi + else + "$_kvm_issue" 'KVM: not available' + if $_kvm_active; then + _info \ + 'Fix: Install qemu-kvm and ensure KVM is enabled in BIOS' + fi + fi + + # vsock module + if [[ -e /dev/vhost-vsock ]]; then + _pass 'vsock: module loaded' + else + "$_kvm_issue" 'vsock: /dev/vhost-vsock not found' + if $_kvm_active; then + _info 'Fix: sudo modprobe vhost_vsock' + fi + fi + + # KVM tools: QEMU, socat, virtiofsd + local _tool_label _tool_bin _tool_pkg + for _tool_label in \ + 'QEMU:qemu-system-x86_64:qemu' \ + 'socat:socat:socat' \ + 'virtiofsd:virtiofsd:virtiofsd' + do + _tool_bin="${_tool_label#*:}" + _tool_pkg="${_tool_bin#*:}" + _tool_bin="${_tool_bin%%:*}" + _tool_label="${_tool_label%%:*}" + + if command -v "$_tool_bin" &>/dev/null; then + _pass "$_tool_label: found" + else + "$_kvm_issue" "$_tool_label: not found" + if $_kvm_active; then + _info \ + "Fix: $(_cowork_pkg_hint "$_distro_id" "$_tool_pkg")" + fi + fi + done + + # VM image + local vm_image + vm_image="${HOME}/.local/share/claude-desktop/vm/rootfs.qcow2" + if [[ -f $vm_image ]]; then + local vm_size + vm_size=$(du -h "$vm_image" 2>/dev/null \ + | cut -f1) || vm_size='unknown size' + _pass "VM image: $vm_size" + else + _info 'VM image: not downloaded yet' + fi + + # Determine active backend (matches daemon's detectBackend()) + local cowork_backend='none (host-direct, no isolation)' + if [[ -n ${COWORK_VM_BACKEND-} ]]; then + case ${COWORK_VM_BACKEND,,} in + kvm) cowork_backend='KVM (full VM isolation, via override)' ;; + bwrap) cowork_backend='bubblewrap (namespace sandbox, via override)' ;; + host) cowork_backend='host-direct (no isolation, via override)' ;; + esac + elif command -v bwrap &>/dev/null \ + && bwrap --ro-bind / / true &>/dev/null; then + cowork_backend='bubblewrap (namespace sandbox)' + elif [[ -e /dev/kvm ]] \ + && [[ -r /dev/kvm && -w /dev/kvm ]] \ + && command -v qemu-system-x86_64 &>/dev/null \ + && [[ -e /dev/vhost-vsock ]]; then + cowork_backend='KVM (full VM isolation)' + fi + _info "Cowork isolation: $cowork_backend" + + # -- Orphaned cowork daemon -- + local _cowork_pids + _cowork_pids=$(pgrep -f 'cowork-vm-service\.js' 2>/dev/null) \ + || true + if [[ -n $_cowork_pids ]]; then + local _daemon_orphaned=true _pid _cmdline + for _pid in $(pgrep -f 'claude-desktop' 2>/dev/null); do + _cmdline=$(tr '\0' ' ' \ + < "/proc/$_pid/cmdline" 2>/dev/null) || continue + [[ $_cmdline == *cowork-vm-service* ]] && continue + _daemon_orphaned=false + break + done + if [[ $_daemon_orphaned == true ]]; then + _warn "Cowork daemon: orphaned (PIDs: $_cowork_pids)" + _info 'Fix: Restart Claude Desktop' \ + '(daemon will be cleaned up automatically)' + else + _pass 'Cowork daemon: running (parent alive)' + fi + fi + + # -- Log file -- + local log_path + log_path="${XDG_CACHE_HOME:-$HOME/.cache}" + log_path="$log_path/claude-desktop-debian/launcher.log" + if [[ -f $log_path ]]; then + local log_size + log_size=$(stat -c '%s' "$log_path" 2>/dev/null) || log_size=0 + local log_size_kb=$((log_size / 1024)) + if ((log_size_kb > 10240)); then + _warn "Log file: ${log_size_kb}KB" \ + "(consider clearing: rm '$log_path')" + else + _pass "Log file: ${log_size_kb}KB ($log_path)" + fi + else + _info 'Log file: not yet created (OK)' + fi + + # -- Summary -- + echo + if ((_doctor_failures == 0)); then + echo -e "${_green}${_bold}All checks passed.${_reset}" + else + echo -e "${_red}${_bold}${_doctor_failures} check(s) failed.${_reset}" + echo 'See above for fixes.' + fi + + return "$_doctor_failures" +}