mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-05-17 08:36:35 +03:00
The cowork manifest patch (Patch 4) copied win32 file entries as linux entries. Since Anthropic now publishes Linux-specific VM images with different content, the win32 checksums cause silent validation failures and startVM timeouts. Compute correct SHA-256 checksums for the linux CDN files and embed them in build.sh. Patch 4 now replaces win32 checksums with the linux values before injecting the manifest entry. Falls back to win32 values if linux checksums are empty. The check-claude-version CI workflow is extended to automatically recompute VM checksums when a new version is detected. This is non-blocking — if CDN files aren't published yet or computation fails, the rest of the workflow proceeds unaffected. Fixes #329 Co-Authored-By: Claude <claude@anthropic.com>
1930 lines
69 KiB
Bash
Executable File
1930 lines
69 KiB
Bash
Executable File
#!/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.1.7714/Claude-3bd6f69326a0abac98bb269c29140e2a543cad64.exe'
|
|
claude_exe_sha256=''
|
|
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.1.7714/Claude-3bd6f69326a0abac98bb269c29140e2a543cad64.exe'
|
|
claude_exe_sha256=''
|
|
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'
|
|
|
|
# VM bundle checksums — SHA-256 of decompressed linux CDN files.
|
|
# Updated automatically by check-claude-version.yml when the
|
|
# bundle SHA changes. Used in patch_cowork_linux() Patch 4 to
|
|
# inject correct linux manifest entries. If empty, Patch 4 falls
|
|
# back to copying win32 checksums (which may not match).
|
|
vm_checksum_x64_rootfs_vhdx='a829fe446f24d5e49dcca7f4c61042cf79bc53197d06c582f3fac69282131410'
|
|
vm_checksum_x64_vmlinuz='9ab0e4031fbdf90c5133a18c0ab399e9abc0a5935777ac5b29c1e26dba8b6596'
|
|
vm_checksum_x64_initrd='032214290388d688790d7b169575a5f75693543b21b33869423713411c12bd6d'
|
|
vm_checksum_arm64_rootfs_vhdx='d6eb347a6914839514c42b262baeba48df81517cd7fdfbeb0e435dd98cbb7105'
|
|
vm_checksum_arm64_vmlinuz='ce876786f908390c65a8a58a442399a864875e13eae8da5e4ebc8c0255772515'
|
|
vm_checksum_arm64_initrd='c75a1aff9719bf09527a8f9f055b3fb6e2c0633973efc8295cf07e46bb6333e1'
|
|
}
|
|
|
|
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" \
|
|
VM_CS_X64_ROOTFS="$vm_checksum_x64_rootfs_vhdx" \
|
|
VM_CS_X64_VMLINUZ="$vm_checksum_x64_vmlinuz" \
|
|
VM_CS_X64_INITRD="$vm_checksum_x64_initrd" \
|
|
VM_CS_ARM64_ROOTFS="$vm_checksum_arm64_rootfs_vhdx" \
|
|
VM_CS_ARM64_VMLINUZ="$vm_checksum_arm64_vmlinuz" \
|
|
VM_CS_ARM64_INITRD="$vm_checksum_arm64_initrd" \
|
|
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(
|
|
'(?<!\\|\\|process\\.platform==="linux"\\))' +
|
|
win32Var.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
|
|
'(\\s*\\?\\s*"vmClient \\(TypeScript\\)")'
|
|
);
|
|
if (logRe.test(code)) {
|
|
code = code.replace(logRe,
|
|
'(' + win32Var + '||process.platform==="linux")$1');
|
|
console.log(' Patched VM client log check for Linux');
|
|
patchCount++;
|
|
}
|
|
|
|
// 2b: Patch the actual module assignment
|
|
// Beautified: WIN32VAR ? (df = { vm: bYe }) : (df = ...)
|
|
// Minified: WIN32VAR?df={vm:bYe}:df=...
|
|
// Handle both: outer parens are optional in minified code
|
|
const assignRe = new RegExp(
|
|
'(?<!\\|\\|process\\.platform==="linux"\\)?)' +
|
|
win32Var.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
|
|
'(\\s*\\?\\s*\\(?\\s*\\w+\\s*=\\s*\\{\\s*vm\\s*:\\s*\\w+\\s*\\}\\s*\\)?)'
|
|
);
|
|
if (assignRe.test(code)) {
|
|
code = code.replace(assignRe,
|
|
'(' + win32Var + '||process.platform==="linux")$1');
|
|
console.log(' Patched VM module assignment for Linux');
|
|
patchCount++;
|
|
}
|
|
} else {
|
|
console.log(' WARNING: Could not find vmClient variable for module loading patch');
|
|
}
|
|
|
|
// ============================================================
|
|
// Patch 3: Socket path - use Unix domain socket on Linux
|
|
// Anchor: unique string "cowork-vm-service" in pipe path
|
|
// ============================================================
|
|
const pipeMatch = code.match(/(\w+)(\s*=\s*)"([^"]*\\\\[^"]*cowork-vm-service[^"]*)"/);
|
|
if (pipeMatch) {
|
|
const pipeVar = pipeMatch[1];
|
|
const assign = pipeMatch[2];
|
|
const pipeStr = pipeMatch[3];
|
|
const oldExpr = pipeVar + assign + '"' + pipeStr + '"';
|
|
const newExpr = pipeVar + assign +
|
|
'process.platform==="linux"?' +
|
|
'(process.env.XDG_RUNTIME_DIR||"/tmp")+"/cowork-vm-service.sock"' +
|
|
':"' + pipeStr + '"';
|
|
code = code.replace(oldExpr, newExpr);
|
|
console.log(' Patched socket path for Linux Unix domain socket');
|
|
patchCount++;
|
|
} else {
|
|
console.log(' WARNING: Could not find pipe path for socket patch');
|
|
}
|
|
|
|
// ============================================================
|
|
// Patch 4: Bundle manifest - add Linux entries to Ln.files
|
|
// Anchor: find files:{darwin: near rootfs.img checksum pattern
|
|
// Extracts the win32 file entries (rootfs.vhdx, vmlinuz, initrd)
|
|
// for structure (names, progressStart/End), then replaces the
|
|
// checksums with correct linux values from build-time env vars.
|
|
// Falls back to win32 checksums if linux checksums unavailable.
|
|
// ============================================================
|
|
const linuxChecksums = {
|
|
x64: {
|
|
'rootfs.vhdx': process.env.VM_CS_X64_ROOTFS || '',
|
|
'vmlinuz': process.env.VM_CS_X64_VMLINUZ || '',
|
|
'initrd': process.env.VM_CS_X64_INITRD || '',
|
|
},
|
|
arm64: {
|
|
'rootfs.vhdx': process.env.VM_CS_ARM64_ROOTFS || '',
|
|
'vmlinuz': process.env.VM_CS_ARM64_VMLINUZ || '',
|
|
'initrd': process.env.VM_CS_ARM64_INITRD || '',
|
|
},
|
|
};
|
|
|
|
function replaceChecksums(archArray, arch) {
|
|
const checksums = linuxChecksums[arch];
|
|
const hasChecksums = Object.values(checksums).every(
|
|
v => /^[a-f0-9]{64}$/.test(v));
|
|
if (!hasChecksums) {
|
|
console.log(` WARNING: Missing linux ${arch} checksums,` +
|
|
' using win32 values as fallback');
|
|
return archArray;
|
|
}
|
|
let result = archArray;
|
|
for (const [name, hash] of Object.entries(checksums)) {
|
|
// Match: checksum:"<64-hex>" near name:"<name>"
|
|
// The checksum field follows the name field in each entry
|
|
const nameIdx = result.indexOf('"' + name + '"');
|
|
if (nameIdx === -1) continue;
|
|
const afterName = result.substring(nameIdx);
|
|
const csRe = /checksum\s*:\s*"([a-f0-9]{64})"/;
|
|
const csMatch = afterName.match(csRe);
|
|
if (csMatch) {
|
|
result = result.substring(0, nameIdx) +
|
|
afterName.replace(csMatch[0],
|
|
'checksum:"' + hash + '"');
|
|
}
|
|
}
|
|
console.log(` Replaced ${arch} checksums with linux values`);
|
|
return result;
|
|
}
|
|
|
|
if (!code.includes('"linux":{') && !code.includes("'linux':{") &&
|
|
!code.includes('linux:{')) {
|
|
const shaRe = /sha\s*:\s*"([a-f0-9]{40})"/;
|
|
const shaMatch = code.match(shaRe);
|
|
if (shaMatch) {
|
|
const shaIdx = code.indexOf(shaMatch[0]);
|
|
const afterSha = code.indexOf('files', shaIdx);
|
|
if (afterSha !== -1 && afterSha - shaIdx < 200) {
|
|
const filesBlock = extractBlock(code, afterSha, '{');
|
|
if (filesBlock) {
|
|
const filesEnd = code.indexOf(filesBlock, afterSha)
|
|
+ filesBlock.length;
|
|
|
|
// Extract win32 x64 and arm64 arrays for structure
|
|
let win32x64 = null;
|
|
let win32arm64 = null;
|
|
const win32Idx = filesBlock.indexOf('win32');
|
|
if (win32Idx !== -1) {
|
|
const win32Block =
|
|
extractBlock(filesBlock, win32Idx, '{');
|
|
if (win32Block) {
|
|
const x64Idx = win32Block.indexOf('x64');
|
|
const arm64Idx = win32Block.indexOf('arm64');
|
|
if (x64Idx !== -1) {
|
|
win32x64 =
|
|
extractBlock(win32Block, x64Idx, '[');
|
|
}
|
|
if (arm64Idx !== -1) {
|
|
win32arm64 =
|
|
extractBlock(win32Block, arm64Idx, '[');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build linux entries with correct checksums
|
|
let linuxX64 = '[]';
|
|
let linuxArm64 = '[]';
|
|
if (win32x64 && win32x64.includes('name')) {
|
|
linuxX64 = replaceChecksums(win32x64, 'x64');
|
|
console.log(' Built linux x64 entries from' +
|
|
' win32 structure');
|
|
}
|
|
if (win32arm64 && win32arm64.includes('name')) {
|
|
linuxArm64 = replaceChecksums(
|
|
win32arm64, 'arm64');
|
|
console.log(' Built linux arm64 entries from' +
|
|
' win32 structure');
|
|
}
|
|
|
|
const insertPos = filesEnd - 1;
|
|
const linuxEntry =
|
|
',linux:{x64:' + linuxX64 +
|
|
',arm64:' + linuxArm64 + '}';
|
|
code = code.substring(0, insertPos) +
|
|
linuxEntry + code.substring(insertPos);
|
|
console.log(' Added Linux entries to bundle' +
|
|
' manifest');
|
|
patchCount++;
|
|
}
|
|
}
|
|
}
|
|
if (!code.includes('linux:{x64:')) {
|
|
console.log(' WARNING: Could not add Linux bundle' +
|
|
' manifest entries');
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Patch 5: MSIX check bypass for Linux
|
|
// The fz() function checks: if(t==="win32"&&!ga()) for MSIX
|
|
// This is already gated to win32, so no change needed.
|
|
// ============================================================
|
|
|
|
// ============================================================
|
|
// Patch 6: Auto-launch service daemon on first connection attempt
|
|
// Anchor: unique string "VM service not running. The service failed to start."
|
|
//
|
|
// The retry loop only retries on ENOENT (socket missing). On Linux,
|
|
// stale sockets from a previous session give ECONNREFUSED instead,
|
|
// which causes an immediate throw with no retry or auto-launch.
|
|
//
|
|
// Fix: patch the ENOENT check to also match ECONNREFUSED on Linux,
|
|
// then inject auto-launch before the retry delay.
|
|
// ============================================================
|
|
const serviceErrorStr = 'VM service not running. The service failed to start.';
|
|
const serviceErrorIdx = code.indexOf(serviceErrorStr);
|
|
if (serviceErrorIdx !== -1) {
|
|
// Step 1: Find the ENOENT check and expand it to include ECONNREFUSED
|
|
// Pattern: VAR.code==="ENOENT"
|
|
// Search backwards from the error string to find it
|
|
const searchStart = Math.max(0, serviceErrorIdx - 300);
|
|
const beforeRegion = code.substring(searchStart, serviceErrorIdx);
|
|
const enoentRe = /(\w+)\.code\s*===\s*"ENOENT"/g;
|
|
let enoentMatch;
|
|
let lastEnoent = null;
|
|
while ((enoentMatch = enoentRe.exec(beforeRegion)) !== null) {
|
|
lastEnoent = enoentMatch;
|
|
}
|
|
if (lastEnoent) {
|
|
const enoentStr = lastEnoent[0];
|
|
const errVar = lastEnoent[1];
|
|
const enoentAbsIdx = searchStart + lastEnoent.index;
|
|
// Replace: VAR.code==="ENOENT"
|
|
// With: (VAR.code==="ENOENT"||process.platform==="linux"&&VAR.code==="ECONNREFUSED")
|
|
const expanded =
|
|
'(' + enoentStr +
|
|
'||process.platform==="linux"&&' + errVar + '.code==="ECONNREFUSED")';
|
|
code = code.substring(0, enoentAbsIdx) +
|
|
expanded +
|
|
code.substring(enoentAbsIdx + enoentStr.length);
|
|
console.log(' Expanded ENOENT check to include ECONNREFUSED on Linux');
|
|
} else {
|
|
console.log(' WARNING: Could not find ENOENT check for ECONNREFUSED expansion');
|
|
}
|
|
|
|
// Step 2: Inject auto-launch before the retry delay
|
|
// Re-find serviceErrorStr since indices shifted after step 1
|
|
const newServiceErrorIdx = code.indexOf(serviceErrorStr);
|
|
const searchEnd = Math.min(code.length, newServiceErrorIdx + 300);
|
|
const searchRegion = code.substring(newServiceErrorIdx, searchEnd);
|
|
const retryMatch = searchRegion.match(
|
|
/await new Promise\((\w+)=>\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.
|
|
// ============================================================
|
|
{
|
|
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) {
|
|
// Scope variables: uX()=arch, Qe=path, i=bundlePath,
|
|
// ft=fs, vg=stream/pipeline, tt=logger
|
|
const linuxBlock =
|
|
'if(process.platform==="linux"){' +
|
|
'const _la=uX(),' +
|
|
'_ls=Qe.join(process.resourcesPath,`smol-bin.${_la}.vhdx`),' +
|
|
'_ld=Qe.join(i,"smol-bin.vhdx");' +
|
|
'ft.existsSync(_ls)?' +
|
|
'(tt.info(`[VM:start] Copying smol-bin.${_la}.vhdx to bundle (Linux)`),' +
|
|
'await vg.pipeline(ft.createReadStream(_ls),ft.createWriteStream(_ld)),' +
|
|
'tt.info(`[VM:start] smol-bin.${_la}.vhdx copied successfully`))' +
|
|
':tt.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)');
|
|
patchCount++;
|
|
} 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
|