Files
claude-re-setup/run.sh
T
LinusandClaude Opus 5.5 4baf0208e1 Add Ghidra 12.2 and start it alongside Claude Desktop
Install Ghidra 12.2 from the official GitHub release into /opt/ghidra,
verified against the SHA-256 in the release notes, with Ubuntu's
openjdk-25-jdk (Ghidra 12.2 requires Java 25). Version, build date and
hash are build args.

The entrypoint, renamed from launch-claude.sh to launch-apps.sh, now
starts both apps and stops both, ending the container, when either is
closed: Ghidra when its process exits (closing the project window),
Claude Desktop when none of its windows is open or minimized. Claude is
stopped via its main process so it can shut down its own helpers.

CLAUDE.md now records that Claude Desktop and Ghidra share one
container, at the user's request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:38:38 +01:00

201 lines
8.1 KiB
Bash
Executable File

#!/usr/bin/env bash
# Start the Claude Desktop container, forwarding the host's X server.
# Before launching, verify that a window from the container can actually be
# drawn on the host display. Asks (in a folder picker) which host folder to
# share with Claude; it is mounted read-write at /workspace in the container.
#
# Usage: ./run.sh [--build] [--check-only] [--workspace DIR]
# --build rebuild the image even if it exists
# --check-only run the display check and exit without starting Claude
# --workspace DIR share DIR without asking (also: WORKSPACE=DIR)
set -euo pipefail
IMAGE="${IMAGE:-claude-re-setup:latest}"
CONTAINER="${CONTAINER:-claude-desktop}"
RUNTIME="${RUNTIME:-docker}"
WORKSPACE="${WORKSPACE:-}"
script_dir="$(dirname "$(readlink -f "$0")")"
state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/claude-re-setup"
last_workspace_file="$state_dir/last_workspace"
build=0
check_only=0
while (( $# )); do
case "$1" in
--build) build=1 ;;
--check-only) check_only=1 ;;
--workspace) [[ $# -ge 2 ]] || { echo "--workspace needs a directory" >&2; exit 2; }
WORKSPACE="$2"; shift ;;
--workspace=*) WORKSPACE="${1#*=}" ;;
-h|--help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "Unknown argument: $1" >&2; exit 2 ;;
esac
shift
done
die() { echo "error: $*" >&2; exit 1; }
info() { echo "==> $*"; }
command -v "$RUNTIME" >/dev/null || die "'$RUNTIME' not found (set RUNTIME=podman to use podman)"
command -v xauth >/dev/null || die "xauth not found on the host (dnf install xorg-x11-xauth)"
# --- Display -----------------------------------------------------------------
# On Wayland sessions this is XWayland, which GNOME/KDE start on demand.
[[ -n "${DISPLAY:-}" ]] || die "DISPLAY is not set; run this from a graphical session"
display_num="${DISPLAY#*:}"
display_num="${display_num%%.*}"
[[ -S "/tmp/.X11-unix/X${display_num}" ]] \
|| die "X socket /tmp/.X11-unix/X${display_num} not found for DISPLAY=$DISPLAY"
# Hand the container a copy of this display's cookie, rewritten to match any
# hostname (family ffff). This avoids opening the server up with 'xhost +'.
xauth_dir="${XDG_RUNTIME_DIR:-/tmp}/claude-re-setup"
xauth_file="$xauth_dir/Xauthority"
mkdir -p "$xauth_dir"
chmod 700 "$xauth_dir"
: > "$xauth_file"
xauth nlist "$DISPLAY" | sed -e 's/^..../ffff/' | xauth -f "$xauth_file" nmerge - \
|| die "could not export the X cookie for $DISPLAY"
[[ -s "$xauth_file" ]] || die "no X cookie found for $DISPLAY (is XAUTHORITY set?)"
# The container user has the same UID as us, so 0600 is enough.
chmod 600 "$xauth_file"
# --- Image -------------------------------------------------------------------
if (( build )) || ! "$RUNTIME" image inspect "$IMAGE" >/dev/null 2>&1; then
info "Building $IMAGE"
"$RUNTIME" build \
--build-arg USER_UID="$(id -u)" \
--build-arg USER_GID="$(id -g)" \
-t "$IMAGE" "$script_dir"
fi
# label=disable: the X socket and cookie are host files that SELinux would
# otherwise block, and relabeling /tmp/.X11-unix with :z would break the host.
run_args=(
--rm
--init
--security-opt label=disable
--shm-size 1g
-e DISPLAY=":${display_num}"
-e XAUTHORITY=/tmp/.Xauthority
-v /tmp/.X11-unix:/tmp/.X11-unix:ro
-v "$xauth_file:/tmp/.Xauthority:ro"
)
# --- Display check -----------------------------------------------------------
# 1. xdpyinfo proves the container can connect and authenticate to the server.
# 2. xmessage opens a real window; xwininfo confirms the server mapped it.
info "Checking that the container can draw on DISPLAY=$DISPLAY"
check_script='
set -e
xdpyinfo >/dev/null 2>&1 || { echo "FAIL: cannot connect to X server at $DISPLAY"; exit 1; }
echo "ok: connected to X server ($(xdpyinfo | sed -n "s/^vendor string: *//p"))"
title="claude-re-setup-x11-check"
xmessage -title "$title" -center -timeout 3 "Display check from the container: OK" &
for _ in $(seq 1 30); do
if xwininfo -name "$title" 2>/dev/null | grep -q "Map State: IsViewable"; then
echo "ok: test window is mapped and viewable"
wait || true
exit 0
fi
sleep 0.1
done
kill %1 2>/dev/null || true
echo "FAIL: test window was created but never became viewable"
exit 1
'
if ! "$RUNTIME" run "${run_args[@]}" "$IMAGE" bash -c "$check_script"; then
die "display check failed; see the output above"
fi
(( check_only )) && { info "Display check passed"; exit 0; }
# --- Claude Desktop ----------------------------------------------------------
if "$RUNTIME" container inspect "$CONTAINER" >/dev/null 2>&1; then
die "container '$CONTAINER' already exists; stop it with: $RUNTIME stop $CONTAINER"
fi
# --- Workspace ---------------------------------------------------------------
# The folder Claude can read and write. Chosen in the desktop's own folder
# picker (zenity on GNOME, kdialog on KDE), else asked in the terminal.
# Default: the last one used, else ./workspace next to this script.
ws_title="Choose the folder Claude can access (mounted at /workspace)"
# Prints the reason a folder can't be shared, or nothing if it's fine.
workspace_problem() {
case "$1" in
/|"$HOME") echo "Sharing $1 would give Claude far more than intended; pick a dedicated folder." ;;
*) [[ -w "$1" ]] || echo "$1 is not writable by $(id -un)." ;;
esac
}
# Shows a folder picker starting in $1; prints the choice, fails on cancel.
gui_pick() {
if command -v zenity >/dev/null; then
zenity --file-selection --directory --title="$ws_title" --filename="$1/" 2>/dev/null
elif command -v kdialog >/dev/null; then
kdialog --title "$ws_title" --getexistingdirectory "$1" 2>/dev/null
else
return 2
fi
}
gui_error() {
if command -v zenity >/dev/null; then zenity --error --no-wrap --text="$1" 2>/dev/null
else kdialog --error "$1" 2>/dev/null; fi
}
if [[ -z "$WORKSPACE" ]]; then
default_ws="$script_dir/workspace"
[[ -r "$last_workspace_file" ]] && default_ws="$(<"$last_workspace_file")"
# The picker needs an existing folder to open in. ./workspace is
# git-ignored, so creating it is harmless; a stale last choice falls back.
[[ -d "$default_ws" ]] || { default_ws="$script_dir/workspace"; mkdir -p "$default_ws"; }
if command -v zenity >/dev/null || command -v kdialog >/dev/null; then
info "Choose the folder to share in the dialog"
while :; do
WORKSPACE="$(gui_pick "$default_ws")" || die "no folder chosen; not starting Claude"
problem="$(workspace_problem "$(readlink -f "$WORKSPACE")")"
[[ -z "$problem" ]] && break
gui_error "$problem"
done
elif [[ -t 0 ]]; then
echo "Claude will get read-write access to one folder, mounted at /workspace."
read -r -e -p "Folder to share [$default_ws]: " WORKSPACE
fi
WORKSPACE="${WORKSPACE:-$default_ws}"
fi
# Expand a leading ~ the way the shell would have.
[[ "$WORKSPACE" == "~" || "$WORKSPACE" == "~/"* ]] && WORKSPACE="$HOME${WORKSPACE#\~}"
if [[ ! -d "$WORKSPACE" ]]; then
[[ -e "$WORKSPACE" ]] && die "$WORKSPACE exists but is not a directory"
if [[ -t 0 ]]; then
read -r -p "$WORKSPACE does not exist. Create it? [Y/n] " answer
[[ "${answer:-y}" =~ ^[Yy] ]] || die "no folder to share"
fi
mkdir -p "$WORKSPACE" || die "could not create $WORKSPACE"
fi
WORKSPACE="$(readlink -f "$WORKSPACE")"
problem="$(workspace_problem "$WORKSPACE")"
[[ -z "$problem" ]] || die "$problem"
mkdir -p "$state_dir"
printf '%s\n' "$WORKSPACE" > "$last_workspace_file"
# No config volume: the container is removed on stop (--rm), so the sign-in
# is discarded and Claude asks for a login on every start.
# No :z on the workspace mount: label=disable already lets the container use
# it, and relabeling an arbitrary host folder could break other apps.
info "Starting Claude Desktop and Ghidra in container '$CONTAINER' (workspace: $WORKSPACE -> /workspace)"
"$RUNTIME" run -d "${run_args[@]}" \
--name "$CONTAINER" \
-v "$WORKSPACE:/workspace" \
"$IMAGE" >/dev/null
info "Running. Logs: $RUNTIME logs -f $CONTAINER Stop: $RUNTIME stop $CONTAINER"