# Copyright 2025 Gentoo Authors
# Distributed under the terms of the GNU General Public License v2
EAPI=8
PYTHON_COMPAT=( python3_{11..14} )
inherit desktop git-r3 python-any-r1 xdg
# Pinned prebuilt Electron runtime. The @ant native stubs and node-pty target
# the Electron 41 ABI (see upstream launch.sh), so this must stay on 41.x.
ELECTRON_PV="41.8.0"
DESCRIPTION="Claude Desktop with Cowork (local agent) support, Linux port"
HOMEPAGE="https://github.com/johnzfitch/claude-cowork-linux"
EGIT_REPO_URI="https://github.com/johnzfitch/claude-cowork-linux.git"
SRC_URI="
https://github.com/electron/electron/releases/download/v${ELECTRON_PV}/electron-v${ELECTRON_PV}-linux-x64.zip
-> electron-${ELECTRON_PV}-linux-x64.zip
"
# MIT: the Linux-port glue (stubs, patches, launcher) from this repository.
# all-rights-reserved: the Claude Desktop archive itself is proprietary
# Anthropic software, downloaded from Anthropic's CDN at build time.
LICENSE="MIT all-rights-reserved"
SLOT="0"
KEYWORDS=""
IUSE="+sandbox wayland"
# - network-sandbox: the proprietary Claude Desktop archive is fetched from
# Anthropic's CDN during src_unpack (Anthropic does not permit redistribution
# or mirroring -- see the project's COMPAT.md), so the build needs network.
# - mirror/bindist: never mirror or redistribute the proprietary blob.
# - strip/test: prebuilt binaries; there is no test suite.
RESTRICT="network-sandbox mirror bindist strip test"
# Runtime libraries for the bundled prebuilt Electron (Chromium), mirroring
# www-client/google-chrome, plus the tools the Cowork SDK and the launcher
# shell out to at runtime (curl, zstd, dbus, xdg-utils, bubblewrap).
RDEPEND="
>=app-accessibility/at-spi2-core-2.46.0:2
app-arch/zstd
app-misc/ca-certificates
dev-libs/expat
dev-libs/glib:2
dev-libs/nspr
>=dev-libs/nss-3.26
media-fonts/liberation-fonts
media-libs/alsa-lib
media-libs/mesa[gbm(+)]
net-misc/curl
net-print/cups
sys-apps/dbus
sys-libs/glibc
sys-libs/libcap
x11-libs/cairo
x11-libs/gdk-pixbuf:2
x11-libs/gtk+:3[wayland?]
x11-libs/libdrm
>=x11-libs/libX11-1.5.0
x11-libs/libXcomposite
x11-libs/libXdamage
x11-libs/libXext
x11-libs/libXfixes
x11-libs/libXrandr
x11-libs/libxcb
x11-libs/libxkbcommon
x11-libs/libxshmfence
x11-libs/pango
x11-misc/xdg-utils
wayland? ( dev-libs/wayland )
sandbox? ( sys-apps/bubblewrap )
"
DEPEND=""
# unzip extracts both the Electron release and the macOS .zip; python runs the
# bundled asar extractor and upstream's enable-cowork.py; curl fetches the blob.
BDEPEND="
${PYTHON_DEPS}
app-arch/unzip
net-misc/curl
"
QA_PREBUILT="usr/lib/${PN}/electron/*"
# git-r3 checks the packaging repo out here.
S="${WORKDIR}/${P}"
# Where the app + electron land on the target system.
CLAUDE_INSTALLDIR="/usr/lib/${PN}"
pkg_pretend() {
use amd64 || die "${PN} only provides an amd64 (x86_64) Electron build"
}
pkg_setup() {
python-any-r1_pkg_setup
}
src_unpack() {
# 1) The Linux-port packaging tree (stubs, patches, enable-cowork.py).
git-r3_src_unpack
# 2) The prebuilt Electron runtime (from SRC_URI / DISTDIR).
mkdir -p "${WORKDIR}/electron" || die
pushd "${WORKDIR}/electron" >/dev/null || die
unpack "electron-${ELECTRON_PV}-linux-x64.zip"
popd >/dev/null || die
# 3) The proprietary Claude Desktop archive. Follow the version upstream
# declares supported in nix/package.nix (the maintainer bumps it after
# validating) rather than blindly grabbing the newest CDN release -- a
# too-new build breaks the minified-JS patches. Override with
# CLAUDE_COWORK_VERSION / CLAUDE_COWORK_URL if you know what you are doing.
local pkgnix="${S}/nix/package.nix"
local cver curl_url chash
cver="${CLAUDE_COWORK_VERSION:-$(grep -oP 'claudeVersion \? "\K[^"]+' "${pkgnix}")}"
curl_url="${CLAUDE_COWORK_URL:-$(grep -oP 'claudeUrl \? "\K[^"]+' "${pkgnix}")}"
chash="$(grep -oP 'claudeHash \? "sha256-\K[^"]+' "${pkgnix}")"
[[ -n ${curl_url} ]] || die "could not determine the Claude Desktop URL from ${pkgnix}"
einfo "Fetching Claude Desktop ${cver} from Anthropic's CDN ..."
einfo " ${curl_url}"
curl -fSL --retry 3 -o "${WORKDIR}/Claude.zip" "${curl_url}" \
|| die "failed to download the Claude Desktop archive"
# Best-effort integrity check against the SRI hash recorded upstream.
if [[ -z ${CLAUDE_COWORK_URL} && -n ${chash} ]]; then
local want got
want="$(printf '%s' "${chash}" | base64 -d 2>/dev/null | od -An -tx1 | tr -d ' \n')"
got="$(sha256sum "${WORKDIR}/Claude.zip" | cut -d' ' -f1)"
if [[ -n ${want} && ${want} != "${got}" ]]; then
die "Claude Desktop archive checksum mismatch (want ${want}, got ${got})"
fi
einfo "Claude Desktop archive checksum verified (sha256 ${got})"
fi
export _CLAUDE_VER="${cver}"
}
src_prepare() {
eapply_user
local repo="${S}"
local app="${WORKDIR}/app"
local dmg="${WORKDIR}/dmg"
# Unpack the macOS .zip. Recent Claude releases ship .zip; older LZFSE .dmg
# images cannot be opened by unzip/p7zip, which is why we fetch the .zip URL.
mkdir -p "${dmg}" || die
unzip -q "${WORKDIR}/Claude.zip" -d "${dmg}" || die "failed to unzip Claude archive"
local claude_app app_asar res
claude_app="$(find "${dmg}" -maxdepth 3 -name '*.app' -type d | head -1)"
[[ -n ${claude_app} ]] || die "Claude.app not found in archive"
app_asar="${claude_app}/Contents/Resources/app.asar"
res="${claude_app}/Contents/Resources"
[[ -f ${app_asar} ]] || die "app.asar not found at ${app_asar}"
# Stash the icon for src_install (claude_app is local to this phase).
[[ -f ${res}/AppIcon.icns ]] && cp "${res}/AppIcon.icns" "${WORKDIR}/AppIcon.icns"
# Extract the asar (and its .unpacked native blobs) with our zero-dep tool.
einfo "Extracting app.asar ..."
"${EPYTHON}" "${FILESDIR}/asar.py" extract "${app_asar}" "${app}" \
|| die "asar extract failed"
# Copy DMG resources/ (i18n JSON, icons, ...) except the asar payloads.
mkdir -p "${app}/resources" || die
local item name
for item in "${res}"/*; do
name="$(basename "${item}")"
case "${name}" in
app.asar|app.asar.unpacked) continue ;;
esac
cp -r "${item}" "${app}/resources/${name}" || die
done
# --- Bake in the Linux-port stubs (mirrors upstream launch.sh/PKGBUILD) ---
einfo "Baking Linux-port stubs ..."
mkdir -p "${app}/node_modules/@ant/claude-swift/js" \
"${app}/node_modules/@ant/claude-native" \
"${app}/cowork" || die
cp -f "${repo}/stubs/@ant/claude-swift/js/index.js" \
"${app}/node_modules/@ant/claude-swift/js/index.js" || die
cp -f "${repo}/stubs/@ant/claude-native/index.js" \
"${app}/node_modules/@ant/claude-native/index.js" || die
cp -f "${repo}/stubs/frame-fix/frame-fix-entry.js" "${app}/frame-fix-entry.js" || die
cp -f "${repo}/stubs/frame-fix/frame-fix-wrapper.js" "${app}/frame-fix-wrapper.js" || die
cp -f "${repo}"/stubs/cowork/*.js "${app}/cowork/" || die
cp -f "${repo}"/stubs/cowork/*.sh "${app}/cowork/" 2>/dev/null
# Optional Linux node-pty build (absent in tagged releases -> the PTY panel
# stays on the inert macOS binary, but the app still launches).
if [[ -f ${repo}/stubs/node-pty-linux/pty.node ]]; then
local ptydest="${app}/node_modules/node-pty/build/Release"
[[ -d ${ptydest} ]] && cp -f "${repo}/stubs/node-pty-linux/pty.node" "${ptydest}/pty.node"
fi
# --- Linux-port wiring (mirrors upstream PKGBUILD build()) ---
einfo "Applying Linux-port patches ..."
local idx="${app}/.vite/build/index.js"
local mainview="${app}/.vite/build/mainView.js"
local pkgjson="${app}/package.json"
# Trampoline: pin resourcesPath to our install layout, then hand off to the
# frame-fix entry point (which adds native Linux window frames).
cat > "${app}/trampoline.js" <<-JSEOF || die
Object.defineProperty(process, 'resourcesPath', {
value: '${EPREFIX}${CLAUDE_INSTALLDIR}/app/resources',
writable: true,
configurable: true,
enumerable: true,
});
require('./frame-fix-entry.js');
JSEOF
if grep -q '"main":.*"\.vite/build/index\.pre\.js"' "${pkgjson}"; then
sed -i 's|"main":.*"\.vite/build/index\.pre\.js"|"main": "trampoline.js"|' "${pkgjson}" || die
else
ewarn "asar entry-point patch skipped (target not found)"
fi
# Strip macOS titlebar options (Vite ESM bypasses the wrapper require-proxy).
if grep -q 'titleBarOverlay' "${idx}"; then
sed -i 's/titleBarStyle:"hidden",titleBarOverlay:[A-Za-z0-9_]\+,trafficLightPosition:[A-Za-z0-9_]\+,//g' "${idx}" || die
sed -i 's/titleBarStyle:"hiddenInset",autoHideMenuBar:!0,skipTaskbar:!0/autoHideMenuBar:!0/g' "${idx}" || die
else
ewarn "titlebar patch skipped (target not found)"
fi
# Drop the isPackaged check on file:// preloads (else the renderer shell
# never loads).
if grep -qE 'e\.protocol==="file:"&&[A-Za-z0-9_]+\.app\.isPackaged===!0' "${idx}"; then
sed -i -E 's/e\.protocol==="file:"&&[A-Za-z0-9_]+\.app\.isPackaged===!0/e.protocol==="file:"/g' "${idx}" || die
else
ewarn "file:// preload patch skipped (target not found)"
fi
# Add a linux branch to getHostPlatform() (best-effort; enable-cowork.py's
# throw->return"darwin-x64" patch below is the real safety net).
if grep -q 'win32-arm64":"win32-x64";throw new Error' "${idx}"; then
sed -i 's|win32-arm64":"win32-x64";throw new Error|win32-arm64":"win32-x64";if(process.platform==="linux")return A==="arm64"?"linux-arm64":"linux-x64";throw new Error|' "${idx}" || die
else
ewarn "linux-x64 platform patch skipped (target not found)"
fi
# Allow file:// as a preload origin in mainView (CoworkSpaces/projects IPC).
if [[ -f ${mainview} ]] && ! grep -q 'e\.protocol==="file:"' "${mainview}"; then
sed -i 's/e\.hostname==="localhost"/e.hostname==="localhost"||e.protocol==="file:"/g' "${mainview}" || die
fi
# Remap --effort xhigh -> max (the SDK only accepts low/medium/high/max).
if grep -q 'O\.push("--effort",this\.options\.effort)' "${idx}"; then
sed -i 's/O\.push("--effort",this\.options\.effort)/O.push("--effort",this.options.effort==="xhigh"?"max":this.options.effort)/' "${idx}" || die
fi
# Neutralise macOS-only Handoff APIs that crash on Linux.
if grep -q 'cA\.app\.invalidateCurrentActivity()' "${idx}"; then
sed -i 's/cA\.app\.invalidateCurrentActivity()/(cA.app.invalidateCurrentActivity||function(){})()/' "${idx}" || die
sed -i 's/cA\.app\.setUserActivity(adt,/((cA.app.setUserActivity||function(){}))(adt,/' "${idx}" || die
fi
# i18n JSONs are read from both resources/ and resources/i18n/.
if compgen -G "${app}/resources/*.json" >/dev/null; then
mkdir -p "${app}/resources/i18n" || die
cp "${app}/resources/"*.json "${app}/resources/i18n/" || die
fi
# Allow bash/sh in the Cowork orchestrator allowlist (upstream gap).
local orch="${app}/cowork/session_orchestrator.js"
if [[ -f ${orch} ]] && grep -q '} else if (allowedPrefixes\.some' "${orch}" \
&& ! grep -qE "commandBasename === [\"']bash[\"']" "${orch}"; then
sed -i 's#^ } else if (allowedPrefixes\.some# } else if (commandBasename === "bash" || commandBasename === "sh") {\n hostCommand = "/usr/bin/" + commandBasename;\n trace("Translated shell command: " + normalizedCommand + " -> " + hostCommand);\n } else if (allowedPrefixes.some#' "${orch}" || die
fi
# Enable Cowork (yukonSilver): platform gate + getHostPlatform +
# IPC origin guards + return-style platform gates.
einfo "Applying Cowork patch (enable-cowork.py) ..."
"${EPYTHON}" "${repo}/enable-cowork.py" "${idx}" || die "enable-cowork.py failed"
# Hard gate: refuse to ship a half-patched (broken) app. A missing marker
# means the Claude build is too new for the current packaging.
if ! grep -q '/\*cowork-patched\*/' "${idx}"; then
eerror "enable-cowork.py could not locate the Cowork platform gate in this"
eerror "Claude Desktop build (${_CLAUDE_VER}): the minified bundle changed"
eerror "and the upstream packaging has not caught up yet. Pin a known-good"
eerror "build, e.g.:"
eerror " CLAUDE_COWORK_VERSION=1.11187.4 \\"
eerror " CLAUDE_COWORK_URL=https://downloads.claude.ai/releases/darwin/universal/1.11187.4/Claude-58400536f3ccde1cff9a129de6c3112dc8cb489a.zip \\"
eerror " emerge ${PN}"
die "Cowork platform gate not patched -- aborting to avoid a broken install"
fi
}
src_install() {
# Big prebuilt trees: cp -a preserves +x bits and symlinks (doins would not).
dodir "${CLAUDE_INSTALLDIR}"
cp -a "${WORKDIR}/electron" "${ED}${CLAUDE_INSTALLDIR}/electron" || die
cp -a "${WORKDIR}/app" "${ED}${CLAUDE_INSTALLDIR}/app" || die
# Ensure the Electron binaries are executable.
fperms 0755 "${CLAUDE_INSTALLDIR}/electron/electron"
local b
for b in chrome-sandbox chrome_crashpad_handler; do
[[ -e ${ED}${CLAUDE_INSTALLDIR}/electron/${b} ]] \
&& fperms 0755 "${CLAUDE_INSTALLDIR}/electron/${b}"
done
# Cowork shell shims must stay executable.
[[ -e ${ED}${CLAUDE_INSTALLDIR}/app/cowork/cowork-plugin-shim.sh ]] \
&& fperms 0755 "${CLAUDE_INSTALLDIR}/app/cowork/cowork-plugin-shim.sh"
# Launcher.
cat > "${T}/${PN}" <<-EOF || die
#!/bin/bash
# Claude Cowork (Linux port) launcher
APPDIR="${EPREFIX}${CLAUDE_INSTALLDIR}"
ELECTRON="\${APPDIR}/electron/electron"
if [[ -n "\${WAYLAND_DISPLAY}" || "\${XDG_SESSION_TYPE}" == "wayland" ]]; then
export ELECTRON_OZONE_PLATFORM_HINT="\${ELECTRON_OZONE_PLATFORM_HINT:-auto}"
fi
# Pick a credential backend: SecretService if a provider owns the bus
# name, otherwise Chromium's plaintext "basic" store.
PW_STORE="gnome-libsecret"
if ! dbus-send --session --print-reply --dest=org.freedesktop.DBus \\
/org/freedesktop/DBus org.freedesktop.DBus.NameHasOwner \\
string:"org.freedesktop.secrets" 2>/dev/null | grep -q "boolean true"; then
PW_STORE="basic"
fi
# Register the claude:// scheme handler once.
if command -v xdg-mime >/dev/null 2>&1; then
if [[ -z "\$(xdg-mime query default x-scheme-handler/claude 2>/dev/null)" ]]; then
xdg-mime default ${PN}.desktop x-scheme-handler/claude 2>/dev/null || true
fi
fi
# Prefer the Chromium namespace sandbox when unprivileged user
# namespaces are available; otherwise fall back to --no-sandbox.
SANDBOX_FLAG="--no-sandbox"
if [[ -f /proc/sys/kernel/unprivileged_userns_clone ]]; then
[[ "\$(cat /proc/sys/kernel/unprivileged_userns_clone 2>/dev/null)" == "1" ]] && SANDBOX_FLAG=""
elif [[ -f /proc/sys/user/max_user_namespaces ]]; then
[[ "\$(cat /proc/sys/user/max_user_namespaces 2>/dev/null)" -gt 0 ]] 2>/dev/null && SANDBOX_FLAG=""
fi
exec "\${ELECTRON}" "\${APPDIR}/app" \\
\${SANDBOX_FLAG} \\
--class=Claude \\
--password-store="\${PW_STORE}" \\
--enable-features=GlobalShortcutsPortal,WaylandWindowDecorations \\
"\$@"
EOF
dobin "${T}/${PN}"
# Desktop entry.
cat > "${T}/${PN}.desktop" <<-EOF || die
[Desktop Entry]
Name=Claude Cowork
Comment=Anthropic Claude Desktop with local agent (Cowork) support
Exec=${PN} %U
Icon=${PN}
Type=Application
Categories=Development;Utility;
MimeType=x-scheme-handler/claude;
StartupWMClass=Claude
EOF
domenu "${T}/${PN}.desktop"
# Icon (best effort: icns2png from media-gfx/libicns is not a hard dep).
if [[ -f ${WORKDIR}/AppIcon.icns ]] && command -v icns2png >/dev/null 2>&1; then
icns2png -x -s 256 "${WORKDIR}/AppIcon.icns" -o "${T}" 2>/dev/null
local png
for png in "${T}"/AppIcon*256*.png "${T}"/AppIcon*.png; do
if [[ -f ${png} ]]; then
newicon -s 256 "${png}" "${PN}.png"
break
fi
done
else
elog "Install media-gfx/libicns (icns2png) and re-emerge for an app icon."
fi
}
pkg_postinst() {
xdg_pkg_postinst
elog "Claude Desktop is proprietary Anthropic software; this package only"
elog "provides the Linux compatibility layer. Launch it with: ${PN}"
elog
elog "This is a -9999 live ebuild: it tracks the packaging repo's master"
elog "branch and downloads the Claude Desktop build that upstream pins in"
elog "nix/package.nix. To force a specific Claude build, set CLAUDE_COWORK_VERSION"
elog "and CLAUDE_COWORK_URL before emerging."
elog
if use sandbox; then
elog "The Chromium sandbox needs unprivileged user namespaces"
elog "(CONFIG_USER_NS). Without them the launcher falls back to"
elog "--no-sandbox automatically."
fi
}