diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..63df3afa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +** +!deploy/ +!deploy/remote-pwa/ +!deploy/remote-pwa/Dockerfile +!deploy/remote-pwa/nginx.conf +!frontend/ +!frontend/dist/ +!frontend/dist/** diff --git a/.github/workflows/deploy-remote-pwa-preview.yml b/.github/workflows/deploy-remote-pwa-preview.yml new file mode 100644 index 00000000..f7244e1b --- /dev/null +++ b/.github/workflows/deploy-remote-pwa-preview.yml @@ -0,0 +1,96 @@ +name: Deploy Remote PWA Preview + +on: + push: + branches: [nightly] + workflow_dispatch: + +permissions: + contents: read + id-token: write + +env: + GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID || 'pane-pwa-preview' }} + GCP_REGION: ${{ vars.GCP_REGION || 'us-central1' }} + GAR_REPOSITORY: ${{ vars.GAR_REPOSITORY || 'pane-preview' }} + CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE || 'pane-remote-pwa-preview' }} + CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT: ${{ vars.CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT || '' }} + +jobs: + deploy: + name: Build and deploy preview + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.15.1' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build remote PWA assets + run: pnpm run build:frontend + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ vars.GCP_DEPLOY_SERVICE_ACCOUNT }} + + - name: Setup Google Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + + - name: Configure Artifact Registry Docker auth + run: gcloud auth configure-docker "${GCP_REGION}-docker.pkg.dev" --quiet --project="${GCP_PROJECT_ID}" + + - name: Build and push container + env: + IMAGE_TAG: ${{ github.sha }} + run: | + set -euo pipefail + + image="${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${GAR_REPOSITORY}/${CLOUD_RUN_SERVICE}:${IMAGE_TAG}" + docker build \ + --file deploy/remote-pwa/Dockerfile \ + --tag "$image" \ + . + docker push "$image" + echo "IMAGE=$image" >> "$GITHUB_ENV" + + - name: Deploy to Cloud Run + run: | + set -euo pipefail + + runtime_service_account="${CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT:-pane-remote-pwa-runtime@${GCP_PROJECT_ID}.iam.gserviceaccount.com}" + + gcloud run deploy "$CLOUD_RUN_SERVICE" \ + --project="$GCP_PROJECT_ID" \ + --region="$GCP_REGION" \ + --platform=managed \ + --image="$IMAGE" \ + --service-account="$runtime_service_account" \ + --no-invoker-iam-check \ + --min-instances=0 \ + --max-instances=1 \ + --cpu=1 \ + --memory=256Mi \ + --port=8080 \ + --set-env-vars="PANE_REMOTE_PWA_PREVIEW_COMMIT=${GITHUB_SHA},PANE_REMOTE_PWA_PREVIEW_REF=${GITHUB_REF_NAME}" \ + --quiet + + - name: Print service URL + run: | + gcloud run services describe "$CLOUD_RUN_SERVICE" \ + --project="$GCP_PROJECT_ID" \ + --region="$GCP_REGION" \ + --platform=managed \ + --format='value(status.url)' diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml new file mode 100644 index 00000000..6ca54413 --- /dev/null +++ b/.github/workflows/nightly-release.yml @@ -0,0 +1,239 @@ +name: Nightly / Canary Release + +on: + workflow_dispatch: + inputs: + channel: + description: 'Release channel' + type: choice + required: true + default: nightly + options: + - nightly + - canary + source_ref: + description: 'Branch, tag, or SHA to build' + type: string + required: true + default: nightly + +permissions: + contents: write + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + channel: ${{ steps.meta.outputs.channel }} + source_ref: ${{ steps.meta.outputs.source_ref }} + source_sha: ${{ steps.meta.outputs.source_sha }} + short_sha: ${{ steps.meta.outputs.short_sha }} + version: ${{ steps.meta.outputs.version }} + tag: ${{ steps.meta.outputs.tag }} + title: ${{ steps.meta.outputs.title }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.source_ref }} + fetch-depth: 0 + + - id: meta + shell: bash + run: | + set -euo pipefail + + channel="${{ inputs.channel }}" + source_ref="${{ inputs.source_ref }}" + base_version="$(node -p "require('./package.json').version.replace(/-.*/, '')")" + source_sha="$(git rev-parse HEAD)" + short_sha="$(git rev-parse --short=7 HEAD)" + timestamp="$(date -u +%Y%m%d%H%M)" + version="${base_version}-${channel}.${timestamp}.${short_sha}.${{ github.run_number }}.${{ github.run_attempt }}" + tag="v${version}" + title="Pane ${channel^} ${version}" + + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*$ ]]; then + echo "Generated version is not valid semver: $version" >&2 + exit 1 + fi + + { + echo "channel=$channel" + echo "source_ref=$source_ref" + echo "source_sha=$source_sha" + echo "short_sha=$short_sha" + echo "version=$version" + echo "tag=$tag" + echo "title=$title" + } >> "$GITHUB_OUTPUT" + + build: + needs: metadata + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + command: build:mac:ci + artifact_name: pane-macos + artifact_path: | + dist-electron/*.dmg + - os: ubuntu-latest + command: build:linux:ci + artifact_name: pane-linux + artifact_path: | + dist-electron/*.deb + dist-electron/*.AppImage + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.metadata.outputs.source_sha }} + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22.15.1' + cache: 'pnpm' + + - uses: actions/cache@v4 + with: + path: | + ~/.cache/electron + ~/.cache/electron-builder + key: ${{ runner.os }}-electron-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: ${{ runner.os }}-electron- + + - run: pnpm install --frozen-lockfile + + - name: Stamp prerelease version + env: + NIGHTLY_VERSION: ${{ needs.metadata.outputs.version }} + run: | + node - <<'NODE' + const fs = require('fs'); + const path = require('path'); + const packagePath = path.resolve('package.json'); + const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + packageJson.version = process.env.NIGHTLY_VERSION; + fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); + NODE + + - name: Build ${{ matrix.os }} package + env: + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + CSC_DISABLE: 'true' + run: pnpm run ${{ matrix.command }} + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }}-${{ needs.metadata.outputs.channel }}-${{ needs.metadata.outputs.tag }} + path: ${{ matrix.artifact_path }} + if-no-files-found: error + retention-days: 30 + + build-windows: + needs: metadata + strategy: + fail-fast: false + matrix: + arch: + - x64 + - arm64 + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.metadata.outputs.source_sha }} + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22.15.1' + cache: 'pnpm' + + - uses: actions/cache@v4 + with: + path: | + ~/AppData/Local/electron/Cache + ~/AppData/Local/electron-builder/Cache + key: windows-${{ matrix.arch }}-electron-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: windows-${{ matrix.arch }}-electron- + + - run: pnpm install --frozen-lockfile + + - name: Stamp prerelease version + env: + NIGHTLY_VERSION: ${{ needs.metadata.outputs.version }} + shell: bash + run: | + node - <<'NODE' + const fs = require('fs'); + const path = require('path'); + const packagePath = path.resolve('package.json'); + const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + packageJson.version = process.env.NIGHTLY_VERSION; + fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); + NODE + + - name: Build Windows ${{ matrix.arch }} package + run: node scripts/build-win.js ${{ matrix.arch }} + + - uses: actions/upload-artifact@v4 + with: + name: pane-windows-${{ matrix.arch }}-${{ needs.metadata.outputs.channel }}-${{ needs.metadata.outputs.tag }} + path: | + dist-electron/*-Windows-${{ matrix.arch }}.exe + if-no-files-found: error + retention-days: 30 + + publish: + needs: + - metadata + - build + - build-windows + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: release-assets + merge-multiple: true + + - name: Publish prerelease + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + CHANNEL: ${{ needs.metadata.outputs.channel }} + SOURCE_REF: ${{ needs.metadata.outputs.source_ref }} + SOURCE_SHA: ${{ needs.metadata.outputs.source_sha }} + SHORT_SHA: ${{ needs.metadata.outputs.short_sha }} + VERSION: ${{ needs.metadata.outputs.version }} + TAG: ${{ needs.metadata.outputs.tag }} + TITLE: ${{ needs.metadata.outputs.title }} + shell: bash + run: | + set -euo pipefail + + find release-assets -maxdepth 1 -type f | sort + cat > release-notes.md </dev/null || [ -z "$profile_id" ]; then + return + fi + + jq -r --arg profileId "$profile_id" ' + .remoteDaemon.client.profiles[]? | select(.id == $profileId) | .token + ' "$PANE_CONFIG" 2>/dev/null | head -1 +} # ============================================================ # Incremental config save function @@ -145,6 +179,28 @@ save_cloud_config() { region=$(echo "$zone" | sed 's/-[a-z]$//') fi + local allow_novnc_json="false" + if [ "$HOSTED_ALLOW_NOVNC_FALLBACK" = "true" ]; then + allow_novnc_json="true" + fi + + local remote_profile_json='null' + if [ -n "$HOSTED_REMOTE_PROFILE_ID" ] && [ -n "$HOSTED_REMOTE_PROFILE_TOKEN" ] && [ -n "$HOSTED_DAEMON_BASE_URL" ]; then + remote_profile_json="$(jq -cn \ + --arg id "$HOSTED_REMOTE_PROFILE_ID" \ + --arg label "$HOSTED_REMOTE_PROFILE_LABEL" \ + --arg baseUrl "$HOSTED_DAEMON_BASE_URL" \ + --arg token "$HOSTED_REMOTE_PROFILE_TOKEN" \ + '{ + id: $id, + label: $label, + baseUrl: $baseUrl, + token: $token, + transport: "http+sse" + }' + )" + fi + # Update config with current values (only non-empty ones) # Use canonical key names (projectId, zone) to match what the app reads local tmp_config="${PANE_CONFIG}.tmp" @@ -154,15 +210,44 @@ save_cloud_config() { --arg region "$region" \ --arg serverId "$server_id" \ --arg vncPassword "$vnc_password" \ - --arg tunnelPort "$tunnel_port" \ - '.cloud = (.cloud // {}) | .cloud.provider = $provider + --arg daemonBaseUrl "$HOSTED_DAEMON_BASE_URL" \ + --arg linkedRemoteProfileId "$HOSTED_REMOTE_PROFILE_ID" \ + --arg daemonStatus "$HOSTED_DAEMON_STATUS" \ + --arg apiToken "$CURRENT_GCP_TOKEN" \ + --argjson tunnelPort "${tunnel_port:-8080}" \ + --argjson allowNoVncFallback "$allow_novnc_json" \ + --argjson remoteProfile "$remote_profile_json" \ + ' + .cloud = (.cloud // {}) | .cloud.provider = $provider | if $projectId != "" then .cloud.projectId = $projectId else . end | if $zone != "" then .cloud.zone = $zone else . end | if $region != "" then .cloud.region = $region else . end | if $serverId != "" then .cloud.serverId = $serverId else . end | if $vncPassword != "" then .cloud.vncPassword = $vncPassword else . end + | if $apiToken != "" then .cloud.apiToken = $apiToken else . end + | if $daemonBaseUrl != "" then .cloud.daemonBaseUrl = $daemonBaseUrl else . end + | if $linkedRemoteProfileId != "" then .cloud.linkedRemoteProfileId = $linkedRemoteProfileId else . end + | if $daemonStatus != "" then .cloud.daemonStatus = $daemonStatus else . end + | .cloud.preferredAccess = "daemon" + | .cloud.allowNoVncFallback = $allowNoVncFallback | .cloud.tunnelPort = $tunnelPort - | .cloud.serverIp = ""' \ + | .cloud.serverIp = "" + | .remoteDaemon = (.remoteDaemon // {}) + | .remoteDaemon.client = (.remoteDaemon.client // { + profiles: [], + activeProfileId: null, + mode: "local" + }) + | if $remoteProfile != null + then .remoteDaemon.client.profiles = ( + [((.remoteDaemon.client.profiles // [])[] | select(.id != $remoteProfile.id))] + + [$remoteProfile] + ) + else . + end + | .remoteDaemon.client.activeProfileId = (.remoteDaemon.client.activeProfileId // null) + | .remoteDaemon.client.mode = (.remoteDaemon.client.mode // "local") + ' \ "$PANE_CONFIG" > "$tmp_config" && mv "$tmp_config" "$PANE_CONFIG" } @@ -358,6 +443,17 @@ if [ "$DESTROY_MODE" = true ]; then # Get project ID from state for cleanup PROJECT_ID=$(terraform -chdir="$TERRAFORM_DIR" output -raw project_id 2>/dev/null || echo "") + INSTANCE_NAME=$(terraform -chdir="$TERRAFORM_DIR" output -raw instance_name 2>/dev/null || echo "") + STATE_USER_ID="${INSTANCE_NAME#pane-}" + if [ -z "$STATE_USER_ID" ] || [ "$STATE_USER_ID" = "$INSTANCE_NAME" ]; then + STATE_USER_ID="${PROJECT_ID#pane-cloud-}" + fi + VNC_PASSWORD=$(terraform -chdir="$TERRAFORM_DIR" output -raw vnc_password 2>/dev/null || echo "destroy-placeholder") + HOSTED_REMOTE_PROFILE_ID=$(terraform -chdir="$TERRAFORM_DIR" output -raw remote_client_id 2>/dev/null || echo "cloud-${STATE_USER_ID}") + HOSTED_REMOTE_PROFILE_LABEL=$(terraform -chdir="$TERRAFORM_DIR" output -raw remote_client_label 2>/dev/null || echo "Pane Cloud Workspace") + HOSTED_REMOTE_PROFILE_TOKEN=$(terraform -chdir="$TERRAFORM_DIR" output -raw remote_client_token 2>/dev/null || echo "destroy-placeholder") + HOSTED_DAEMON_PORT=$(terraform -chdir="$TERRAFORM_DIR" output -raw remote_daemon_port 2>/dev/null || echo "42137") + HOSTED_ALLOW_NOVNC_FALLBACK=$(normalize_bool "$(terraform -chdir="$TERRAFORM_DIR" output -raw novnc_fallback_enabled 2>/dev/null || echo "false")") if ! prompt_yes_no "Are you sure you want to destroy the cloud infrastructure?" "n"; then info "Aborted." @@ -367,7 +463,16 @@ if [ "$DESTROY_MODE" = true ]; then info "Running terraform destroy..." cd "$TERRAFORM_DIR" terraform init -input=false >/dev/null 2>&1 - terraform destroy -auto-approve + terraform destroy \ + -var="project_id=${PROJECT_ID}" \ + -var="user_id=${STATE_USER_ID}" \ + -var="vnc_password=${VNC_PASSWORD}" \ + -var="remote_client_id=${HOSTED_REMOTE_PROFILE_ID}" \ + -var="remote_client_label=${HOSTED_REMOTE_PROFILE_LABEL}" \ + -var="remote_client_token=${HOSTED_REMOTE_PROFILE_TOKEN}" \ + -var="remote_daemon_port=${HOSTED_DAEMON_PORT}" \ + -var="enable_novnc_fallback=${HOSTED_ALLOW_NOVNC_FALLBACK}" \ + -auto-approve success "Infrastructure destroyed." @@ -462,7 +567,21 @@ if [ -f "${TERRAFORM_DIR}/terraform.tfstate" ]; then # Read remaining terraform outputs PROJECT_ID=$(terraform -chdir="$TERRAFORM_DIR" output -raw project_id 2>/dev/null || echo "") GCP_ZONE=$(terraform -chdir="$TERRAFORM_DIR" output -raw zone 2>/dev/null || echo "") - TUNNEL_PORT=8080 + HOSTED_DAEMON_PORT=$(terraform -chdir="$TERRAFORM_DIR" output -raw remote_daemon_port 2>/dev/null || echo "42137") + HOSTED_REMOTE_PROFILE_ID=$(terraform -chdir="$TERRAFORM_DIR" output -raw remote_client_id 2>/dev/null || echo "") + HOSTED_REMOTE_PROFILE_LABEL=$(terraform -chdir="$TERRAFORM_DIR" output -raw remote_client_label 2>/dev/null || echo "Pane Cloud Workspace") + HOSTED_REMOTE_PROFILE_TOKEN=$(terraform -chdir="$TERRAFORM_DIR" output -raw remote_client_token 2>/dev/null || echo "") + HOSTED_ALLOW_NOVNC_FALLBACK=$(normalize_bool "$(terraform -chdir="$TERRAFORM_DIR" output -raw novnc_fallback_enabled 2>/dev/null || echo "false")") + TUNNEL_PORT=$HOSTED_TUNNEL_PORT + HOSTED_DAEMON_BASE_URL="$(terraform -chdir="$TERRAFORM_DIR" output -raw daemon_base_url 2>/dev/null || echo "http://127.0.0.1:${TUNNEL_PORT}/daemon/")" + + if [ -z "$HOSTED_REMOTE_PROFILE_ID" ]; then + HOSTED_REMOTE_PROFILE_ID="cloud-${INSTANCE_NAME#pane-}" + fi + + if [ -z "$HOSTED_REMOTE_PROFILE_TOKEN" ]; then + HOSTED_REMOTE_PROFILE_TOKEN="$(read_existing_remote_profile_token "$HOSTED_REMOTE_PROFILE_ID")" + fi # If project_id output doesn't exist (older state), extract from tunnel command if [ -z "$PROJECT_ID" ]; then @@ -546,7 +665,18 @@ if [ -f "${TERRAFORM_DIR}/terraform.tfstate" ]; then fi echo "" - # Step 4: Get VNC password (from terraform state first, then VM as fallback) + # Step 4: Check hosted daemon readiness from inside the VM before opening the tunnel + info "Checking hosted daemon health..." + HOSTED_DAEMON_STATUS=$(gcloud compute ssh "$INSTANCE_NAME" \ + --zone="$GCP_ZONE" \ + --project="$PROJECT_ID" \ + --tunnel-through-iap \ + --command="curl -fsS http://127.0.0.1/health >/dev/null 2>&1 && echo 'ready' || echo 'bootstrapping'" \ + 2>/dev/null || echo "unknown") + success "Hosted daemon status: ${HOSTED_DAEMON_STATUS}" + echo "" + + # Step 5: Get VNC password (from terraform state first, then VM as fallback) info "Retrieving VNC password..." # Try terraform state first (we now store it there) @@ -584,51 +714,24 @@ if [ -f "${TERRAFORM_DIR}/terraform.tfstate" ]; then fi echo "" - # Step 5: Update Pane config + # Step 6: Update Pane config info "Updating Pane config..." mkdir -p "$PANE_CONFIG_DIR" + CURRENT_GCP_TOKEN="$GCP_TOKEN" if command -v jq &>/dev/null; then - # Read existing config or start with empty object - EXISTING_CONFIG='{}' - if [ -f "$PANE_CONFIG" ]; then - EXISTING_CONFIG=$(cat "$PANE_CONFIG" 2>/dev/null || echo '{}') - fi - - # Update config using jq and capture to variable (avoids Windows path issues with temp files) - NEW_CONFIG=$(echo "$EXISTING_CONFIG" | jq --arg provider "gcp" \ - --arg token "$GCP_TOKEN" \ - --arg serverId "$INSTANCE_NAME" \ - --arg vncPw "$VNC_PASSWORD" \ - --arg projectId "$PROJECT_ID" \ - --arg zone "$GCP_ZONE" \ - --argjson port "$TUNNEL_PORT" \ - '.cloud = { - provider: $provider, - apiToken: $token, - serverId: $serverId, - vncPassword: $vncPw, - projectId: $projectId, - zone: $zone, - tunnelPort: $port - }') - - if [ -n "$NEW_CONFIG" ] && [ "$NEW_CONFIG" != "null" ]; then - printf '%s\n' "$NEW_CONFIG" > "$PANE_CONFIG" - success "Config updated: ${PANE_CONFIG}" - else - warn "jq produced empty output — config not updated." - fi + save_cloud_config "$PROJECT_ID" "$GCP_ZONE" "$INSTANCE_NAME" "$VNC_PASSWORD" "$TUNNEL_PORT" + success "Config updated: ${PANE_CONFIG}" else warn "jq not found — skipping config update." fi echo "" - # Step 6: Start IAP tunnel + # Step 7: Start IAP tunnel header "Starting IAP Tunnel" echo -e "The tunnel will connect your local port ${BOLD}${TUNNEL_PORT}${NC} to the VM." - echo -e "Once connected, open Pane and click the ${BOLD}Cloud${NC} button to view your VM." + echo -e "Once connected, open Pane and click ${BOLD}Connect Cloud Runtime${NC}." echo "" echo -e "${YELLOW}Press Ctrl+C to disconnect the tunnel.${NC}" echo "" @@ -687,6 +790,8 @@ if ! gcloud auth application-default print-access-token &>/dev/null 2>&1; then gcloud auth application-default login fi +CURRENT_GCP_TOKEN=$(gcloud auth print-access-token 2>/dev/null || echo "") + # ============================================================ # Step 2: Choose or create a GCP project # ============================================================ @@ -704,6 +809,11 @@ done # Sanitize: lowercase, alphanumeric + hyphens only, strip carriage returns (WSL fix) USER_ID=$(echo "$USER_ID" | tr -d '\r' | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g') PROJECT_ID="pane-cloud-${USER_ID}" +HOSTED_REMOTE_PROFILE_ID="cloud-${USER_ID}" +HOSTED_REMOTE_PROFILE_TOKEN="$(read_existing_remote_profile_token "$HOSTED_REMOTE_PROFILE_ID")" +if [ -z "$HOSTED_REMOTE_PROFILE_TOKEN" ]; then + HOSTED_REMOTE_PROFILE_TOKEN="$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')" +fi info "Project ID will be: ${BOLD}${PROJECT_ID}${NC}" @@ -781,6 +891,11 @@ GCP_REGION=$(echo "$GCP_ZONE" | sed 's/-[a-z]$//') prompt_input MACHINE_TYPE "Machine type" "e2-highmem-2" prompt_input DISK_SIZE "Boot disk size (GB)" "128" +if prompt_yes_no "Enable noVNC fallback/debug desktop access?" "n"; then + HOSTED_ALLOW_NOVNC_FALLBACK="true" +else + HOSTED_ALLOW_NOVNC_FALLBACK="false" +fi echo "" info "Configuration summary:" @@ -791,6 +906,8 @@ echo " Region: ${GCP_REGION}" echo " Machine: ${MACHINE_TYPE}" echo " Disk: ${DISK_SIZE} GB" echo " Security: IAP-only (no public IP)" +echo " Runtime: Headless Pane daemon" +echo " noVNC debug: ${HOSTED_ALLOW_NOVNC_FALLBACK}" echo "" if ! prompt_yes_no "Proceed with Terraform apply?"; then @@ -817,7 +934,11 @@ else success "VNC password generated." fi echo -e "\n ${BOLD}VNC Password: ${YELLOW}${VNC_PASSWORD}${NC}\n" -echo -e " ${CYAN}Save this password — you'll need it to connect to the noVNC display.${NC}\n" +if [ "$HOSTED_ALLOW_NOVNC_FALLBACK" = "true" ]; then + echo -e " ${CYAN}Save this password — you'll need it for optional noVNC fallback access.${NC}\n" +else + echo -e " ${CYAN}noVNC fallback is disabled by default; this password is retained only for future recovery use.${NC}\n" +fi # Save progress: zone and VNC password now known save_cloud_config "$PROJECT_ID" "$GCP_ZONE" "" "$VNC_PASSWORD" @@ -848,6 +969,11 @@ terraform apply \ -var="machine_type=${MACHINE_TYPE}" \ -var="disk_size_gb=${DISK_SIZE}" \ -var="vnc_password=${VNC_PASSWORD}" \ + -var="remote_client_id=${HOSTED_REMOTE_PROFILE_ID}" \ + -var="remote_client_label=${HOSTED_REMOTE_PROFILE_LABEL}" \ + -var="remote_client_token=${HOSTED_REMOTE_PROFILE_TOKEN}" \ + -var="remote_daemon_port=${HOSTED_DAEMON_PORT}" \ + -var="enable_novnc_fallback=${HOSTED_ALLOW_NOVNC_FALLBACK}" \ -auto-approve success "Infrastructure provisioned!" @@ -855,8 +981,15 @@ success "Infrastructure provisioned!" # Capture outputs INSTANCE_NAME=$(terraform output -raw instance_name 2>/dev/null) SSH_CMD=$(terraform output -raw ssh_command 2>/dev/null) -TUNNEL_CMD=$(terraform output -raw novnc_tunnel_command 2>/dev/null) +TUNNEL_CMD=$(terraform output -raw daemon_tunnel_command 2>/dev/null) NOVNC_URL=$(terraform output -raw novnc_url 2>/dev/null) +HOSTED_DAEMON_PORT=$(terraform output -raw remote_daemon_port 2>/dev/null || echo "$HOSTED_DAEMON_PORT") +HOSTED_REMOTE_PROFILE_ID=$(terraform output -raw remote_client_id 2>/dev/null || echo "$HOSTED_REMOTE_PROFILE_ID") +HOSTED_REMOTE_PROFILE_LABEL=$(terraform output -raw remote_client_label 2>/dev/null || echo "$HOSTED_REMOTE_PROFILE_LABEL") +HOSTED_REMOTE_PROFILE_TOKEN=$(terraform output -raw remote_client_token 2>/dev/null || echo "$HOSTED_REMOTE_PROFILE_TOKEN") +HOSTED_ALLOW_NOVNC_FALLBACK=$(normalize_bool "$(terraform output -raw novnc_fallback_enabled 2>/dev/null || echo "$HOSTED_ALLOW_NOVNC_FALLBACK")") +HOSTED_DAEMON_BASE_URL=$(terraform output -raw daemon_base_url 2>/dev/null || echo "$HOSTED_DAEMON_BASE_URL") +HOSTED_DAEMON_STATUS="bootstrapping" # Save progress: instance name now known - this is the critical save! save_cloud_config "$PROJECT_ID" "$GCP_ZONE" "$INSTANCE_NAME" "$VNC_PASSWORD" @@ -870,7 +1003,7 @@ header "Step 7: Waiting for VM Setup" info "The VM is running the setup script (installs packages, Node.js, Pane, etc.)" info "This typically takes 3-5 minutes on a fresh VM.\n" -# Poll for setup completion by checking if supervisor is running +# Poll for setup completion by checking the daemon health endpoint through SSH MAX_WAIT=600 # 10 minutes max ELAPSED=0 INTERVAL=15 @@ -878,17 +1011,19 @@ INTERVAL=15 while [ $ELAPSED -lt $MAX_WAIT ]; do echo -ne "\r Waiting... (${ELAPSED}s / ${MAX_WAIT}s)" - # Try to SSH in and check if setup is done (supervisor running = setup complete) + # Try to SSH in and check if setup is done (daemon health endpoint ready) SETUP_DONE=$(gcloud compute ssh "$INSTANCE_NAME" \ --zone="$GCP_ZONE" \ --project="$PROJECT_ID" \ --tunnel-through-iap \ - --command="systemctl is-active supervisor 2>/dev/null || echo 'not-ready'" \ + --command="curl -fsS http://127.0.0.1/health >/dev/null 2>&1 && echo 'ready' || echo 'not-ready'" \ 2>/dev/null || echo "ssh-failed") - if [ "$SETUP_DONE" = "active" ]; then + if [ "$SETUP_DONE" = "ready" ]; then echo "" - success "VM setup complete! All services are running." + HOSTED_DAEMON_STATUS="ready" + save_cloud_config "$PROJECT_ID" "$GCP_ZONE" "$INSTANCE_NAME" "$VNC_PASSWORD" + success "VM setup complete! Hosted daemon health check passed." break fi @@ -912,47 +1047,11 @@ mkdir -p "$PANE_CONFIG_DIR" # Get GCP access token for API calls GCP_TOKEN=$(gcloud auth print-access-token 2>/dev/null || echo "") -TUNNEL_PORT=8080 +CURRENT_GCP_TOKEN="$GCP_TOKEN" +TUNNEL_PORT="$HOSTED_TUNNEL_PORT" if command -v jq &>/dev/null; then - if [ -f "$PANE_CONFIG" ]; then - # Merge cloud settings into existing config - jq --arg provider "gcp" \ - --arg token "$GCP_TOKEN" \ - --arg serverId "$INSTANCE_NAME" \ - --arg vncPw "$VNC_PASSWORD" \ - --arg projectId "$PROJECT_ID" \ - --arg zone "$GCP_ZONE" \ - --argjson port "$TUNNEL_PORT" \ - '.cloud = { - provider: $provider, - apiToken: $token, - serverId: $serverId, - vncPassword: $vncPw, - projectId: $projectId, - zone: $zone, - tunnelPort: $port - }' "$PANE_CONFIG" > "${PANE_CONFIG}.tmp" \ - && mv "${PANE_CONFIG}.tmp" "$PANE_CONFIG" - else - # Create new config with cloud settings - echo '{}' | jq --arg provider "gcp" \ - --arg token "$GCP_TOKEN" \ - --arg serverId "$INSTANCE_NAME" \ - --arg vncPw "$VNC_PASSWORD" \ - --arg projectId "$PROJECT_ID" \ - --arg zone "$GCP_ZONE" \ - --argjson port "$TUNNEL_PORT" \ - '.cloud = { - provider: $provider, - apiToken: $token, - serverId: $serverId, - vncPassword: $vncPw, - projectId: $projectId, - zone: $zone, - tunnelPort: $port - }' > "$PANE_CONFIG" - fi + save_cloud_config "$PROJECT_ID" "$GCP_ZONE" "$INSTANCE_NAME" "$VNC_PASSWORD" "$TUNNEL_PORT" success "Pane configured with cloud settings." info "Settings written to ${PANE_CONFIG}" info "Note: The GCP access token expires in ~1 hour. Pane auto-refreshes it via gcloud." @@ -968,17 +1067,26 @@ header "Setup Complete!" echo -e "${GREEN}${BOLD}Your Pane Cloud VM is ready!${NC}\n" -echo -e "${BOLD}Connect to your VM:${NC}" +echo -e "${BOLD}Connect to your hosted workspace:${NC}" echo "" -echo -e " ${CYAN}1. Start the IAP tunnel (run in a separate terminal):${NC}" +echo -e " ${CYAN}1. Start the daemon IAP tunnel (run in a separate terminal):${NC}" echo -e " ${BOLD}${TUNNEL_CMD}${NC}" echo "" -echo -e " ${CYAN}2. Open noVNC in your browser:${NC}" -echo -e " ${BOLD}${NOVNC_URL}${NC}" +echo -e " ${CYAN}2. Open Pane locally and click ${BOLD}Connect Cloud Runtime${NC}${CYAN}.${NC}" echo "" -echo -e " ${CYAN}3. Enter the VNC password when prompted${NC}" +echo -e " ${CYAN}3. Pane will connect to:${NC} ${BOLD}${HOSTED_DAEMON_BASE_URL}${NC}" echo "" +if [ "$HOSTED_ALLOW_NOVNC_FALLBACK" = "true" ] && [ -n "$NOVNC_URL" ]; then + echo -e "${BOLD}Optional noVNC fallback/debug access:${NC}" + echo -e " ${BOLD}${NOVNC_URL}${NC}" + echo -e " Password: ${BOLD}${VNC_PASSWORD}${NC}" + echo -e " Desktop fallback is manual so it does not compete with the hosted daemon:" + echo -e " ${BOLD}sudo supervisorctl stop pane-cloud:pane-daemon${NC}" + echo -e " ${BOLD}sudo supervisorctl start pane-cloud:PaneDesktop${NC}" + echo "" +fi + echo -e "${BOLD}SSH access:${NC}" echo -e " ${BOLD}${SSH_CMD}${NC}" echo "" @@ -992,11 +1100,12 @@ echo "" echo -e "${BOLD}Cost management:${NC}" echo -e " Stop VM: gcloud compute instances stop ${INSTANCE_NAME} --zone=${GCP_ZONE} --project=${PROJECT_ID}" echo -e " Start VM: gcloud compute instances start ${INSTANCE_NAME} --zone=${GCP_ZONE} --project=${PROJECT_ID}" -echo -e " Delete VM: cd ${TERRAFORM_DIR} && terraform destroy -var=\"project_id=${PROJECT_ID}\" -var=\"user_id=${USER_ID}\"" +echo -e " Delete VM: bash cloud/scripts/setup-cloud.sh --destroy" echo "" echo -e "${BOLD}Security:${NC}" echo -e " - No public IP — VM is only accessible via GCP IAP tunnel" echo -e " - All traffic authenticated through your Google account" +echo -e " - Pane daemon requests require the generated bearer token in your linked local profile" echo -e " - Daily snapshots with 7-day retention for backups" echo "" diff --git a/cloud/scripts/setup-vm.sh b/cloud/scripts/setup-vm.sh index 600f6155..503fb91a 100755 --- a/cloud/scripts/setup-vm.sh +++ b/cloud/scripts/setup-vm.sh @@ -1,6 +1,7 @@ #!/bin/bash # Pane Cloud VM Setup Script -# Installs all dependencies and configures the noVNC display stack +# Installs all dependencies and configures a daemon-first hosted workspace +# with optional noVNC fallback/debug access. # Run on a fresh Ubuntu 24.04 VM as root # # Usage: sudo bash setup-vm.sh [--pane-version VERSION] @@ -26,7 +27,7 @@ echo "" # ============================================================ # 1. System packages # ============================================================ -echo "[1/8] Installing system packages..." +echo "[1/10] Installing system packages..." export DEBIAN_FRONTEND=noninteractive apt-get update -qq @@ -77,15 +78,56 @@ echo " Done." hash -r # ============================================================ -# 2. Node.js 20 LTS +# 2. Read instance metadata for hosted workspace bootstrap # ============================================================ -echo "[2/8] Installing Node.js 20 LTS..." +metadata_value() { + local key="$1" + curl -sf -H "Metadata-Flavor: Google" \ + "http://metadata.google.internal/computeMetadata/v1/instance/attributes/${key}" 2>/dev/null || true +} + +normalize_bool() { + local value + value="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]')" + case "$value" in + 1|true|yes|on) + echo "true" + ;; + *) + echo "false" + ;; + esac +} + +REMOTE_CLIENT_ID="$(metadata_value remote-client-id)" +REMOTE_CLIENT_LABEL="$(metadata_value remote-client-label)" +REMOTE_CLIENT_TOKEN="$(metadata_value remote-client-token)" +REMOTE_DAEMON_PORT="$(metadata_value remote-daemon-port)" +ENABLE_NOVNC_FALLBACK="$(normalize_bool "$(metadata_value enable-novnc-fallback)")" + +if ! [[ "$REMOTE_DAEMON_PORT" =~ ^[0-9]+$ ]] || [ "$REMOTE_DAEMON_PORT" -lt 1 ] || [ "$REMOTE_DAEMON_PORT" -gt 65535 ]; then + REMOTE_DAEMON_PORT=42137 +fi + +if [ -z "$REMOTE_CLIENT_LABEL" ]; then + REMOTE_CLIENT_LABEL="Pane Cloud Workspace" +fi -# Always ensure we have Node 20+ (Ubuntu 24.04 ships with Node 18) +echo "[2/10] Hosted workspace metadata" +echo " Daemon port: ${REMOTE_DAEMON_PORT}" +echo " Remote client id: ${REMOTE_CLIENT_ID:-}" +echo " noVNC fallback enabled: ${ENABLE_NOVNC_FALLBACK}" + +# ============================================================ +# 3. Node.js 22 LTS +# ============================================================ +echo "[3/10] Installing Node.js 22 LTS..." + +# Always ensure we have Node 22+ (the repo requires >= 22.14) NODE_MAJOR=$(node --version 2>/dev/null | sed 's/v\([0-9]*\).*/\1/' || echo "0") -if [ "$NODE_MAJOR" -lt 20 ]; then - echo " Current Node version: $(node --version 2>/dev/null || echo 'none'). Upgrading to Node 20..." - curl -fsSL https://deb.nodesource.com/setup_20.x | bash - +if [ "$NODE_MAJOR" -lt 22 ]; then + echo " Current Node version: $(node --version 2>/dev/null || echo 'none'). Upgrading to Node 22..." + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - apt-get install -y -qq nodejs > /dev/null fi @@ -101,9 +143,9 @@ fi echo " Node $(node --version), pnpm $(pnpm --version 2>/dev/null || echo 'not installed')" # ============================================================ -# 3. GitHub CLI +# 4. GitHub CLI # ============================================================ -echo "[3/8] Installing GitHub CLI..." +echo "[4/10] Installing GitHub CLI..." if ! command -v gh &> /dev/null; then (type -p wget >/dev/null || apt-get install wget -y -qq) \ && mkdir -p -m 755 /etc/apt/keyrings \ @@ -118,9 +160,9 @@ fi echo " Done." # ============================================================ -# 4. Claude Code CLI +# 5. Claude Code CLI # ============================================================ -echo "[4/8] Installing Claude Code CLI..." +echo "[5/10] Installing Claude Code CLI..." if ! command -v claude &> /dev/null; then # Install latest Claude Code (intentionally unpinned — VM should have newest version) npm install -g @anthropic-ai/claude-code > /dev/null 2>&1 || true @@ -128,9 +170,9 @@ fi echo " Done." # ============================================================ -# 5. Install Pane +# 6. Install Pane # ============================================================ -echo "[5/9] Installing Pane..." +echo "[6/10] Installing Pane..." ARCH=$(dpkg --print-architecture) if [ ! -f /usr/bin/Pane ]; then # Download the latest Pane AppImage from GitHub Releases @@ -168,9 +210,9 @@ if [ ! -f /usr/bin/Pane ] && ! command -v Pane &>/dev/null; then fi # ============================================================ -# 6. Create Pane user +# 7. Create Pane user # ============================================================ -echo "[6/9] Setting up Pane user..." +echo "[7/10] Setting up Pane user..." if ! id "${PANE_USER}" &>/dev/null; then useradd -m -s /bin/bash "${PANE_USER}" fi @@ -220,9 +262,9 @@ chown "${PANE_USER}:${PANE_USER}" "${FLUXBOX_DIR}/init" echo " Fluxbox configured (no title bar, no toolbar)" # ============================================================ -# 7. Get or generate VNC password +# 8. Get or generate optional VNC password # ============================================================ -echo "[7/9] Setting up VNC password..." +echo "[8/10] Setting up optional VNC password..." VNC_PASSWORD_FILE="/home/${PANE_USER}/.vnc_password" # Try to get VNC password from instance metadata (set by Terraform) @@ -243,17 +285,78 @@ chown "${PANE_USER}:${PANE_USER}" "${VNC_PASSWORD_FILE}" echo " VNC password saved to ${VNC_PASSWORD_FILE}" # ============================================================ -# 8. Configure supervisord +# 9. Prepare Pane daemon config and supervisord # ============================================================ -echo "[8/9] Configuring supervisord..." +echo "[9/10] Preparing Pane daemon config..." # Get Pane user's UID for XDG_RUNTIME_DIR PANE_UID=$(id -u "${PANE_USER}") +PANE_CONFIG_FILE="/home/${PANE_USER}/.pane/config.json" +REMOTE_CLIENT_TOKEN_HASH="" + +if [ -n "$REMOTE_CLIENT_TOKEN" ]; then + REMOTE_CLIENT_TOKEN_HASH="$(printf '%s' "$REMOTE_CLIENT_TOKEN" | sha256sum | awk '{print $1}')" +fi + +HOST_CLIENTS_JSON='[]' +if [ -n "$REMOTE_CLIENT_ID" ] && [ -n "$REMOTE_CLIENT_TOKEN_HASH" ]; then + HOST_CLIENTS_JSON="$(jq -cn \ + --arg id "$REMOTE_CLIENT_ID" \ + --arg label "$REMOTE_CLIENT_LABEL" \ + --arg createdAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg tokenHash "$REMOTE_CLIENT_TOKEN_HASH" \ + '[{ + id: $id, + label: $label, + createdAt: $createdAt, + tokenHash: $tokenHash + }]')" +else + echo " WARNING: remote client metadata is incomplete; hosted daemon auth will rely on any existing local config." +fi + +EXISTING_PANE_CONFIG='{}' +if [ -f "$PANE_CONFIG_FILE" ]; then + EXISTING_PANE_CONFIG="$(cat "$PANE_CONFIG_FILE" 2>/dev/null || echo '{}')" +fi + +UPDATED_PANE_CONFIG="$( + printf '%s' "$EXISTING_PANE_CONFIG" | jq \ + --argjson daemonPort "$REMOTE_DAEMON_PORT" \ + --argjson hostClients "$HOST_CLIENTS_JSON" \ + ' + .remoteDaemon = (.remoteDaemon // {}) + | .remoteDaemon.host = (.remoteDaemon.host // {}) + | .remoteDaemon.host.config = { + enabled: true, + listenHost: "127.0.0.1", + listenPort: $daemonPort, + pairingRequired: true, + allowInsecureHttpOnLoopback: true + } + | if ($hostClients | length) > 0 + then .remoteDaemon.host.clients = $hostClients + else .remoteDaemon.host.clients = (.remoteDaemon.host.clients // []) + end + | .remoteDaemon.client = (.remoteDaemon.client // { + profiles: [], + activeProfileId: null, + mode: "local" + }) + ' +)" + +printf '%s\n' "$UPDATED_PANE_CONFIG" > "$PANE_CONFIG_FILE" +chown "${PANE_USER}:${PANE_USER}" "$PANE_CONFIG_FILE" +chmod 600 "$PANE_CONFIG_FILE" + +echo " Pane daemon config written to ${PANE_CONFIG_FILE}" +echo " Configuring supervisord..." cat > /etc/supervisor/conf.d/pane-stack.conf << SUPERVISOR_EOF -; ============================================================= -; Pane Cloud Display Stack -; ============================================================= +; ============================================================ +; Pane Cloud Hosted Workspace Stack +; ============================================================ [program:xvfb] command=/usr/bin/Xvfb :99 -screen 0 1920x1080x24 -ac +extension GLX +render -noreset @@ -262,71 +365,112 @@ autorestart=true stdout_logfile=/var/log/supervisor/xvfb.log stderr_logfile=/var/log/supervisor/xvfb-error.log +[program:pane-daemon] +command=/usr/bin/Pane --no-sandbox --daemon-headless --pane-dir /home/${PANE_USER}/.pane +priority=20 +autorestart=true +environment=DISPLAY=":99",HOME="/home/${PANE_USER}",XDG_RUNTIME_DIR="/run/user/${PANE_UID}" +user=${PANE_USER} +directory=/home/${PANE_USER} +stdout_logfile=/var/log/supervisor/pane-daemon.log +stderr_logfile=/var/log/supervisor/pane-daemon-error.log +startsecs=5 +startretries=5 +SUPERVISOR_EOF + +SUPERVISOR_GROUP_PROGRAMS="xvfb,pane-daemon" + +if [ "$ENABLE_NOVNC_FALLBACK" = "true" ]; then + cat >> /etc/supervisor/conf.d/pane-stack.conf << SUPERVISOR_FALLBACK_EOF + [program:fluxbox] command=/usr/bin/fluxbox -priority=20 +priority=30 autorestart=true environment=DISPLAY=":99" -user=Pane +user=${PANE_USER} stdout_logfile=/var/log/supervisor/fluxbox.log stderr_logfile=/var/log/supervisor/fluxbox-error.log -[program:Pane] -command=/usr/bin/Pane --no-sandbox --start-fullscreen -priority=30 -autorestart=true -environment=DISPLAY=":99",HOME="/home/Pane",XDG_RUNTIME_DIR="/run/user/${PANE_UID}" -user=Pane -directory=/home/Pane +[program:PaneDesktop] +command=/usr/bin/Pane --no-sandbox --start-fullscreen --pane-dir /home/${PANE_USER}/.pane +priority=40 +autostart=false +autorestart=false +environment=DISPLAY=":99",HOME="/home/${PANE_USER}",XDG_RUNTIME_DIR="/run/user/${PANE_UID}" +user=${PANE_USER} +directory=/home/${PANE_USER} stdout_logfile=/var/log/supervisor/Pane.log stderr_logfile=/var/log/supervisor/pane-error.log -; Give Pane time to start before considering it failed startsecs=5 -; Restart up to 5 times if it crashes startretries=5 [program:x11vnc] command=/usr/bin/x11vnc -display :99 -passwd ${VNC_PASSWORD} -forever -rfbport 5900 -localhost -noxdamage -cursor arrow -noxfixes -priority=40 +priority=50 autorestart=true -user=Pane +user=${PANE_USER} stdout_logfile=/var/log/supervisor/x11vnc.log stderr_logfile=/var/log/supervisor/x11vnc-error.log [program:websockify] command=/usr/bin/websockify --web=/usr/share/novnc 6080 localhost:5900 -priority=50 +priority=60 autorestart=true stdout_logfile=/var/log/supervisor/websockify.log stderr_logfile=/var/log/supervisor/websockify-error.log +SUPERVISOR_FALLBACK_EOF + + SUPERVISOR_GROUP_PROGRAMS="xvfb,pane-daemon,fluxbox,PaneDesktop,x11vnc,websockify" +fi + +cat >> /etc/supervisor/conf.d/pane-stack.conf << SUPERVISOR_GROUP_EOF [group:pane-cloud] -programs=xvfb,fluxbox,Pane,x11vnc,websockify +programs=${SUPERVISOR_GROUP_PROGRAMS} priority=999 -SUPERVISOR_EOF +SUPERVISOR_GROUP_EOF echo " Done." # ============================================================ -# 9. Configure NGINX +# 10. Configure NGINX # ============================================================ -echo "[9/9] Configuring NGINX..." +echo "[10/10] Configuring NGINX..." -cat > /etc/nginx/sites-available/pane-cloud << 'NGINX_EOF' -# Pane Cloud - NGINX reverse proxy for noVNC -# TLS will be configured by certbot after domain is set up +cat > /etc/nginx/sites-available/pane-cloud << NGINX_EOF +# Pane Cloud - NGINX reverse proxy for the hosted workspace daemon +# noVNC stays optional and is only exposed when fallback is enabled. server { listen 80 default_server; server_name _; - # Health check endpoint - location /health { + location = /health { access_log off; - return 200 'ok'; - add_header Content-Type text/plain; + proxy_pass http://127.0.0.1:${REMOTE_DAEMON_PORT}/health; + proxy_http_version 1.1; + proxy_set_header Host \$host; + proxy_buffering off; } + location /daemon/ { + proxy_pass http://127.0.0.1:${REMOTE_DAEMON_PORT}/; + proxy_http_version 1.1; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } +NGINX_EOF + +if [ "$ENABLE_NOVNC_FALLBACK" = "true" ]; then + cat >> /etc/nginx/sites-available/pane-cloud << 'NGINX_FALLBACK_EOF' + # noVNC static files location /novnc/ { alias /usr/share/novnc/; @@ -346,12 +490,22 @@ server { proxy_buffering off; } - # Default: redirect to noVNC location / { return 301 /novnc/vnc.html?autoconnect=true&resize=scale&reconnect=true&reconnect_delay=1000; } } -NGINX_EOF +NGINX_FALLBACK_EOF +else + cat >> /etc/nginx/sites-available/pane-cloud << 'NGINX_DAEMON_ONLY_EOF' + + location / { + access_log off; + default_type text/plain; + return 200 'Pane hosted daemon ready. Use /daemon/ through your authenticated tunnel.'; + } +} +NGINX_DAEMON_ONLY_EOF +fi # Enable the site ln -sf /etc/nginx/sites-available/pane-cloud /etc/nginx/sites-enabled/pane-cloud @@ -379,14 +533,26 @@ systemctl restart nginx echo "" echo "=== Setup Complete ===" echo "" -echo "VNC password: ${VNC_PASSWORD}" +echo "Hosted daemon loopback port: ${REMOTE_DAEMON_PORT}" +echo "Hosted daemon health endpoint: http://127.0.0.1/health" +echo "Hosted daemon reverse-proxy path: http://127.0.0.1/daemon/" echo "" echo "Access via IAP tunnel:" echo " gcloud compute start-iap-tunnel 80 --local-host-port=localhost:8080 --zone= --project=" -echo " Then open: http://localhost:8080/novnc/vnc.html?autoconnect=true&resize=scale" +echo " Then connect your local Pane client to: http://127.0.0.1:8080/daemon/" echo "" -echo "First-run auth (do this in the noVNC session):" -echo " 1. gh auth login (GitHub)" -echo " 2. claude login (Claude Code)" +if [ "$ENABLE_NOVNC_FALLBACK" = "true" ]; then + echo "noVNC fallback is enabled." + echo " The desktop app is not auto-started so it does not compete with the headless daemon." + echo " To debug the desktop app:" + echo " sudo supervisorctl stop pane-cloud:pane-daemon" + echo " sudo supervisorctl start pane-cloud:PaneDesktop" + echo " VNC password: ${VNC_PASSWORD}" + echo " Browser URL: http://localhost:8080/novnc/vnc.html?autoconnect=true&resize=scale" + echo "" +fi +echo "First-run auth (SSH in via IAP, or use the optional noVNC fallback if enabled):" +echo " 1. gh auth login (GitHub)" +echo " 2. claude login (Claude Code)" echo " 3. Set API keys in Pane Settings" echo "" diff --git a/cloud/terraform/gcp/main.tf b/cloud/terraform/gcp/main.tf index 9c79fc31..4c255243 100644 --- a/cloud/terraform/gcp/main.tf +++ b/cloud/terraform/gcp/main.tf @@ -4,8 +4,13 @@ # # Usage: # terraform init -# terraform plan -var="project_id=YOUR_PROJECT" -var="user_id=user123" -# terraform apply -var="project_id=YOUR_PROJECT" -var="user_id=user123" +# terraform plan \ +# -var="project_id=YOUR_PROJECT" \ +# -var="user_id=user123" \ +# -var="vnc_password=RECOVERY_PASSWORD" \ +# -var="remote_client_id=cloud-user123" \ +# -var="remote_client_label=Pane Cloud Workspace" \ +# -var="remote_client_token=GENERATED_TOKEN" terraform { required_version = ">= 1.5" @@ -70,6 +75,34 @@ variable "vnc_password" { sensitive = true } +variable "remote_client_id" { + description = "Stable remote profile/client ID for the hosted workspace daemon" + type = string +} + +variable "remote_client_label" { + description = "Human-readable label for the hosted workspace daemon client" + type = string +} + +variable "remote_client_token" { + description = "Bearer token the hosted workspace daemon will accept from the linked client profile" + type = string + sensitive = true +} + +variable "remote_daemon_port" { + description = "Loopback port the hosted Pane daemon listens on inside the VM" + type = number + default = 42137 +} + +variable "enable_novnc_fallback" { + description = "Whether to keep the legacy noVNC desktop stack available for fallback/debug access" + type = bool + default = false +} + variable "snapshot_start_time" { description = "Daily snapshot start time (HH:MM format, UTC)" type = string @@ -197,9 +230,14 @@ resource "google_compute_instance" "pane" { # VM is only reachable via IAP tunnel } - # Pass VNC password via instance metadata so we have it immediately + # Pass hosted workspace bootstrap settings via instance metadata. metadata = { - vnc-password = var.vnc_password + vnc-password = var.vnc_password + remote-client-id = var.remote_client_id + remote-client-label = var.remote_client_label + remote-client-token = var.remote_client_token + remote-daemon-port = tostring(var.remote_daemon_port) + enable-novnc-fallback = tostring(var.enable_novnc_fallback) } metadata_startup_script = file("${path.module}/../../scripts/setup-vm.sh") @@ -280,13 +318,49 @@ output "ssh_command" { } output "novnc_tunnel_command" { - description = "Start IAP tunnel to access noVNC on localhost:8080" + description = "Legacy/fallback tunnel command; the same tunnel carries daemon and optional noVNC traffic" value = "gcloud compute start-iap-tunnel pane-${var.user_id} 80 --local-host-port=localhost:8080 --zone=${var.zone} --project=${var.project_id}" } +output "daemon_tunnel_command" { + description = "Start the IAP tunnel used by the hosted workspace daemon and optional noVNC fallback" + value = "gcloud compute start-iap-tunnel pane-${var.user_id} 80 --local-host-port=localhost:8080 --zone=${var.zone} --project=${var.project_id}" +} + +output "daemon_base_url" { + description = "Base URL the local Pane client should use while the IAP tunnel is running" + value = "http://127.0.0.1:8080/daemon/" +} + +output "remote_client_id" { + description = "Stable hosted workspace remote profile/client ID" + value = var.remote_client_id +} + +output "remote_client_label" { + description = "Human-readable hosted workspace remote profile/client label" + value = var.remote_client_label +} + +output "remote_client_token" { + description = "Bearer token the hosted workspace daemon accepts from the linked client" + value = var.remote_client_token + sensitive = true +} + +output "remote_daemon_port" { + description = "Loopback port the hosted Pane daemon listens on inside the VM" + value = var.remote_daemon_port +} + output "novnc_url" { - description = "Open this in browser AFTER starting the IAP tunnel" - value = "http://localhost:8080/novnc/vnc.html?autoconnect=true&resize=scale" + description = "Open this in browser AFTER starting the IAP tunnel when noVNC fallback is enabled" + value = var.enable_novnc_fallback ? "http://localhost:8080/novnc/vnc.html?autoconnect=true&resize=scale" : "" +} + +output "novnc_fallback_enabled" { + description = "Whether the hosted VM keeps the legacy noVNC desktop stack enabled" + value = var.enable_novnc_fallback } output "setup_log_command" { diff --git a/deploy/remote-pwa/Dockerfile b/deploy/remote-pwa/Dockerfile new file mode 100644 index 00000000..2e14de99 --- /dev/null +++ b/deploy/remote-pwa/Dockerfile @@ -0,0 +1,6 @@ +FROM nginxinc/nginx-unprivileged:1.27-alpine + +COPY deploy/remote-pwa/nginx.conf /etc/nginx/conf.d/default.conf +COPY frontend/dist/ /usr/share/nginx/html/ + +EXPOSE 8080 diff --git a/deploy/remote-pwa/nginx.conf b/deploy/remote-pwa/nginx.conf new file mode 100644 index 00000000..c776c4ca --- /dev/null +++ b/deploy/remote-pwa/nginx.conf @@ -0,0 +1,63 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index remote.html; + + include /etc/nginx/mime.types; + types { + application/manifest+json webmanifest; + } + default_type application/octet-stream; + + server_tokens off; + absolute_redirect off; + + gzip on; + gzip_types + application/javascript + application/json + application/manifest+json + image/svg+xml + text/css + text/javascript + text/plain; + + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + location = /healthz { + access_log off; + add_header Content-Type "text/plain; charset=utf-8"; + return 200 "ok\n"; + } + + location = / { + return 308 /app/; + } + + location = /app { + return 308 /app/; + } + + location = /remote.html { + return 308 /app/; + } + + location ~ ^/app/assets/(.*)$ { + rewrite ^/app/(.*)$ /$1 break; + add_header Cache-Control "public, max-age=31536000, immutable" always; + try_files $uri =404; + } + + location ~ ^/app/(.+\.(ico|json|png|svg|txt|webmanifest))$ { + rewrite ^/app/(.*)$ /$1 break; + add_header Cache-Control "no-cache" always; + try_files $uri =404; + } + + location /app/ { + add_header Cache-Control "no-cache" always; + try_files /remote.html =404; + } +} diff --git a/docs/SELF_HOSTED_REMOTE_DAEMON.md b/docs/SELF_HOSTED_REMOTE_DAEMON.md new file mode 100644 index 00000000..3705b796 --- /dev/null +++ b/docs/SELF_HOSTED_REMOTE_DAEMON.md @@ -0,0 +1,191 @@ +# Self-Hosted Remote Daemon + +This guide covers running Pane on your own workstation, Mac mini, Linux box, or VM and connecting to it from the local Pane desktop app. + +The intended flow is: + +1. Run one setup command on the remote machine. +2. Copy the generated `pane-remote://...` connection code. +3. Paste it into local Pane under `Settings > Self-Hosted Remote Daemon > Import Remote Connection`. + +Pane saves the profile and attempts to connect immediately. Local desktop mode is unchanged until a remote profile is imported and activated. + +## One-Command Setup + +From a source checkout: + +```bash +pnpm remote:setup -- --label "VM" +``` + +For validation against a separate data directory: + +```bash +PANE_DIR=/tmp/pane-remote-vm pnpm remote:setup -- --label "VM" --print-only +``` + +Packaged Pane builds can run the same setup path without opening a window: + +```bash +pane --remote-setup --label "VM" +``` + +The setup command: + +- detects Linux, macOS, or Windows host behavior +- writes remote daemon config into one `PANE_DIR` (default `~/.pane_remote`) +- enables the loopback listener on `127.0.0.1:42137` +- creates a paired client record with a hashed token on the host +- emits the raw token only inside the one-time `pane-remote://...` import code +- attempts to install and start a user-level daemon service +- prints the manual daemon command if service setup is unavailable +- detects Tailscale Serve where possible and otherwise prints an SSH local-forward command + +Useful options: + +```bash +pnpm remote:setup -- --help +pnpm remote:setup -- --channel nightly +pnpm remote:setup -- --pane-dir "$HOME/.pane_remote" +pnpm remote:setup -- --prefer-tunnel ssh +pnpm remote:setup -- --no-install-service +pnpm remote:setup -- --no-tailscale-serve +``` + +## Import Locally + +On your local desktop machine: + +1. Open Pane. +2. Go to `Settings > Self-Hosted Remote Daemon`. +3. Paste the full `pane-remote://...` code into `Import Remote Connection`. +4. Click `Import & Connect`. + +If the tunnel is not reachable yet, Pane still saves the profile and shows the connection error. Start the printed SSH/Tailscale tunnel and click `Connect` on the saved profile. + +## Security Model + +- The daemon listener only supports loopback hosts: `127.0.0.1`, `::1`, or `localhost`. +- Direct public or LAN binding is intentionally rejected. +- Use SSH local forwarding, Tailscale Serve, or a trusted HTTPS reverse proxy that forwards to loopback. +- Treat the generated `pane-remote://...` code like a secret. It contains the bearer token needed by the local client. + +Tailscale Serve example generated by setup: + +```bash +tailscale serve --bg http://127.0.0.1:42137 +``` + +SSH fallback generated by setup: + +```bash +ssh -N -L 42137:127.0.0.1:42137 user@your-host +``` + +## Manual Advanced Flow + +The old manual flow still works and is useful for debugging. + +### 1. Choose the Host Data Directory + +```bash +export PANE_DIR="$HOME/.pane_remote" +mkdir -p "$PANE_DIR" +``` + +Pane stores config and database files under that directory. The setup command, desktop app, and headless daemon must use the same `PANE_DIR`. + +### 2. Configure the Remote Listener + +Launch Pane on the host against that directory: + +```bash +PANE_DIR="$HOME/.pane_remote" pnpm dev +``` + +In `Settings > Self-Hosted Remote Daemon`: + +1. Enable `Enable remote daemon listener`. +2. Keep `Listen Host` on `127.0.0.1`. +3. Keep or change `Listen Port`, default `42137`. +4. Leave `Require pairing / saved bearer tokens` enabled. +5. Leave `Allow direct HTTP on loopback` enabled. +6. Save host settings. + +### 3. Create a Paired Connection + +From the same settings section on the host: + +1. Enter a label such as `Office Mac mini`. +2. Enter the base URL the client will use after tunneling, for example `http://127.0.0.1:42137`. +3. Click `Create Paired Profile`. + +Pane adds a host-side allowed client record, adds a matching local profile, and shows the generated bearer token once. + +### 4. Start the Headless Daemon + +```bash +PANE_DIR="$HOME/.pane_remote" pnpm daemon:headless +``` + +On success: + +```text +[Pane daemon] Headless host ready on tcp:127.0.0.1:42137 +``` + +### 5. Connect the Desktop Client + +Use `Import Remote Connection` for a generated code, or use `Save Existing Remote Profile` with: + +- label +- base URL +- bearer token + +Then click `Connect` on the saved profile. + +## Validation + +Recommended checks after connecting: + +1. Verify projects and sessions load in the client. +2. Open a terminal-backed session and confirm output streaming works. +3. Send terminal input and verify the remote runtime receives it. +4. Resize a terminal and confirm the remote terminal resizes. +5. Open a file and confirm read/write works. +6. Check git status and commit/diff flows. +7. Run an approve-mode command and confirm the permission dialog appears on the client. + +## Troubleshooting + +### The headless daemon starts but remote connect fails + +Check: + +- the daemon uses the same `PANE_DIR` that setup wrote +- the tunnel/proxy forwards to the same loopback port as the host config +- the client profile base URL matches the client-side tunnel endpoint +- the `pane-remote://...` code was not truncated + +### I changed host settings but nothing happened + +The headless daemon watches config and starts or stops the remote transport based on saved host config. If behavior looks stale, restart the daemon once and verify the correct `PANE_DIR`. + +### Why can’t I bind to `0.0.0.0` or a LAN IP? + +That is intentionally blocked. The current security model is loopback plus a secure exposure layer. + +### Why are some actions disabled in remote mode? + +Some actions operate on the local desktop client machine rather than the remote workspace. Pane currently disables or keeps local-only behavior for: + +- opening a local IDE from the client +- revealing files in the client OS file manager +- the native clipboard-image fallback path + +## Current Limitations + +- No hosted relay, NAT traversal, or account-based multi-tenant auth +- No web/mobile client in this phase +- No direct non-loopback listener support +- No full live remote end-to-end CI harness yet diff --git a/docs/remote-daemon-lifecycle.md b/docs/remote-daemon-lifecycle.md new file mode 100644 index 00000000..19c86c97 --- /dev/null +++ b/docs/remote-daemon-lifecycle.md @@ -0,0 +1,33 @@ +# Remote Daemon Lifecycle + +This is the implementation checklist for Remote Pane setup, teardown, and runtime switching. It exists to keep config writes, runtime controller actions, and renderer refreshes in sync. + +## Runtime Roles + +- Host lifecycle is owned by `PaneRemoteTransportController` and `remoteHostRuntimeStateStore`. +- Client lifecycle is owned by `RemotePaneClientController`. +- IPC handlers in `main/src/ipc/remoteDaemon.ts` orchestrate config writes and controller calls. +- Renderer runtime changes are reconciled through `remote-daemon:resync-required`. + +## Lifecycle Matrix + +| Action | Main-process side effect | Renderer side effect | +| --- | --- | --- | +| Import connection and connect succeeds | Activate profile, save/dedupe profile, set remote mode | Resync config, sessions, panels | +| Import connection and connect fails | Save/dedupe profile, keep current active runtime | No runtime resync | +| Connect saved profile | Activate profile before persisting remote mode | Resync config, sessions, panels | +| Switch to local runtime | Disconnect active remote client, save local mode | Resync config, sessions, panels, clear stale active session | +| Delete inactive profile | Remove profile | No runtime resync | +| Delete active profile | Switch local, remove profile, save local mode | Resync config, sessions, panels | +| Enable or update host | Save host config, transport controller syncs to live or error | Host-state event updates UI | +| Stop host | Save disabled host config, transport controller stops server | Host-state event updates UI | +| Disconnect host clients | Drop matching SSE clients | Host-state event updates client count | +| Revoke host client | Remove saved client record, drop matching SSE clients | Host-state event updates client count | + +## Guardrails + +- Do not persist remote mode until the selected profile has successfully activated. +- Failed import-connect saves the profile but must not switch runtime or emit a renderer resync. +- Connected remote clients are runtime state, not saved client records. +- Current Pane Data hosting is live only while that Pane app is running. +- Isolated daemon data can install a background service; Current Pane Data should not. diff --git a/frontend/public/remote-manifest.webmanifest b/frontend/public/remote-manifest.webmanifest new file mode 100644 index 00000000..a3ee2955 --- /dev/null +++ b/frontend/public/remote-manifest.webmanifest @@ -0,0 +1,11 @@ +{ + "name": "Remote Pane", + "short_name": "Remote Pane", + "description": "Connect to a remote Pane host from a browser.", + "start_url": "/app/", + "scope": "/app/", + "display": "standalone", + "background_color": "#f7fafc", + "theme_color": "#f7fafc", + "orientation": "any" +} diff --git a/frontend/remote.html b/frontend/remote.html new file mode 100644 index 00000000..33f55411 --- /dev/null +++ b/frontend/remote.html @@ -0,0 +1,15 @@ + + + + + + + + + Remote Pane + + +
+ + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d6b9f50d..97c7eeee 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,10 +36,15 @@ import { CreateSessionDialog } from './components/CreateSessionDialog'; import { AddProjectDialog } from './components/AddProjectDialog'; import { useNavigationStore } from './stores/navigationStore'; import { initPostHog, capture, captureUnconditionally, posthog } from './services/posthog'; -import type { VersionUpdateInfo, PermissionInput } from './types/session'; +import type { VersionUpdateInfo } from './types/session'; import type { AnalyticsIdentity, TerminalShortcut } from './types/config'; import type { ResumableSession } from '../../shared/types/panels'; import type { Project } from './types/project'; +import type { + PanePermissionRequest, + PanePermissionResolvedEvent, + PanePermissionInput, +} from '../../shared/types/daemon'; import { isMac } from './utils/platformUtils'; // Stable empty array to avoid creating new references in render @@ -52,14 +57,6 @@ interface IPCResponse { error?: string; } -interface PermissionRequest { - id: string; - sessionId: string; - toolName: string; - input: PermissionInput; - timestamp: number; -} - function App() { const [isWelcomeOpen, setIsWelcomeOpen] = useState(false); const [isAnalyticsConsentOpen, setIsAnalyticsConsentOpen] = useState(false); @@ -67,7 +64,7 @@ function App() { const [isAboutOpen, setIsAboutOpen] = useState(false); const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false); const [updateVersionInfo, setUpdateVersionInfo] = useState(null); - const [currentPermissionRequest, setCurrentPermissionRequest] = useState(null); + const [currentPermissionRequest, setCurrentPermissionRequest] = useState(null); const [isDiscordOpen, setIsDiscordOpen] = useState(false); const [hasCheckedWelcome, setHasCheckedWelcome] = useState(false); const [isOnboardingOpen, setIsOnboardingOpen] = useState(false); @@ -204,6 +201,8 @@ function App() { // Detect unclean shutdown from previous session and notify user useEffect(() => { + if (!window.electronAPI?.events?.onUncleanShutdownDetected) return; + return window.electronAPI.events.onUncleanShutdownDetected(() => { showNotification( 'Pane didn\'t shut down cleanly', @@ -509,19 +508,41 @@ function App() { checkResumableSessions(); }, [isLoaded, isAnalyticsConsentOpen]); + const loadNextPendingPermission = useCallback(async () => { + try { + const result = await API.permissions.getPending(); + if (result.success) { + setCurrentPermissionRequest(result.data?.[0] ?? null); + } else { + console.error('Failed to fetch pending permission requests:', result.error); + } + } catch (error) { + console.error('Failed to load pending permission requests:', error); + } + }, []); + useEffect(() => { - // Set up permission request listener - const handlePermissionRequest = (...args: unknown[]) => { - const request = args[0] as PermissionRequest; + if (!window.electronAPI?.events) { + return; + } + + const removePermissionRequest = window.electronAPI.events.onPermissionRequest((request: PanePermissionRequest) => { setCurrentPermissionRequest(request); - }; + }); + const removePermissionResolved = window.electronAPI.events.onPermissionResolved((event: PanePermissionResolvedEvent) => { + setCurrentPermissionRequest((currentRequest) => ( + currentRequest?.id === event.request.id ? null : currentRequest + )); + void loadNextPendingPermission(); + }); - window.electron?.on('permission:request', handlePermissionRequest); + void loadNextPendingPermission(); return () => { - window.electron?.off('permission:request', handlePermissionRequest); + removePermissionRequest(); + removePermissionResolved(); }; - }, []); + }, [loadNextPendingPermission]); useEffect(() => { // Set up version update listener @@ -559,17 +580,26 @@ function App() { setIsUpdateDialogOpen(true); }; - const handlePermissionResponse = async (requestId: string, behavior: 'allow' | 'deny', _updatedInput?: PermissionInput, message?: string) => { + const handlePermissionResponse = useCallback(async ( + requestId: string, + behavior: 'allow' | 'deny', + updatedInput?: PanePermissionInput, + message?: string, + ) => { try { - await API.permissions.respond(requestId, { - allow: behavior === 'allow', - reason: message + const result = await API.permissions.respond(requestId, { + behavior, + updatedInput, + message, }); - setCurrentPermissionRequest(null); + if (!result.success) { + throw new Error(result.error || 'Failed to respond to permission request'); + } + await loadNextPendingPermission(); } catch (error) { console.error('Failed to respond to permission request:', error); } - }; + }, [loadNextPendingPermission]); return ( diff --git a/frontend/src/components/CloudWidget.tsx b/frontend/src/components/CloudWidget.tsx index fbcb3dfb..3f5eee2b 100644 --- a/frontend/src/components/CloudWidget.tsx +++ b/frontend/src/components/CloudWidget.tsx @@ -1,9 +1,12 @@ import { useEffect, useCallback, useState } from 'react'; import { Play, Square, Loader2, Cloud, Monitor, Terminal } from 'lucide-react'; +import { createDefaultCloudVmState } from '../../../shared/types/cloud'; import { useCloudStore } from '../stores/cloudStore'; import { useSessionStore } from '../stores/sessionStore'; import { panelApi } from '../services/panelApi'; +const DEFAULT_CLOUD_STATE = createDefaultCloudVmState(); + export function CloudWidget() { const { vmState, showCloudView, loading, setVmState, setLoading, setShowCloudView, toggleCloudView } = useCloudStore(); const activeSessionId = useSessionStore((state) => state.activeSessionId); @@ -56,7 +59,7 @@ export function CloudWidget() { } } catch (err) { setVmState({ - ...(vmState ?? { status: 'unknown', ip: null, noVncUrl: null, provider: null, serverId: null, lastChecked: null, tunnelStatus: 'off' as const }), + ...(vmState ?? DEFAULT_CLOUD_STATE), error: err instanceof Error ? err.message : 'Failed to start VM', status: 'unknown', }); @@ -75,7 +78,7 @@ export function CloudWidget() { } } catch (err) { setVmState({ - ...(vmState ?? { status: 'unknown', ip: null, noVncUrl: null, provider: null, serverId: null, lastChecked: null, tunnelStatus: 'off' as const }), + ...(vmState ?? DEFAULT_CLOUD_STATE), error: err instanceof Error ? err.message : 'Failed to stop VM', status: 'unknown', }); @@ -84,10 +87,56 @@ export function CloudWidget() { } }, [setLoading, setShowCloudView, setVmState, vmState]); + const handleConnectWorkspace = useCallback(async () => { + setLoading(true); + try { + const result = await window.electronAPI.cloud.connectWorkspace(); + if (result.success && result.data) { + setVmState(result.data); + return; + } + + setVmState({ + ...(vmState ?? DEFAULT_CLOUD_STATE), + error: result.error ?? 'Failed to connect to hosted workspace', + }); + } catch (err) { + setVmState({ + ...(vmState ?? DEFAULT_CLOUD_STATE), + error: err instanceof Error ? err.message : 'Failed to connect to hosted workspace', + }); + } finally { + setLoading(false); + } + }, [setLoading, setVmState, vmState]); + + const handleDisconnectWorkspace = useCallback(async () => { + setLoading(true); + try { + const result = await window.electronAPI.cloud.disconnectWorkspace(); + if (result.success && result.data) { + setVmState(result.data); + return; + } + + setVmState({ + ...(vmState ?? DEFAULT_CLOUD_STATE), + error: result.error ?? 'Failed to switch back to local runtime', + }); + } catch (err) { + setVmState({ + ...(vmState ?? DEFAULT_CLOUD_STATE), + error: err instanceof Error ? err.message : 'Failed to switch back to local runtime', + }); + } finally { + setLoading(false); + } + }, [setLoading, setVmState, vmState]); + const handleOpenSetupTerminal = useCallback(async () => { if (!activeSessionId) { setVmState({ - ...(vmState ?? { status: 'unknown', ip: null, noVncUrl: null, provider: null, serverId: null, lastChecked: null, tunnelStatus: 'off' as const }), + ...(vmState ?? DEFAULT_CLOUD_STATE), error: 'Select a session first to open the setup terminal', }); return; @@ -127,18 +176,49 @@ export function CloudWidget() { const isRunning = vmState.status === 'running'; const isOff = vmState.status === 'off'; const isUnknown = vmState.status === 'unknown'; // Usually means auth failed - const tunnelConnecting = isRunning && vmState.tunnelStatus === 'starting'; - const tunnelReady = isRunning && vmState.tunnelStatus === 'running'; - const tunnelError = isRunning && vmState.tunnelStatus === 'error'; - const tunnelDisconnected = isRunning && vmState.tunnelStatus === 'off'; + const hasDaemonMetadata = Boolean( + vmState.daemonBaseUrl + || vmState.linkedRemoteProfileId + || vmState.daemonStatus !== 'unknown', + ); + const noVncFallbackReady = vmState.allowNoVncFallback && Boolean(vmState.noVncUrl); + const daemonUnavailableWithFallback = + noVncFallbackReady && + (vmState.daemonStatus === 'unknown' || vmState.daemonStatus === 'error'); + const daemonAccess = + isRunning && + vmState.preferredAccess === 'daemon' && + hasDaemonMetadata && + !daemonUnavailableWithFallback; + const daemonBootstrapping = daemonAccess && vmState.daemonStatus === 'bootstrapping'; + const daemonReady = daemonAccess && vmState.daemonStatus === 'ready'; + const daemonConnected = daemonReady && vmState.remoteConnectionStatus === 'connected'; + const daemonConnectAvailable = daemonReady && vmState.remoteConnectionStatus === 'available'; + const daemonConnectionReconnecting = + daemonReady && + (vmState.remoteConnectionStatus === 'connecting' || vmState.remoteConnectionStatus === 'reconnecting'); + const daemonConnectionError = daemonReady && vmState.remoteConnectionStatus === 'error'; + const daemonConnectionUnavailable = daemonConnectionReconnecting || daemonConnectionError; + const daemonError = daemonAccess && vmState.daemonStatus === 'error'; + const canManageCloudVmLifecycle = isRunning && vmState.noVncUrl !== null; + const tunnelConnecting = isRunning && !daemonAccess && vmState.tunnelStatus === 'starting'; + const tunnelReady = isRunning && !daemonAccess && vmState.tunnelStatus === 'running'; + const tunnelError = isRunning && !daemonAccess && vmState.tunnelStatus === 'error'; + const tunnelDisconnected = isRunning && !daemonAccess && vmState.tunnelStatus === 'off'; // Show reconnect button for: tunnel issues OR unknown status (auth failed) - const needsReconnect = (tunnelError || tunnelDisconnected || isUnknown) && !loading && activeSessionId; + const needsReconnect = (daemonError || tunnelError || tunnelDisconnected || isUnknown) && !loading && activeSessionId; // Compute transitioning label const getTransitionLabel = () => { if (vmState.status === 'stopping') return 'Stopping...'; if (vmState.status === 'starting' || vmState.status === 'initializing') return 'Starting VM...'; + if (daemonBootstrapping) return 'Starting workspace daemon...'; + if (daemonConnectionReconnecting) { + return vmState.remoteConnectionStatus === 'connecting' + ? 'Connecting cloud runtime...' + : 'Reconnecting cloud runtime...'; + } if (tunnelConnecting) return 'Connecting tunnel...'; return 'Loading...'; }; @@ -156,7 +236,7 @@ export function CloudWidget() { )} {/* Transitioning state (VM starting/stopping or tunnel connecting) */} - {(isTransitioning || tunnelConnecting || loading) && ( + {(isTransitioning || daemonBootstrapping || daemonConnectionReconnecting || tunnelConnecting || loading) && (
@@ -181,7 +261,7 @@ export function CloudWidget() { {needsReconnect && ( <> {/* Only show stop button if VM is confirmed running */} - {isRunning && ( + {canManageCloudVmLifecycle && ( + )} + + + )} + + {daemonConnected && !loading && ( + <> + {canManageCloudVmLifecycle && ( + + )} + +
+ + Cloud Connected +
+ + )} + + {daemonConnectionUnavailable && !loading && ( + <> + +
+ + + {daemonConnectionError ? 'Cloud Connection Error' : 'Cloud Reconnecting'} + +
+ + )} + + {daemonReady && !daemonConnectAvailable && !daemonConnected && !daemonConnectionUnavailable && !loading && ( + <> + {canManageCloudVmLifecycle && ( + + )} +
+ + Daemon Ready +
+ + )}
); } diff --git a/frontend/src/components/DetailPanel.tsx b/frontend/src/components/DetailPanel.tsx index 5322275b..5c91b150 100644 --- a/frontend/src/components/DetailPanel.tsx +++ b/frontend/src/components/DetailPanel.tsx @@ -78,6 +78,7 @@ function actionTooltip(action: { description?: string; disabled?: boolean; disab export function DetailPanel({ isVisible, width, height, onResize, mergeError, projectGitActions, orientation, isCollapsed, onToggleCollapse, onSwapLayout, terminalShortcuts, onCommitClick }: DetailPanelProps) { const sessionContext = useSession(); const immersiveMode = useNavigationStore(s => s.immersiveMode); + const remoteIdeTooltip = 'Open in IDE is only available in local mode. Switch this client back to the local runtime to use your desktop IDE.'; // Build IDE dropdown items, sending safe IDE keys (resolved to commands server-side) const ideItems = useMemo(() => { @@ -98,7 +99,7 @@ export function DetailPanel({ isVisible, width, height, onResize, mergeError, pr if (!sessionContext) return null; - const { session, gitBranchActions, isMerging, gitCommands, onOpenIDEWithCommand, onConfigureIDE, onSetTracking, trackingBranch } = sessionContext; + const { session, gitBranchActions, isMerging, gitCommands, onOpenIDEWithCommand, onConfigureIDE, onSetTracking, trackingBranch, isRemoteMode } = sessionContext; const gitStatus = session.gitStatus; const isProject = !!session.isMainRepo; // Treat git as unavailable only when status has loaded but indicates failure. @@ -186,25 +187,35 @@ export function DetailPanel({ isVisible, width, height, onResize, mergeError, pr {/* IDE button */} {onOpenIDEWithCommand && ( - - - - } - items={ideItems} - footer={ - - } - position="auto" - width="sm" - /> + + + ) : ( + + + + } + items={ideItems} + footer={ + + } + position="auto" + width="sm" + /> + ) )} {/* Terminal shortcut pills — inline with git actions */} @@ -333,24 +344,35 @@ export function DetailPanel({ isVisible, width, height, onResize, mergeError, pr )} {onOpenIDEWithCommand && ( - - - Open in IDE - - } - items={ideItems} - footer={ - - } - position="auto" - width="sm" - /> + isRemoteMode ? ( + + + + + + ) : ( + + + Open in IDE + + } + items={ideItems} + footer={ + + } + position="auto" + width="sm" + /> + ) )} diff --git a/frontend/src/components/HomePage.tsx b/frontend/src/components/HomePage.tsx index a10303bb..00e27346 100644 --- a/frontend/src/components/HomePage.tsx +++ b/frontend/src/components/HomePage.tsx @@ -9,7 +9,7 @@ import { Dropdown } from './ui/Dropdown'; import { Badge } from './ui/Badge'; import { AddProjectDialog } from './AddProjectDialog'; import { CloneFromGitHubDialog } from './CloneFromGitHubDialog'; -import { formatDistanceToNow } from '../utils/timestampUtils'; +import { formatDistanceToNow, isValidTimestamp } from '../utils/timestampUtils'; import type { Project } from '../types/project'; import type { Session } from '../types/session'; @@ -224,7 +224,7 @@ export function HomePage() { const recentSessions = useMemo(() => { return sessions - .filter((s): s is Session & { lastActivity: string } => !s.archived && typeof s.lastActivity === 'string') + .filter((s): s is Session & { lastActivity: string } => !s.archived && isValidTimestamp(s.lastActivity)) .sort((a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()) .slice(0, 8); }, [sessions]); @@ -417,9 +417,11 @@ export function HomePage() { )}
- - {getStatusLabel(session.status)} - + {session.status !== 'stopped' && session.status !== 'ready' && ( + + {getStatusLabel(session.status)} + + )} {formatDistanceToNow(session.lastActivity)} diff --git a/frontend/src/components/PermissionDialog.tsx b/frontend/src/components/PermissionDialog.tsx index 781bf4a3..0583e346 100644 --- a/frontend/src/components/PermissionDialog.tsx +++ b/frontend/src/components/PermissionDialog.tsx @@ -1,20 +1,18 @@ import React, { useState, useEffect } from 'react'; import { Check, X, Shield, AlertTriangle, Code, Edit } from 'lucide-react'; +import type { PanePermissionRequest } from '../../../shared/types/daemon'; import { Modal, ModalHeader, ModalBody, ModalFooter } from './ui/Modal'; import { Button } from './ui/Button'; import { Textarea } from './ui/Textarea'; -interface PermissionRequest { - id: string; - sessionId: string; - toolName: string; - input: Record; - timestamp: number; -} - interface PermissionDialogProps { - request: PermissionRequest | null; - onRespond: (requestId: string, behavior: 'allow' | 'deny', updatedInput?: Record, message?: string) => void; + request: PanePermissionRequest | null; + onRespond: ( + requestId: string, + behavior: 'allow' | 'deny', + updatedInput?: PanePermissionRequest['input'], + message?: string, + ) => void; session?: { name: string }; } @@ -219,4 +217,4 @@ export const PermissionDialog: React.FC = ({ request, onR ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/ProjectSessionList.tsx b/frontend/src/components/ProjectSessionList.tsx index d39b4501..8d1ca3e2 100644 --- a/frontend/src/components/ProjectSessionList.tsx +++ b/frontend/src/components/ProjectSessionList.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import { ChevronDown, ChevronRight, Plus, FolderPlus, GitBranch, MoreHorizontal, Home, Archive, ArchiveRestore, Trash2, GitPullRequest, Pin } from 'lucide-react'; +import { ChevronDown, ChevronRight, Plus, FolderPlus, GitBranch, MoreHorizontal, Home, Archive, ArchiveRestore, Trash2, GitPullRequest, Pin, Monitor } from 'lucide-react'; import { SessionDetailTooltip } from './SessionDetailTooltip'; import { useSessionStore } from '../stores/sessionStore'; import { useNavigationStore } from '../stores/navigationStore'; @@ -19,9 +19,17 @@ import { usePanelStore } from '../stores/panelStore'; interface ProjectSessionListProps { sessionSortAscending: boolean; + showRemoteDesktopLink?: boolean; + onRemoteDesktopClick?: () => void; + remoteDesktopTooltip?: string; } -export function ProjectSessionList({ sessionSortAscending }: ProjectSessionListProps) { +export function ProjectSessionList({ + sessionSortAscending, + showRemoteDesktopLink = false, + onRemoteDesktopClick, + remoteDesktopTooltip, +}: ProjectSessionListProps) { const [projects, setProjects] = useState([]); const [expandedProjects, setExpandedProjects] = useState>(new Set()); const [showCreateDialog, setShowCreateDialog] = useState(false); @@ -336,6 +344,19 @@ export function ProjectSessionList({ sessionSortAscending }: ProjectSessionListP Home + {showRemoteDesktopLink && onRemoteDesktopClick && ( + + + + )} + {pinnedSessions.length > 0 && ( <>
diff --git a/frontend/src/components/SessionView.tsx b/frontend/src/components/SessionView.tsx index 71666d24..20c30fc1 100644 --- a/frontend/src/components/SessionView.tsx +++ b/frontend/src/components/SessionView.tsx @@ -53,6 +53,7 @@ export const SessionView = memo(() => { () => (config?.customCommands ?? []).filter(cmd => cmd?.name && cmd?.command), [config?.customCommands] ); + const isRemoteMode = config?.remoteDaemon?.client.mode === 'remote'; const deleteCustomCommand = useCallback((index: number) => { const existing = config?.customCommands ?? []; updateConfig({ customCommands: existing.filter((_, i) => i !== index) }).catch(() => {}); @@ -598,6 +599,13 @@ export const SessionView = memo(() => { const handleOpenIDEWithCommand = useCallback(async (ideKey?: string) => { if (!activeSession) return; + if (isRemoteMode) { + useErrorStore.getState().showError({ + title: 'Open IDE unavailable', + error: 'Open in IDE is only available in local mode. Switch this client back to the local runtime to use your desktop IDE.', + }); + return; + } try { const response = await API.sessions.openIDE(activeSession.id, ideKey); if (!response.success) { @@ -612,7 +620,7 @@ export const SessionView = memo(() => { error: error instanceof Error ? error.message : 'Unknown error occurred', }); } - }, [activeSession]); + }, [activeSession, isRemoteMode]); // Detail panel state const [detailVisible, setDetailVisible] = useState(() => { @@ -963,7 +971,7 @@ export const SessionView = memo(() => { return (
{/* SINGLE SessionProvider wraps everything */} - setShowProjectSettings(true)} onSetTracking={handleOpenSetTracking} trackingBranch={currentUpstream} configuredIDECommand={sessionProject?.open_ide_command}> + setShowProjectSettings(true)} onSetTracking={handleOpenSetTracking} trackingBranch={currentUpstream} configuredIDECommand={sessionProject?.open_ide_command} isRemoteMode={isRemoteMode}> {/* Tab bar at top */} (null); const [verbose, setVerbose] = useState(false); const [claudeExecutablePath, setClaudeExecutablePath] = useState(''); const [autoCheckUpdates, setAutoCheckUpdates] = useState(true); @@ -75,13 +149,149 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) { const [activeTab, setActiveTab] = useState<'general' | 'notifications' | 'shortcuts'>('general'); const [analyticsEnabled, setAnalyticsEnabled] = useState(true); const [previousAnalyticsEnabled, setPreviousAnalyticsEnabled] = useState(true); - const [preferredShell, setPreferredShell] = useState('auto'); - const [availableShells, setAvailableShells] = useState>([]); + const [preferredShell, setPreferredShell] = useState('auto'); + const [availableShells, setAvailableShells] = useState([]); const [terminalShortcuts, setTerminalShortcuts] = useState([]); const [worktreeFileSync, setWorktreeFileSync] = useState([]); + const [remoteDaemonConfig, setRemoteDaemonConfig] = useState(createDefaultRemoteDaemonConfig()); + const [remoteConnectionState, setRemoteConnectionState] = useState(createDefaultRemotePaneConnectionState()); + const [remoteHostState, setRemoteHostState] = useState(createDefaultRemoteDaemonHostRuntimeState()); + const [remoteHostConfigDraft, setRemoteHostConfigDraft] = useState(createDefaultRemoteDaemonConfig().host.config); + const [remotePairLabel, setRemotePairLabel] = useState(''); + const [remotePairBaseUrl, setRemotePairBaseUrl] = useState('http://127.0.0.1:42137'); + const [remoteCreatedToken, setRemoteCreatedToken] = useState(null); + const [remoteConnectionCode, setRemoteConnectionCode] = useState(''); + const [remoteImportResult, setRemoteImportResult] = useState(null); + const [remoteImportedProfileLabel, setRemoteImportedProfileLabel] = useState(''); + const [remoteImportedProfileBaseUrl, setRemoteImportedProfileBaseUrl] = useState('http://127.0.0.1:42137'); + const [remoteImportedProfileToken, setRemoteImportedProfileToken] = useState(''); + const [remoteBusy, setRemoteBusy] = useState(false); + const [remoteSetupTerminalBusy, setRemoteSetupTerminalBusy] = useState(false); + const [remoteClientSetupTerminalBusy, setRemoteClientSetupTerminalBusy] = useState(false); + const [remoteClientSetupError, setRemoteClientSetupError] = useState(null); + const [remoteImportRecoveryProfileId, setRemoteImportRecoveryProfileId] = useState(null); + const [remoteImportRecoveryError, setRemoteImportRecoveryError] = useState(null); + const [remoteSetupDataMode, setRemoteSetupDataMode] = useState('current'); + const [remoteSetupLabel, setRemoteSetupLabel] = useState(''); + const [remoteSetupListenPort, setRemoteSetupListenPort] = useState(42137); + const [remoteSetupPaneDir, setRemoteSetupPaneDir] = useState(''); + const [remoteSetupTunnelPreference, setRemoteSetupTunnelPreference] = useState('tailscale'); + const [remoteSetupManualBaseUrl, setRemoteSetupManualBaseUrl] = useState(''); + const [remoteSetupInstallService, setRemoteSetupInstallService] = useState(true); + const [remoteSetupResult, setRemoteSetupResult] = useState(null); + const [remoteHostConnectionCode, setRemoteHostConnectionCode] = useState(null); + const [remoteSetupCopyResult, setRemoteSetupCopyResult] = useState(null); + const [remoteSetupError, setRemoteSetupError] = useState(null); + const fontsLoadedForOpenRef = useRef(false); const { updateSettings } = useNotifications(); const { theme, setTheme } = useTheme(); const { fetchConfig: refreshConfigStore } = useConfigStore(); + const activeProjectId = useNavigationStore((state) => state.activeProjectId); + const navigateToSessions = useNavigationStore((state) => state.navigateToSessions); + const activeSessionProjectId = useSessionStore((state) => { + const activeSession = state.sessions.find((session) => session.id === state.activeSessionId) + ?? state.activeMainRepoSession; + return activeSession?.projectId ?? null; + }); + const setActiveSession = useSessionStore((state) => state.setActiveSession); + + const refreshRemoteDaemonSettings = useCallback(async () => { + const [configResponse, connectionStateResponse, hostStateResponse] = await Promise.all([ + API.remoteDaemon.getConfig(), + API.remoteDaemon.getConnectionState(), + API.remoteDaemon.getHostState(), + ]); + + if (configResponse.success && configResponse.data) { + setRemoteDaemonConfig(configResponse.data); + setRemoteHostConfigDraft(configResponse.data.host.config); + setRemoteSetupListenPort((currentValue) => ( + currentValue === 42137 ? configResponse.data!.host.config.listenPort : currentValue + )); + setRemotePairBaseUrl((currentValue) => ( + currentValue === 'http://127.0.0.1:42137' + ? formatRemoteBaseUrl( + configResponse.data!.host.config.listenHost, + configResponse.data!.host.config.listenPort, + ) + : currentValue + )); + setRemoteImportedProfileBaseUrl((currentValue) => ( + currentValue === 'http://127.0.0.1:42137' + ? formatRemoteBaseUrl( + configResponse.data!.host.config.listenHost, + configResponse.data!.host.config.listenPort, + ) + : currentValue + )); + } + + if (connectionStateResponse.success && connectionStateResponse.data) { + setRemoteConnectionState(connectionStateResponse.data); + } + + if (hostStateResponse.success && hostStateResponse.data) { + setRemoteHostState(hostStateResponse.data); + } + }, []); + + const fetchConfig = useCallback(async (currentPlatform?: string) => { + try { + const response = await API.config.get(); + if (!response.success || !response.data) { + throw new Error(response.error || 'Failed to fetch config'); + } + const data = response.data; + setVerbose(data.verbose || false); + setAutoCheckUpdates(data.autoCheckUpdates !== false); // Default to true + setDevMode(data.devMode || false); + setUsePtyHost(data.usePtyHost === true); + setInitialUsePtyHost(data.usePtyHost === true); + setClaudeExecutablePath(data.claudeExecutablePath || ''); + setEnableCommitFooter(data.enableCommitFooter !== false); // Default to true + setUiScale(data.uiScale || 1.0); + setTerminalFontFamily(data.terminalFontFamily || ''); + setTerminalFontSize(data.terminalFontSize || 14); + + // Load additional paths + const paths = data.additionalPaths || []; + setAdditionalPathsText(paths.join('\n')); + + // Load notification settings + if (data.notifications) { + setNotificationSettings(data.notifications); + // Update the useNotifications hook with loaded settings + updateSettings(data.notifications); + } + + // Load analytics settings + if (data.analytics) { + const enabled = data.analytics.enabled !== false; // Default to true + setAnalyticsEnabled(enabled); + setPreviousAnalyticsEnabled(enabled); + } + + // Fetch available shells on Windows + const platformToCheck = currentPlatform || platform; + if (platformToCheck === 'win32') { + const shellsResponse = await API.config.getAvailableShells(); + if (shellsResponse.success && shellsResponse.data) { + setAvailableShells(shellsResponse.data); + } + } + setPreferredShell(data.preferredShell || 'auto'); + + // Load terminal shortcuts + setTerminalShortcuts(data.terminalShortcuts ?? []); + + // Load worktree file sync entries + setWorktreeFileSync(data.worktreeFileSync ?? DEFAULT_WORKTREE_FILE_SYNC_ENTRIES); + + await refreshRemoteDaemonSettings(); + } catch { + setError('Failed to load configuration'); + } + }, [platform, refreshRemoteDaemonSettings, updateSettings]); useEffect(() => { if (isOpen) { @@ -91,12 +301,12 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) { fetchConfig(p); }); - // Load system monospace fonts for the font picker - window.electronAPI.config.getMonospaceFonts().then((result) => { - if (result?.data && Array.isArray(result.data)) { - setSystemMonoFonts(result.data as string[]); - } - }).catch(() => { /* fc-list not available — dropdown will be empty */ }); + const unsubscribeRemoteConnectionState = window.electronAPI.remoteDaemon.onConnectionStateChanged((state) => { + setRemoteConnectionState(state); + }); + const unsubscribeRemoteHostState = window.electronAPI.remoteDaemon.onHostStateChanged((state) => { + setRemoteHostState(state); + }); const loadAutoRename = async () => { try { @@ -132,62 +342,400 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) { if (initialSection === 'terminal-shortcuts') { setActiveTab('shortcuts'); } + + return () => { + unsubscribeRemoteConnectionState(); + unsubscribeRemoteHostState(); + }; + } + }, [fetchConfig, initialSection, isOpen]); + + useEffect(() => { + if (!isOpen) { + fontsLoadedForOpenRef.current = false; + return; } - }, [isOpen, initialSection]); - const fetchConfig = async (currentPlatform?: string) => { + if (fontsLoadedForOpenRef.current) { + return; + } + fontsLoadedForOpenRef.current = true; + + // macOS font enumeration can be expensive; keep it out of the broad + // settings refresh effect so state sync cannot repeatedly spawn it. + let cancelled = false; + window.electronAPI.config.getMonospaceFonts().then((result) => { + if (!cancelled && result?.data && Array.isArray(result.data)) { + setSystemMonoFonts(result.data as string[]); + } + }).catch(() => { /* fc-list not available — dropdown will be empty */ }); + + return () => { + cancelled = true; + }; + }, [isOpen]); + + const runRemoteDaemonAction = async ( + action: () => Promise, + options: { + onError?: (message: string) => void; + mirrorErrorToGlobal?: boolean; + } = {}, + ) => { + setRemoteBusy(true); + setError(null); try { - const response = await API.config.get(); - if (!response.success) throw new Error(response.error || 'Failed to fetch config'); - const data = response.data; - setConfig(data); - setVerbose(data.verbose || false); - setAutoCheckUpdates(data.autoCheckUpdates !== false); // Default to true - setDevMode(data.devMode || false); - setUsePtyHost(data.usePtyHost === true); - setInitialUsePtyHost(data.usePtyHost === true); - setClaudeExecutablePath(data.claudeExecutablePath || ''); - setEnableCommitFooter(data.enableCommitFooter !== false); // Default to true - setUiScale(data.uiScale || 1.0); - setTerminalFontFamily(data.terminalFontFamily || ''); - setTerminalFontSize(data.terminalFontSize || 14); + await action(); + await Promise.all([ + refreshRemoteDaemonSettings(), + refreshConfigStore(), + ]); + } catch (err) { + const message = err instanceof Error ? err.message : 'Remote daemon action failed'; + options.onError?.(message); + if (options.mirrorErrorToGlobal !== false) { + setError(message); + } + } finally { + setRemoteBusy(false); + } + }; - // Load additional paths - const paths = data.additionalPaths || []; - setAdditionalPathsText(paths.join('\n')); + const handleSaveRemoteHostConfig = async () => { + await runRemoteDaemonAction(async () => { + const response = await API.remoteDaemon.updateHostConfig(remoteHostConfigDraft); + if (!response.success) { + throw new Error(response.error || 'Failed to save remote daemon host config'); + } + }); + }; - // Load notification settings - if (data.notifications) { - setNotificationSettings(data.notifications); - // Update the useNotifications hook with loaded settings - updateSettings(data.notifications); + const handleStopRemoteHost = async () => { + await runRemoteDaemonAction(async () => { + const response = await API.remoteDaemon.updateHostConfig({ enabled: false }); + if (!response.success) { + throw new Error(response.error || 'Failed to stop remote host'); } - // Load analytics settings - if (data.analytics) { - const enabled = data.analytics.enabled !== false; // Default to true - setAnalyticsEnabled(enabled); - setPreviousAnalyticsEnabled(enabled); + setRemoteHostConfigDraft((current) => ({ + ...current, + enabled: false, + })); + }); + }; + + const handleClearRemoteHostAccess = async () => { + await runRemoteDaemonAction(async () => { + const response = await API.remoteDaemon.clearHostAccess(); + if (!response.success) { + throw new Error(response.error || 'Failed to forget remote host access'); } - // Fetch available shells on Windows - const platformToCheck = currentPlatform || platform; - if (platformToCheck === 'win32') { - const shellsResponse = await API.config.getAvailableShells(); - if (shellsResponse.success) { - setAvailableShells(shellsResponse.data); + setRemoteHostConnectionCode(null); + setRemoteSetupResult(null); + setRemoteSetupCopyResult('Forgot cached host code and revoked existing remote clients. Create a new code to reconnect.'); + }); + }; + + const handleDisconnectRemoteClients = async (clientIds?: string[]) => { + await runRemoteDaemonAction(async () => { + const response = await API.remoteDaemon.disconnectHostClients(clientIds); + if (!response.success) { + throw new Error(response.error || 'Failed to disconnect remote clients'); + } + }); + }; + + const handleRevokeRemoteClient = async (clientId: string) => { + await runRemoteDaemonAction(async () => { + const response = await API.remoteDaemon.deleteClientRecord(clientId); + if (!response.success) { + throw new Error(response.error || 'Failed to revoke remote client access'); + } + }); + }; + + const buildRemoteHostSetupRequest = (): RemoteHostSetupRequest => ({ + dataDirectoryMode: remoteSetupDataMode, + label: remoteSetupLabel, + listenPort: remoteSetupListenPort, + paneDir: remoteSetupDataMode === 'isolated' && remoteSetupPaneDir.trim().length > 0 + ? remoteSetupPaneDir + : undefined, + preferTunnel: remoteSetupTunnelPreference, + baseUrl: remoteSetupTunnelPreference === 'manual' && remoteSetupManualBaseUrl.trim().length > 0 + ? remoteSetupManualBaseUrl + : undefined, + installService: remoteSetupDataMode === 'isolated' ? remoteSetupInstallService : false, + }); + + const handleSetupRemoteHost = async () => { + await runRemoteDaemonAction(async () => { + setRemoteSetupResult(null); + setRemoteSetupCopyResult(null); + setRemoteSetupError(null); + const response = await API.remoteDaemon.setupHost(buildRemoteHostSetupRequest()); + if (!response.success || !response.data) { + throw new Error(response.error || 'Failed to set up this machine as a remote'); + } + + setRemoteSetupResult(response.data); + setRemoteHostConnectionCode(response.data.connectionCode); + setRemoteSetupLabel(''); + }, { + onError: setRemoteSetupError, + mirrorErrorToGlobal: false, + }); + }; + + const handleOpenRemoteSetupTerminal = async () => { + const projectId = activeProjectId ?? activeSessionProjectId; + if (!projectId) { + setRemoteSetupError('Select a project before opening a setup terminal.'); + return; + } + + setRemoteSetupTerminalBusy(true); + setRemoteSetupError(null); + setRemoteSetupResult(null); + setRemoteHostConnectionCode(null); + setRemoteSetupCopyResult(null); + + try { + const commandResponse = await API.remoteDaemon.getInteractiveSetupCommand(buildRemoteHostSetupRequest()); + if (!commandResponse.success || !commandResponse.data?.command) { + throw new Error(commandResponse.error || 'Failed to prepare the remote setup command'); + } + + const sessionResponse = await API.sessions.getOrCreateMainRepoSession(projectId); + if (!sessionResponse.success || !sessionResponse.data?.id) { + throw new Error(sessionResponse.error || 'Failed to open a project terminal'); + } + + const sessionId = sessionResponse.data.id as string; + const panel = await panelApi.createPanel({ + sessionId, + type: 'terminal', + title: 'Tailscale Setup', + initialState: { + customState: { + initialCommand: commandResponse.data.command, + }, + }, + }); + + await panelApi.setActivePanel(sessionId, panel.id); + await setActiveSession(sessionId); + navigateToSessions(); + onClose(); + } catch (err) { + setRemoteSetupError(err instanceof Error ? err.message : 'Failed to open setup terminal'); + } finally { + setRemoteSetupTerminalBusy(false); + } + }; + + const handleOpenTailscaleClientSetupTerminal = async () => { + const projectId = activeProjectId ?? activeSessionProjectId; + if (!projectId) { + setRemoteClientSetupError('Select a project before opening a Tailscale setup terminal.'); + return; + } + + setRemoteClientSetupTerminalBusy(true); + setRemoteClientSetupError(null); + setError(null); + + try { + const commandResponse = await API.remoteDaemon.getInteractiveClientSetupCommand(); + if (!commandResponse.success || !commandResponse.data?.command) { + throw new Error(commandResponse.error || 'Failed to prepare the Tailscale setup command'); + } + + if (remoteConnectionState.mode === 'remote') { + const localResponse = await API.remoteDaemon.updateClientState({ + activeProfileId: null, + mode: 'local', + }); + if (!localResponse.success) { + throw new Error(localResponse.error || 'Failed to switch to local runtime before opening Tailscale setup'); } } - setPreferredShell(data.preferredShell || 'auto'); - // Load terminal shortcuts - setTerminalShortcuts(data.terminalShortcuts ?? []); + const sessionResponse = await API.sessions.getOrCreateMainRepoSession(projectId); + if (!sessionResponse.success || !sessionResponse.data?.id) { + throw new Error(sessionResponse.error || 'Failed to open a project terminal'); + } - // Load worktree file sync entries - setWorktreeFileSync(data.worktreeFileSync ?? DEFAULT_WORKTREE_FILE_SYNC_ENTRIES); + const sessionId = sessionResponse.data.id as string; + const panel = await panelApi.createPanel({ + sessionId, + type: 'terminal', + title: 'Tailscale Client Setup', + initialState: { + customState: { + initialCommand: commandResponse.data.command, + }, + }, + }); + + await panelApi.setActivePanel(sessionId, panel.id); + await setActiveSession(sessionId); + navigateToSessions(); + onClose(); } catch (err) { - setError('Failed to load configuration'); + setRemoteClientSetupError(err instanceof Error ? err.message : 'Failed to open Tailscale setup terminal'); + } finally { + setRemoteClientSetupTerminalBusy(false); + } + }; + + const handleCopyRemoteSetupConnectionCode = async () => { + if (!remoteSetupResult) { + return; + } + try { + await navigator.clipboard.writeText(remoteSetupResult.connectionCode); + setRemoteSetupCopyResult('Copied connection code.'); + } catch { + setError('Failed to copy connection code'); + } + }; + + const handleCopyRemoteHostConnectionCode = async () => { + const connectionCode = remoteHostConnectionCode ?? remoteSetupResult?.connectionCode; + if (!connectionCode) { + return; } + try { + await navigator.clipboard.writeText(connectionCode); + setRemoteSetupCopyResult('Copied connection code.'); + } catch { + setError('Failed to copy connection code'); + } + }; + + const handleCreateAndCopyRemoteHostCode = async () => { + await runRemoteDaemonAction(async () => { + setRemoteSetupCopyResult(null); + setRemoteSetupError(null); + const response = await API.remoteDaemon.createHostConnectionCode({ + label: remoteSetupLabel, + }); + if (!response.success || !response.data) { + throw new Error(response.error || 'Failed to create remote connection code'); + } + + setRemoteHostConnectionCode(response.data.connectionCode); + try { + await navigator.clipboard.writeText(response.data.connectionCode); + setRemoteSetupCopyResult('Created and copied connection code.'); + } catch { + setRemoteSetupCopyResult('Connection code ready. Click the code to copy it.'); + } + }, { + onError: setRemoteSetupError, + mirrorErrorToGlobal: false, + }); + }; + + const handleCreateRemoteConnectionPair = async () => { + await runRemoteDaemonAction(async () => { + const response = await API.remoteDaemon.createConnectionPair({ + label: remotePairLabel, + baseUrl: remotePairBaseUrl, + }); + if (!response.success) { + throw new Error(response.error || 'Failed to create remote daemon connection pair'); + } + + setRemotePairLabel(''); + setRemoteCreatedToken(response.data?.token ?? null); + }); + }; + + const handleUseRemoteProfile = async (profileId: string) => { + await runRemoteDaemonAction(async () => { + setRemoteImportRecoveryProfileId(null); + setRemoteImportRecoveryError(null); + setRemoteClientSetupError(null); + const response = await API.remoteDaemon.updateClientState({ + activeProfileId: profileId, + mode: 'remote', + }); + if (!response.success) { + throw new Error(response.error || 'Failed to connect to remote daemon profile'); + } + }); + }; + + const handleSwitchToLocalMode = async () => { + await runRemoteDaemonAction(async () => { + const response = await API.remoteDaemon.updateClientState({ + mode: 'local', + }); + if (!response.success) { + throw new Error(response.error || 'Failed to return to local mode'); + } + }); + }; + + const handleDeleteRemoteProfile = async (profileId: string) => { + await runRemoteDaemonAction(async () => { + const response = await API.remoteDaemon.deleteConnectionProfile(profileId); + if (!response.success) { + throw new Error(response.error || 'Failed to delete remote daemon connection profile'); + } + }); + }; + + const handleSaveRemoteProfile = async () => { + await runRemoteDaemonAction(async () => { + const profile: RemotePaneConnectionProfile = { + id: crypto.randomUUID(), + label: remoteImportedProfileLabel.trim(), + baseUrl: remoteImportedProfileBaseUrl.trim(), + token: remoteImportedProfileToken.trim(), + transport: 'http+sse', + }; + + const response = await API.remoteDaemon.upsertConnectionProfile(profile); + if (!response.success) { + throw new Error(response.error || 'Failed to save remote daemon connection profile'); + } + + setRemoteImportedProfileLabel(''); + setRemoteImportedProfileToken(''); + }); + }; + + const handleImportRemoteConnectionCode = async () => { + await runRemoteDaemonAction(async () => { + setRemoteImportResult(null); + setRemoteImportRecoveryProfileId(null); + setRemoteImportRecoveryError(null); + setRemoteClientSetupError(null); + const response = await API.remoteDaemon.importConnectionCode(remoteConnectionCode, { + connect: true, + }); + if (!response.success) { + throw new Error(response.error || 'Failed to import remote daemon connection code'); + } + + const result = response.data; + setRemoteConnectionCode(''); + if (result?.connected) { + setRemoteImportResult(`Connected to ${result.profile.label}.`); + } else if (result?.connectionError) { + setRemoteImportResult(`Saved ${result.profile.label}, but connection failed: ${result.connectionError}`); + if (isTailscaleDnsConnectionFailure(result.profile, result.connectionError)) { + setRemoteImportRecoveryProfileId(result.profile.id); + setRemoteImportRecoveryError(result.connectionError); + } + } else if (result?.profile) { + setRemoteImportResult(`Saved ${result.profile.label}.`); + } + }); }; const handleAutoRenameToggle = async (checked: boolean) => { @@ -231,82 +779,746 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) { worktreeFileSync: filteredWorktreeFileSync, }); - if (!response.success) { - throw new Error(response.error || 'Failed to update configuration'); - } + if (!response.success) { + throw new Error(response.error || 'Failed to update configuration'); + } + + // Only toggle PostHog opt-in/opt-out after config save succeeds + if (previousAnalyticsEnabled !== analyticsEnabled) { + if (analyticsEnabled) { + optIn(); + capture('analytics_opted_in'); + } else { + captureAndOptOut('analytics_opted_out'); + } + } + + // Update the useNotifications hook with new settings + updateSettings(notificationSettings); + + // Refresh config from server + await fetchConfig(); + + // Also refresh the global config store + await refreshConfigStore(); + + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update configuration'); + } finally { + setIsSubmitting(false); + } + }; + + const remoteSetupRequiresManualBaseUrl = + remoteSetupTunnelPreference === 'manual' && remoteSetupManualBaseUrl.trim().length === 0; + const remoteSetupPortIsValid = + Number.isInteger(remoteSetupListenPort) && remoteSetupListenPort > 0 && remoteSetupListenPort <= 65535; + const remoteHostRuntimePresentation = getRemoteHostRuntimePresentation(remoteHostState); + const liveRemoteClientIds = new Set( + remoteHostState.connectedClients + .map((client) => client.clientId) + .filter((clientId): clientId is string => Boolean(clientId)), + ); + const remoteSetupFallbackCommands = remoteSetupResult + ? remoteSetupResult.fallbackTunnelCommands.filter((command) => command !== remoteSetupResult.tunnel?.command) + : []; + const activeRemoteHostConnectionCode = remoteHostConnectionCode ?? remoteSetupResult?.connectionCode ?? null; + const activeRemoteProfile = remoteConnectionState.activeProfileId + ? remoteDaemonConfig.client.profiles.find((profile) => profile.id === remoteConnectionState.activeProfileId) + : undefined; + const importRecoveryProfile = remoteImportRecoveryProfileId + ? remoteDaemonConfig.client.profiles.find((profile) => profile.id === remoteImportRecoveryProfileId) + : undefined; + const activeTailscaleDnsFailure = isTailscaleDnsConnectionFailure(activeRemoteProfile, remoteConnectionState.lastError); + const importTailscaleDnsFailure = isTailscaleDnsConnectionFailure(importRecoveryProfile, remoteImportRecoveryError); + const remoteLastSeenText = formatRemoteLastSeen(remoteConnectionState.lastSeenAt); + const renderTailscaleClientRecovery = ( + profile: RemotePaneConnectionProfile | undefined, + errorMessage: string | null, + ) => { + if (!profile || !errorMessage) { + return null; + } + + return ( +
+
+ +
+

Set up Tailscale on this device

+

+ This profile uses Tailscale, but Pane cannot resolve {getRemoteProfileHostname(profile) ?? 'the remote host'}. + Install Tailscale and sign in to the same account on this device, then retry the connection. +

+

{errorMessage}

+
+
+ {remoteClientSetupError && ( +
+ {remoteClientSetupError} +
+ )} +
+ + + +
+
+ ); + }; + + return ( + + } + onClose={onClose} + /> + + + {/* Tabs */} +
+ + + +
+ + {activeTab === 'general' && ( +
+ } + defaultExpanded={true} + > + } + spacing="sm" + > +
+ + +
+ +
+
+ +
+

{remoteHostRuntimePresentation.title}

+

{remoteHostRuntimePresentation.description}

+
+
+ {(remoteHostState.status === 'live' || remoteDaemonConfig.host.config.enabled) && ( + + )} + {remoteDaemonConfig.host.access && ( + + )} + {remoteHostState.status === 'live' && remoteHostState.connectedClients.length > 0 && ( + + )} + {(remoteHostState.status === 'live' || remoteDaemonConfig.host.config.enabled) && ( + + )} +
+
+ {activeRemoteHostConnectionCode && ( + + )} + {remoteSetupCopyResult && ( +

{remoteSetupCopyResult}

+ )} + {remoteSetupError && ( +

+ {remoteSetupError} +

+ )} + {remoteDaemonConfig.host.clients.length > 0 && ( +
+

Paired clients

+
+ {remoteDaemonConfig.host.clients.map((client) => { + const isLive = liveRemoteClientIds.has(client.id); + return ( +
+
+

+ {client.label} + {isLive && connected} +

+

+ {client.lastUsedAt ? formatRemoteLastSeen(client.lastUsedAt) : 'Never used.'} +

+
+
+ {isLive && ( + + )} + +
+
+ ); + })} +
+

+ Disconnect drops live clients now. Revoke removes access so the client cannot reconnect with that saved token. +

+
+ )} +
+ +
+ setRemoteSetupLabel(e.target.value)} + placeholder="Office Mac mini" + fullWidth + /> + setRemoteSetupListenPort(Number.parseInt(e.target.value, 10) || 42137)} + error={remoteSetupPortIsValid ? undefined : 'Port must be between 1 and 65535'} + fullWidth + /> +
+ + {remoteSetupDataMode === 'isolated' && ( +
+ setRemoteSetupPaneDir(e.target.value)} + placeholder="Default: ~/.pane_remote" + fullWidth + /> + setRemoteSetupInstallService(e.target.checked)} + /> +
+ )} + +
+

Access Mode

+
+ {[ + ['tailscale', 'Tailscale', 'Recommended for another device or network.'], + ['ssh', 'SSH Tunnel', 'Advanced local forwarding.'], + ['manual', 'Manual HTTPS', 'Advanced custom tunnel URL.'], + ].map(([id, label, description]) => ( + + ))} +
+
+ + {remoteSetupTunnelPreference === 'tailscale' && ( +

+ Pane can open a terminal that installs Tailscale if needed, walks through login, and configures Tailscale Serve for this daemon. +

+ )} + + {remoteSetupTunnelPreference === 'ssh' && ( +

+ SSH tunnel mode creates a localhost connection code and requires running the generated SSH command on the client machine before connecting. +

+ )} + + {remoteSetupTunnelPreference === 'manual' && ( + setRemoteSetupManualBaseUrl(e.target.value)} + placeholder="https://pane-remote.example.com" + error={remoteSetupRequiresManualBaseUrl ? 'HTTPS base URL is required for manual mode' : undefined} + fullWidth + /> + )} + +
+

+ Tailscale is recommended for another device or network. Current Pane Data keeps this host live while Pane is open; Isolated Daemon Data can install a service. +

+ +
+ + {remoteSetupError && ( +
+
+ +

{remoteSetupError}

+
+ {remoteSetupError.toLowerCase().includes('tailscale') && ( +
+ + +
+ )} +
+ )} - // Only toggle PostHog opt-in/opt-out after config save succeeds - if (previousAnalyticsEnabled !== analyticsEnabled) { - if (analyticsEnabled) { - optIn(); - capture('analytics_opted_in'); - } else { - captureAndOptOut('analytics_opted_out'); - } - } + {remoteSetupResult && ( +
+
+
+

Connection code ready

+

+ {remoteSetupResult.label} · {remoteSetupResult.paneDir} +

+
+ +
+