#!/usr/bin/env bash # Arguments passed from the main script version="$1" architecture="$2" work_dir="$3" # The top-level build directory (e.g., ./build) app_staging_dir="$4" # Directory containing the prepared app files package_name="$5" maintainer="$6" description="$7" echo '--- Starting Debian Package Build ---' echo "Version: $version" echo "Architecture: $architecture" echo "Work Directory: $work_dir" echo "App Staging Directory: $app_staging_dir" echo "Package Name: $package_name" package_root="$work_dir/package" install_dir="$package_root/usr" # Clean previous package structure if it exists rm -rf "$package_root" # Create Debian package structure echo "Creating package structure in $package_root..." mkdir -p "$package_root/DEBIAN" || exit 1 mkdir -p "$install_dir/lib/$package_name" || exit 1 mkdir -p "$install_dir/share/applications" || exit 1 mkdir -p "$install_dir/share/icons" || exit 1 mkdir -p "$install_dir/bin" || exit 1 # --- Icon Installation --- echo 'Installing icons...' # The official tree ships hicolor PNGs (16-256px). The source basename # stays upstream's claude-desktop.png, but the destination is renamed to # $package_name.png so our files never collide with the icons installed # by Anthropic's official claude-desktop package. official_hicolor="${CLAUDE_EXTRACT_DIR:?}/usr/share/icons/hicolor" found_icons=false for icon_source_path in "$official_hicolor"/*/apps/claude-desktop.png; do [[ -f $icon_source_path ]] || continue size_dir=$(basename "$(dirname "$(dirname "$icon_source_path")")") echo "Installing $size_dir icon..." install -Dm 644 "$icon_source_path" \ "$install_dir/share/icons/hicolor/$size_dir/apps/$package_name.png" || exit 1 found_icons=true done if [[ $found_icons == false ]]; then echo "Warning: no hicolor icons found under $official_hicolor" fi echo 'Icons installed' # --- Copy Application Files --- echo "Copying application files from $app_staging_dir..." # The staging dir is the extracted official usr/lib/claude-desktop tree # (Electron ELF, chrome-sandbox, resources/, locales/, ...); ship it as-is. cp -a "$app_staging_dir/." "$install_dir/lib/$package_name/" || exit 1 echo 'Official application tree copied' # Copy shared launcher library (launcher-common.sh sources doctor.sh # at runtime, so both must live in the same directory) script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cp "$(dirname "$script_dir")/launcher-common.sh" "$install_dir/lib/$package_name/" || exit 1 sed -i "s/@@WM_CLASS@@/$WM_CLASS/" "$install_dir/lib/$package_name/launcher-common.sh" cp "$(dirname "$script_dir")/doctor.sh" "$install_dir/lib/$package_name/" || exit 1 echo 'Shared launcher library + doctor copied' # --- Create Desktop Entry --- echo 'Creating desktop entry...' cat > "$install_dir/share/applications/$package_name.desktop" << EOF [Desktop Entry] Name=Claude Exec=/usr/bin/$package_name %u Icon=$package_name Type=Application Terminal=false Categories=Office;Utility; MimeType=x-scheme-handler/claude; StartupWMClass=$WM_CLASS EOF echo 'Desktop entry created' # --- Install AppStream metainfo (App Center / GNOME Software / KDE Discover) --- echo 'Installing AppStream metainfo...' metainfo_name='io.github.aaddrick.claude-desktop-unofficial.metainfo.xml' install -Dm 644 "$script_dir/$metainfo_name" \ "$install_dir/share/metainfo/$metainfo_name" || exit 1 echo 'AppStream metainfo installed' # --- Create Launcher Script --- echo 'Creating launcher script...' cat > "$install_dir/bin/$package_name" << EOF #!/usr/bin/env bash # Source shared launcher library source "/usr/lib/$package_name/launcher-common.sh" # The official Electron binary; it auto-loads the co-located # resources/app.asar, so no app path is ever passed (issue #696). app_exec="/usr/lib/$package_name/claude-desktop" # Handle --doctor flag before anything else if [[ "\${1:-}" == '--doctor' ]]; then run_doctor "\$app_exec" exit \$? fi # --version never reaches the terminal via Electron: the launcher # redirects all app output to the log (#772). Answer it here instead. if [[ "\${1:-}" == '--version' ]]; then echo "$package_name $version" exit 0 fi # Setup logging and environment setup_logging || exit 1 setup_electron_env cleanup_orphaned_cowork_daemon cleanup_stale_desktop_helpers cleanup_stale_lock cleanup_stale_cowork_socket heal_autostart_entry "/usr/bin/$package_name" backup_user_config # Log startup info log_message '--- Claude Desktop Launcher Start ---' log_message "Timestamp: \$(date)" log_message "Arguments: \$@" log_session_env # Check for display if ! check_display; then log_message 'No display detected (TTY session)' echo 'Error: Claude Desktop requires a graphical desktop environment.' >&2 echo 'Please run from within an X11 or Wayland session, not from a TTY.' >&2 exit 1 fi # Detect display backend detect_display_backend if [[ \$is_wayland == true ]]; then log_message 'Wayland detected' fi if [[ ! -x \$app_exec ]]; then log_message "Error: Claude Desktop binary not found at \$app_exec" echo "Error: Claude Desktop binary not found at \$app_exec" >&2 exit 1 fi # Build Chromium switches for the official binary build_electron_args 'deb' # Change to application directory app_dir="/usr/lib/$package_name" log_message "Changing directory to \$app_dir" cd "\$app_dir" || { log_message "Failed to cd to \$app_dir"; exit 1; } # Execute the official binary and keep the launcher alive so explicit # quit can clean up Desktop-owned helpers that outlive the main process. log_message "Executing: \$app_exec \${electron_args[*]} \$*" run_electron_and_cleanup "\$app_exec" "\${electron_args[@]}" "\$@" exit \$? EOF chmod +x "$install_dir/bin/$package_name" || exit 1 echo 'Launcher script created' # --- Create Control File --- echo 'Creating control file...' # Depends/Recommends are re-emitted verbatim from the extracted official # control file (exported by build.sh): the contract differs per arch # (arm64 recommends qemu-system-arm/qemu-efi-aarch64 instead of # qemu-system-x86/ovmf) and tracks upstream automatically this way. { echo "Package: $package_name" echo "Version: $version" echo 'Section: utils' echo 'Priority: optional' echo "Architecture: $architecture" # The 'claude-desktop' name is shared by two other packages: our own # legacy repack (<= 1.15200.x) and Anthropic's official package # (>= 1.17377.1). The relation must stay version-scoped to hit only # the legacy repack — unscoped, apt would remove the official package # on install. The bound also sits below the 1.16000.0-1 transitional # dummy built at the end of this script, so the dummy and this # package can coexist during the rename upgrade. echo 'Conflicts: claude-desktop (<< 1.16000)' echo 'Replaces: claude-desktop (<< 1.16000)' if [[ -n ${OFFICIAL_DEB_DEPENDS:-} ]]; then echo "Depends: $OFFICIAL_DEB_DEPENDS" fi if [[ -n ${OFFICIAL_DEB_RECOMMENDS:-} ]]; then echo "Recommends: $OFFICIAL_DEB_RECOMMENDS" fi echo "Maintainer: $maintainer" echo "Description: $description" echo ' Claude is an AI assistant from Anthropic.' echo ' This package provides the desktop interface for Claude.' echo ' .' echo ' Supported on Debian-based Linux distributions (Debian, Ubuntu, Linux Mint, MX Linux, etc.)' } > "$package_root/DEBIAN/control" echo 'Control file created' # --- Create Postinst Script --- echo 'Creating postinst script...' cat > "$package_root/DEBIAN/postinst" << EOF #!/bin/sh set -e # Update desktop database for MIME types echo "Updating desktop database..." update-desktop-database /usr/share/applications > /dev/null 2>&1 || true # Set correct permissions for chrome-sandbox. The official data.tar # records it SUID, but our non-root ar|tar extraction strips the bit, so # it must be re-asserted here. echo "Setting chrome-sandbox permissions..." SANDBOX_PATH="" LOCAL_SANDBOX_PATH="/usr/lib/$package_name/chrome-sandbox" if [ -f "\$LOCAL_SANDBOX_PATH" ]; then SANDBOX_PATH="\$LOCAL_SANDBOX_PATH" fi if [ -n "\$SANDBOX_PATH" ] && [ -f "\$SANDBOX_PATH" ]; then echo "Found chrome-sandbox at: \$SANDBOX_PATH" chown root:root "\$SANDBOX_PATH" || echo "Warning: Failed to chown chrome-sandbox" chmod 4755 "\$SANDBOX_PATH" || echo "Warning: Failed to chmod chrome-sandbox" echo "Permissions set for \$SANDBOX_PATH" else echo "Warning: chrome-sandbox binary not found in local package at \$LOCAL_SANDBOX_PATH. Sandbox may not function correctly." fi # --- AppArmor profile for Chromium's user-namespace sandbox --- # Ubuntu 24.04+ sets kernel.apparmor_restrict_unprivileged_userns=1, which # blocks the unprivileged user namespaces Chromium's sandbox relies on, # crashing the app on launch with a sandbox/.../credentials.cc FATAL. # Grant userns to our Electron binary via a scoped AppArmor profile, exactly # as the google-chrome, code, and slack packages do. Gate on the kernel knob # (not just apparmor_parser): only Ubuntu-family systems impose the # restriction, so on stock Debian/others the knob is absent and we skip the # profile entirely rather than installing one they never need. The knob may # read 0 now and flip to 1 later, so existence — not value — is the gate. APPARMOR_PROFILE="/etc/apparmor.d/$package_name" if command -v apparmor_parser >/dev/null 2>&1 \ && [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then echo "Configuring AppArmor profile for Chromium sandbox..." # Writing the profile is best-effort: a read-only or atypical /etc must # never abort the install (this postinst runs under set -e). Keeping the # grep / mkdir + heredoc in the if/elif conditions exempts them from # errexit. Debian Policy 10.7.3: a profile without our marker header was # hand-created or hand-edited by the admin — preserve it, never overwrite. if [ -e "\$APPARMOR_PROFILE" ] \ && ! grep -qF "managed by the $package_name package" \ "\$APPARMOR_PROFILE" 2>/dev/null; then echo "Preserving locally modified \$APPARMOR_PROFILE (no marker header)" apparmor_parser -r "\$APPARMOR_PROFILE" >/dev/null 2>&1 || true elif mkdir -p /etc/apparmor.d 2>/dev/null && cat > "\$APPARMOR_PROFILE" <<'APPARMOR_EOF' # This profile is managed by the $package_name package (postinst); direct # edits will be overwritten on upgrade. Put local changes in # /etc/apparmor.d/local/$package_name instead. abi , include profile $package_name /usr/lib/$package_name/claude-desktop flags=(unconfined) { userns, include if exists } APPARMOR_EOF then if apparmor_parser -Q "\$APPARMOR_PROFILE" >/dev/null 2>&1; then apparmor_parser -r "\$APPARMOR_PROFILE" >/dev/null 2>&1 || echo "Note: AppArmor profile staged but not loaded now; it will apply on the next AppArmor reload or reboot." echo "AppArmor profile installed at \$APPARMOR_PROFILE" else rm -f "\$APPARMOR_PROFILE" echo "AppArmor on this system does not support the userns rule; skipping profile (not required here)." fi else # A failed write may leave a truncated profile behind; clear it. # The || true is mandatory: this branch is errexit-live, and a bare # rm fails the upgrade on a read-only /etc. rm -f "\$APPARMOR_PROFILE" 2>/dev/null || true echo "Warning: could not write \$APPARMOR_PROFILE; skipping AppArmor profile." fi fi exit 0 EOF chmod +x "$package_root/DEBIAN/postinst" || exit 1 echo 'Postinst script created' # --- Create Postrm Script --- echo 'Creating postrm script...' # The AppArmor profiles are generated by postinst, not tracked by dpkg, so we # unload and delete them ourselves. Cleanup lives in postrm (not prerm) so it # also fires on purge and abort-install. Skip on upgrade — the incoming # postinst rewrites and reloads them. 'disappear' is deliberately not handled: # matching it would also clean during the overwrite-by-another-package flow. # Three profile names: the Electron one (Chromium sandbox, #687), the bwrap # one (Cowork sandbox helper, #694), and the legacy pre-rename names. The # bwrap profile is no longer installed since the official-deb rebase parked # the bwrap Cowork backend, but 2.x installs left it behind (as # 'claude-desktop-bwrap', before the rename) — keep removing it here. # The legacy 'claude-desktop' Electron profile needs care: Anthropic's # official package postinst also writes /etc/apparmor.d/claude-desktop # (untracked by dpkg, so ownership can't be queried) — only our marker # header proves the file came from our legacy 2.x postinst. Markerless # files (official's, admin-created, or pre-v2.0.19 ours) are preserved. # Per Debian Policy 10.7.3 the profiles are configuration: unload them # whenever the confined binaries go away, but delete the files only on # purge — a profile for an absent binary is a harmless no-op (google-chrome # leaves its profile behind the same way). cat > "$package_root/DEBIAN/postrm" << EOF #!/bin/sh set -e case "\$1" in remove|purge|abort-install) for _profile in "/etc/apparmor.d/$package_name" \ "/etc/apparmor.d/${package_name}-bwrap" \ "/etc/apparmor.d/claude-desktop-bwrap"; do if [ -e "\$_profile" ] \ && command -v apparmor_parser >/dev/null 2>&1; then apparmor_parser -R "\$_profile" >/dev/null 2>&1 || true fi # Policy 10.7.3: config survives remove; delete on purge only. if [ "\$1" = purge ]; then rm -f "\$_profile" 2>/dev/null || true fi done # Legacy 2.x profile from before the rename to $package_name. # The official claude-desktop package writes the same path from # its postinst; touch it only when our marker header proves our # legacy postinst wrote it. _legacy='/etc/apparmor.d/claude-desktop' if [ -e "\$_legacy" ] \ && grep -qF 'managed by the claude-desktop package' \ "\$_legacy" 2>/dev/null; then if command -v apparmor_parser >/dev/null 2>&1; then apparmor_parser -R "\$_legacy" >/dev/null 2>&1 || true fi if [ "\$1" = purge ]; then rm -f "\$_legacy" 2>/dev/null || true fi fi ;; esac exit 0 EOF chmod +x "$package_root/DEBIAN/postrm" || exit 1 echo 'Postrm script created' # --- Build .deb Package --- echo 'Building .deb package...' deb_file="$work_dir/${package_name}_${version}_${architecture}.deb" # Fix DEBIAN directory permissions (must be 755 for dpkg-deb) echo 'Setting DEBIAN directory permissions...' chmod 755 "$package_root/DEBIAN" || exit 1 # Fix script permissions in DEBIAN directory echo 'Setting script permissions...' chmod 755 "$package_root/DEBIAN/postinst" || exit 1 chmod 755 "$package_root/DEBIAN/postrm" || exit 1 # Normalize the installed tree before building. A restrictive build umask # can leave directories at 0700, and dpkg-deb records file ownership # verbatim unless told otherwise. Both bite at runtime: the launcher runs # as the desktop user, who then can't traverse into app.asar.unpacked/ — # silently breaking Cowork's daemon auto-launch (the fork is guarded by # fs.existsSync(), which returns false on a directory it can't read, so # the symptom is an endless connect ENOENT on the VM-service socket with # no daemon log and no [cowork-autolaunch] line). Canonical modes: dirs # and already-executable files 755, every other file 644. The blanket # pass clears chrome-sandbox's setuid bit, but postinst re-asserts 4755 # after install, so the net result is unchanged. echo 'Normalizing installed tree permissions...' find "$install_dir" -type d -exec chmod 755 {} + || exit 1 find "$install_dir" -type f -exec chmod u=rwX,go=rX {} + || exit 1 # --root-owner-group forces root:root in the archive so a leaked build # uid can't deny access on the installed system (the build does not run # under fakeroot). if ! dpkg-deb --root-owner-group --build "$package_root" "$deb_file"; then echo 'Failed to build .deb package' >&2 exit 1 fi echo "Deb package built successfully: $deb_file" # --- Build Transitional Dummy Package --- # The package renamed from 'claude-desktop' to claude-desktop-unofficial # when Anthropic's official package claimed the old name. This # control-only dummy walks legacy repack installs (<= 1.15200.x) over # the rename: apt upgrades the old 'claude-desktop' to it, and its # Depends pulls in the real package. Version 1.16000.0-1 sits above # every legacy repack and below the first official build (1.17377.1), # so the official package still upgrades cleanly over the dummy. # Architecture: all — built on the amd64 leg only so the arm64 CI leg # doesn't emit a duplicate artifact. build.sh moves it next to the # main .deb (the filename is referenced there; keep them in sync). if [[ $architecture == 'amd64' ]]; then echo 'Building transitional dummy package...' transitional_root="$work_dir/transitional-package" transitional_deb="$work_dir/claude-desktop_1.16000.0-1_all.deb" rm -rf "$transitional_root" mkdir -p "$transitional_root/DEBIAN" || exit 1 { echo 'Package: claude-desktop' echo 'Version: 1.16000.0-1' echo 'Architecture: all' echo "Depends: $package_name" echo 'Section: oldlibs' echo 'Priority: optional' echo "Maintainer: $maintainer" echo 'Description: Transitional package for the rename to claude-desktop-unofficial' echo " This dummy package eases upgrades from the legacy 'claude-desktop'" echo " community repack to its new name, $package_name." echo ' It can be safely removed after the upgrade.' } > "$transitional_root/DEBIAN/control" chmod 755 "$transitional_root/DEBIAN" || exit 1 if ! dpkg-deb --root-owner-group --build \ "$transitional_root" "$transitional_deb"; then echo 'Failed to build transitional .deb package' >&2 exit 1 fi echo "Transitional package built successfully: $transitional_deb" fi echo '--- Debian Package Build Finished ---' exit 0