diff --git a/.gitignore b/.gitignore index 3af20a931..48447014e 100644 --- a/.gitignore +++ b/.gitignore @@ -91,7 +91,9 @@ typings/ # Electron-Forge out/ -bin/ +bin/*/thv +bin/*/README.md +bin/*/LICENSE # Playwright /test-results/ @@ -99,3 +101,5 @@ bin/ /blob-report/ /playwright/.cache/ /test-videos/ + + diff --git a/bin/Dockerfile b/bin/Dockerfile new file mode 100644 index 000000000..7e36d084c --- /dev/null +++ b/bin/Dockerfile @@ -0,0 +1,19 @@ +FROM docker:dind + +RUN apk add --no-cache dbus dbus-x11 gnome-keyring libsecret + +RUN setcap -r /usr/bin/gnome-keyring-daemon 2>/dev/null || true + +ENV XDG_CURRENT_DESKTOP=GNOME \ + XDG_SESSION_DESKTOP=gnome \ + DESKTOP_SESSION=gnome \ + # a writable runtime dir (glib falls back to /tmp if this is unset) + XDG_RUNTIME_DIR=/tmp/xdg-runtime +ENV DOCKER_HOST=unix:///var/run/docker.sock + +COPY --chmod=755 linux-x64/thv /usr/local/bin/thv +COPY ./ephemeral/entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/entrypoint.sh + +ENTRYPOINT ["entrypoint.sh"] +CMD [] diff --git a/bin/ephemeral/entrypoint.sh b/bin/ephemeral/entrypoint.sh new file mode 100644 index 000000000..9c46426ea --- /dev/null +++ b/bin/ephemeral/entrypoint.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env sh +set -eu + +export CI=true + +/usr/local/bin/dockerd-entrypoint.sh & +until docker info >/dev/null 2>&1; do sleep 0.5; done +echo "🐳 Docker-in-Docker daemon is ready." + +mkdir -p /tmp/xdg-runtime/keyring +chmod 700 /tmp/xdg-runtime /tmp/xdg-runtime/keyring + +eval "$(dbus-launch --sh-syntax)" +export XDG_RUNTIME_DIR=/tmp/xdg-runtime + +echo "default-password" | gnome-keyring-daemon --unlock --components=secrets,ssh & +sleep 2 + +export GNOME_KEYRING_CONTROL GNOME_KEYRING_PID + +exec "$@" diff --git a/bin/ephemeral/thv.sh b/bin/ephemeral/thv.sh new file mode 100755 index 000000000..8654717e0 --- /dev/null +++ b/bin/ephemeral/thv.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +( + cd "${ROOT_DIR}" + docker run --privileged \ + --cap-drop=SETPCAP \ + --cap-add=IPC_LOCK \ + --tmpfs /run \ + --rm -i \ + --network host \ + -v "${ROOT_DIR}:/workspace" \ + -w /workspace \ + thv-containerized thv "$@" +) diff --git a/main/src/tests/toolhive-manager.test.ts b/main/src/tests/toolhive-manager.test.ts index 3a6fe96a3..16119ceea 100644 --- a/main/src/tests/toolhive-manager.test.ts +++ b/main/src/tests/toolhive-manager.test.ts @@ -22,6 +22,11 @@ import { getQuittingState } from '../app-state' vi.mock('node:child_process') vi.mock('node:fs') vi.mock('node:net') +vi.mock('../../utils/delay', () => ({ + delay: vi.fn( + (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + ), +})) vi.mock('electron', () => ({ app: { isPackaged: false, @@ -39,7 +44,7 @@ vi.mock('@sentry/electron/main', () => ({ const mockScope = { addBreadcrumb: vi.fn(), } - callback(mockScope) + return callback(mockScope) }), })) @@ -181,7 +186,7 @@ describe('toolhive-manager', () => { it('updates tray status when tray is provided', async () => { const startPromise = startToolhive(mockTray) - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(4000) await startPromise expect(mockUpdateTrayStatus).toHaveBeenCalledWith(mockTray, true) @@ -190,7 +195,7 @@ describe('toolhive-manager', () => { it('logs process PID after spawning', async () => { const startPromise = startToolhive() - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(4000) await startPromise expect(mockLog.info).toHaveBeenCalledWith( @@ -201,7 +206,7 @@ describe('toolhive-manager', () => { it('handles process error events', async () => { const startPromise = startToolhive(mockTray) - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(4000) await startPromise const testError = new Error('Test spawn error') @@ -221,7 +226,7 @@ describe('toolhive-manager', () => { it('handles process exit events', async () => { const startPromise = startToolhive(mockTray) - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(4000) await startPromise mockProcess.emit('exit', 1) @@ -239,7 +244,7 @@ describe('toolhive-manager', () => { const startPromise = startToolhive(mockTray) // Advancing the timer actually allows the promise to resolve - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(4000) await startPromise mockCaptureMessage.mockClear() diff --git a/main/src/toolhive-manager.ts b/main/src/toolhive-manager.ts index 5063306fd..594d2b6ec 100644 --- a/main/src/toolhive-manager.ts +++ b/main/src/toolhive-manager.ts @@ -7,24 +7,17 @@ import type { Tray } from 'electron' import { updateTrayStatus } from './system-tray' import log from './logger' import * as Sentry from '@sentry/electron/main' +import { delay } from '../../utils/delay' import { getQuittingState } from './app-state' -const binName = process.platform === 'win32' ? 'thv.exe' : 'thv' +// Use environment variables for binary customization with Windows fallback +const binName = + process.env.BIN_NAME ?? (process.platform === 'win32' ? 'thv.exe' : 'thv') +const binArch = process.env.BIN_ARCH ?? `${process.platform}-${process.arch}` + const binPath = app.isPackaged - ? path.join( - process.resourcesPath, - 'bin', - `${process.platform}-${process.arch}`, - binName - ) - : path.resolve( - __dirname, - '..', - '..', - 'bin', - `${process.platform}-${process.arch}`, - binName - ) + ? path.join(process.resourcesPath, 'bin', binArch, binName) + : path.resolve(__dirname, '..', '..', 'bin', binArch, binName) let toolhiveProcess: ReturnType | undefined let toolhivePort: number | undefined @@ -105,7 +98,7 @@ async function findFreePort( } export async function startToolhive(tray?: Tray): Promise { - Sentry.withScope>(async (scope) => { + return Sentry.withScope>(async (scope) => { if (!existsSync(binPath)) { log.error(`ToolHive binary not found at: ${binPath}`) return @@ -134,6 +127,8 @@ export async function startToolhive(tray?: Tray): Promise { } ) + await delay(4000) + log.info(`[startToolhive] Process spawned with PID: ${toolhiveProcess.pid}`) scope.addBreadcrumb({ diff --git a/package.json b/package.json index 6cf95844d..a70705010 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ }, "scripts": { "start": "electron-forge start", - "e2e": "env CI=true sh -c \"tsc -b --clean && tsc -b && electron-forge package && playwright test\"", + "start:ephemeral": "BIN_NAME='thv.sh' BIN_ARCH='ephemeral' electron-forge start", + "e2e": "env CI=true sh -c \"tsc -b --clean && tsc -b && electron-forge package && BIN_NAME='thv.sh' BIN_ARCH='ephemeral' playwright test\"", "package": "tsc -b --clean && tsc -b && electron-forge package", "make": "tsc -b --clean && tsc -b && electron-forge make", "prettier": "prettier . --check",