# 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
}