diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 6d0f5733b41..74afb73b92c 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -27,6 +27,39 @@ on: required: false type: number default: 30 + workflow_dispatch: + # Manual build trigger so the fork can produce installers without the + # upstream release tooling (the fork uses v*-fork.* tags that + # release-desktop.yml does not react to). + inputs: + channel: + description: "Release channel (stable or canary)" + required: true + type: choice + options: + - stable + - canary + default: stable + version_suffix: + description: "Version suffix to append (e.g., -canary). Empty for stable." + required: false + type: string + default: "" + electron_builder_config: + description: "Electron builder config file" + required: false + type: string + default: "electron-builder.ts" + artifact_prefix: + description: "Prefix for artifact names" + required: false + type: string + default: "desktop" + artifact_retention_days: + description: "Number of days to retain artifacts" + required: false + type: number + default: 14 jobs: build-macos: @@ -263,3 +296,129 @@ jobs: path: apps/desktop/release/*-linux.yml retention-days: ${{ inputs.artifact_retention_days }} if-no-files-found: error + + build-windows: + name: Build - Windows (x64) + runs-on: windows-latest + environment: production + + defaults: + run: + # bash (Git Bash) is bundled on windows-latest runners and is used so + # the cross-platform scripts behave like they do on macOS/Linux. + shell: bash + + steps: + - name: Enable git long paths + shell: pwsh + run: git config --global core.longpaths true + + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Bun + id: setup-bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Cache dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}- + + - name: Install dependencies + run: bun install --frozen --ignore-scripts + + - name: Install desktop native dependencies + working-directory: apps/desktop + run: bun run install:deps + + - name: Set version suffix + if: inputs.version_suffix != '' + working-directory: apps/desktop + run: | + CURRENT_VERSION=$(node -p "require('./package.json').version") + NEW_VERSION="${CURRENT_VERSION}${{ inputs.version_suffix }}" + echo "Setting version to: $NEW_VERSION" + node -e " + const fs = require('fs'); + const pkg = require('./package.json'); + pkg.version = '$NEW_VERSION'; + fs.writeFileSync('./package.json', JSON.stringify(pkg, null, '\t') + '\n'); + " + echo "Updated package.json version to $NEW_VERSION" + + - name: Clean dev folder + working-directory: apps/desktop + run: bun run clean:dev + + - name: Generate file icons + working-directory: apps/desktop + run: bun run generate:icons + + - name: Compile app with electron-vite + working-directory: apps/desktop + env: + NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} + NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GH_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }} + NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} + NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} + NEXT_PUBLIC_DOCS_URL: ${{ secrets.NEXT_PUBLIC_DOCS_URL }} + NEXT_PUBLIC_STREAMS_URL: ${{ secrets.NEXT_PUBLIC_STREAMS_URL }} + NEXT_PUBLIC_ELECTRIC_URL: ${{ secrets.NEXT_PUBLIC_ELECTRIC_URL }} + SENTRY_DSN_DESKTOP: ${{ secrets.SENTRY_DSN_DESKTOP }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + RELAY_URL: ${{ secrets.RELAY_URL }} + SUPERSET_WORKSPACE_NAME: superset + run: bun run compile:app + + - name: Build superset-browser-mcp binary + # Bun doesn't auto-run lifecycle hooks, so the prebuild step that emits + # Resources/resources/superset-browser-mcp/superset-browser-mcp.exe + # has to be invoked explicitly. + working-directory: apps/desktop + run: bun run build:browser-mcp + + - name: Build Electron app (NSIS) + working-directory: apps/desktop + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + run: bun run package -- --publish never --config ${{ inputs.electron_builder_config }} --win + + - name: Verify Windows installer + update manifest exist + working-directory: apps/desktop + run: | + ls -la release + test -n "$(ls -1 release/*.exe 2>/dev/null)" || { + echo "::error::No NSIS .exe generated in apps/desktop/release" + exit 1 + } + test -f release/latest.yml || { + echo "::error::latest.yml (auto-update manifest) missing" + exit 1 + } + + - name: Upload NSIS installer artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ inputs.artifact_prefix }}-win-x64-nsis + path: | + apps/desktop/release/*.exe + apps/desktop/release/*.exe.blockmap + retention-days: ${{ inputs.artifact_retention_days }} + if-no-files-found: error + + - name: Upload Windows auto-update manifest + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ inputs.artifact_prefix }}-win-x64-update-manifest + path: apps/desktop/release/latest.yml + retention-days: ${{ inputs.artifact_retention_days }} + if-no-files-found: error diff --git a/README.md b/README.md index ecff7fe8082..fe400844b94 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ If it runs in a terminal, it runs on Superset | Requirement | Details | |:------------|:--------| -| **OS** | macOS (Windows/Linux untested) | +| **OS** | macOS / Windows x64 (preview, this fork) / Linux (untested) | | **Runtime** | [Bun](https://bun.sh/) v1.0+ | | **Version Control** | Git 2.20+ | | **GitHub CLI** | [gh](https://cli.github.com/) | @@ -194,7 +194,41 @@ If it runs in a terminal, it runs on Superset ### Quick Start (Pre-built) -**[Download Superset for macOS](https://github.com/superset-sh/superset/releases/latest)** +- **macOS**: [Download Superset for macOS](https://github.com/superset-sh/superset/releases/latest) (upstream) +- **Windows x64** *(preview, this fork)*: [Latest MocA-Love/superset release](https://github.com/MocA-Love/superset/releases/latest) — pick the `Superset-*-x64.exe` NSIS installer. Windows support is a rolling preview; see the [Windows known limitations](#windows-preview-known-limitations) below. + +### Windows preview: known limitations + +Windows builds are a rolling preview tracked in issue [#273](https://github.com/MocA-Love/superset/issues/273). The app launches and the core workspace / terminal / chat flow works, but the following fork features are not yet on par with macOS: + +- **Agent wrapper (`~/.superset/bin` PATH injection)** — not yet generated on Windows. Agents (Claude Code / Codex / Gemini / cursor-agent / Copilot) still work via their global hook configs (`~/.claude/settings.json`, `~/.codex/hooks.json`, `~/.gemini/settings.json`, `~/.cursor/hooks.json`) but Superset-side PATH overrides for managed binaries are skipped. +- **GitHub Copilot CLI project hooks** — `.github/hooks/superset-notify.json` is auto-synced by the bash wrapper on Unix only. On Windows, place the hook file manually until the wrapper is ported to PowerShell. +- **Mica / Acrylic** — applied via `BrowserWindow.setBackgroundMaterial('mica')`; Windows 10 and pre-22H2 Windows 11 show the opaque fallback. +- **Auto-install on quit** — the fork checks GitHub API for updates on Windows like on macOS, but there's no NSIS auto-install-on-quit flow yet; clicking "Install" opens the release page for manual download. +- **Dev-mode deep-link** — `superset://` protocol is registered at install time via NSIS + `app.setAsDefaultProtocolClient`. Dev-mode (running `bun run dev` from source) does not register the protocol on Windows. + +### Building Windows locally + +```powershell +# Enable long paths (run once per machine; opens a UAC prompt). +git config --global core.longpaths true + +git clone https://github.com/MocA-Love/superset.git +cd superset + +# Skip lifecycle scripts; native modules are materialized by the postinstall .mjs. +bun install --frozen --ignore-scripts + +cd apps/desktop +$env:SUPERSET_WORKSPACE_NAME = 'superset' +bun run install:deps +bun run compile:app +bun run build:browser-mcp +bun run package -- --publish never --win +# Output: apps/desktop/release/*.exe + *.exe.blockmap + latest.yml +``` + +The Windows NSIS build is also available via GitHub Actions → **Build Desktop App** → *Run workflow* on `MocA-Love/superset`, which produces the same artifacts without needing a Windows machine. ### Build from Source diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index a0c1cbce862..fd30074b18e 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -96,8 +96,10 @@ const config: Configuration = { "!**/.DS_Store", ], - // Rebuild native modules for Electron's Node.js version - npmRebuild: true, + // Rebuild native modules for Electron's Node.js version. + // Disabled on Windows — native modules are materialized by install:deps + + // copy:native-modules, and node-gyp fails without Visual Studio Build Tools. + npmRebuild: process.platform !== "win32", // macOS DMG // NOTE: dmgbuild 1.2.0 は size = (sum(app files) + 128MB) を割り当てるが、 @@ -212,6 +214,9 @@ const config: Configuration = { nsis: { oneClick: false, allowToChangeInstallationDirectory: true, + createDesktopShortcut: true, + createStartMenuShortcut: true, + shortcutName: productName, }, }; diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 3637b123c23..310eff05ec9 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -15,6 +15,7 @@ import { defineEnv, devPath, htmlEnvTransformPlugin, + stripCrossOriginPlugin, } from "./vite/helpers"; // override: true ensures .env values take precedence over inherited env vars @@ -124,6 +125,11 @@ export default defineConfig({ }, output: { dir: resolve(devPath, "main"), + // VS Code and other Electron hosts set ELECTRON_RUN_AS_NODE=1 on + // their child process env; leaving it set puts Electron into plain + // Node mode and the app never opens a window. Clear it before any + // require("electron") call — must be the very first statement. + banner: "delete process.env.ELECTRON_RUN_AS_NODE;", }, external: ["electron", ...mainExternalizedDependencies], plugins: [sentryPlugin].filter(Boolean), @@ -253,6 +259,7 @@ export default defineConfig({ }), reactPlugin(), htmlEnvTransformPlugin(), + stripCrossOriginPlugin(), ], worker: { diff --git a/apps/desktop/scripts/copy-native-modules.ts b/apps/desktop/scripts/copy-native-modules.ts index c730c9de9ac..6008a77da06 100644 --- a/apps/desktop/scripts/copy-native-modules.ts +++ b/apps/desktop/scripts/copy-native-modules.ts @@ -122,8 +122,13 @@ function copyModuleIfSymlink( console.log(` ${moduleName}: symlink -> replacing with real files`); console.log(` Real path: ${realPath}`); - // Remove the symlink - rmSync(modulePath); + // Windows: Bun materializes direct deps as directory junctions instead of + // file symlinks; rmSync needs { recursive: true } to remove them. + if (process.platform === "win32") { + rmSync(modulePath, { recursive: true, force: true }); + } else { + rmSync(modulePath); + } // Copy the actual files cpSync(realPath, modulePath, { recursive: true }); @@ -275,7 +280,13 @@ function copyDependencyForPackage( const nestedStats = lstatSync(nestedDependencyPath); if (nestedStats.isSymbolicLink()) { const realPath = realpathSync(nestedDependencyPath); - rmSync(nestedDependencyPath); + // Windows: bun materializes these as directory junctions, not + // file symlinks; rmSync needs { recursive: true } to remove them. + if (process.platform === "win32") { + rmSync(nestedDependencyPath, { recursive: true, force: true }); + } else { + rmSync(nestedDependencyPath); + } cpSync(realPath, nestedDependencyPath, { recursive: true, }); diff --git a/apps/desktop/src/lib/electron-app/factories/app/setup.ts b/apps/desktop/src/lib/electron-app/factories/app/setup.ts index 67385353019..fae84025cc6 100644 --- a/apps/desktop/src/lib/electron-app/factories/app/setup.ts +++ b/apps/desktop/src/lib/electron-app/factories/app/setup.ts @@ -59,7 +59,9 @@ export async function makeAppSetup( return window; } -PLATFORM.IS_LINUX && app.disableHardwareAcceleration(); +// Disable GPU hardware acceleration on Linux and Windows to prevent black/blank +// screens caused by driver incompatibilities with Chromium's compositor. +(PLATFORM.IS_LINUX || PLATFORM.IS_WINDOWS) && app.disableHardwareAcceleration(); // macOS Sequoia+: occluded window throttling can corrupt GPU compositor layers if (PLATFORM.IS_MAC) { diff --git a/apps/desktop/src/lib/window-loader.ts b/apps/desktop/src/lib/window-loader.ts index 1b01eb63c66..e116fa54edd 100644 --- a/apps/desktop/src/lib/window-loader.ts +++ b/apps/desktop/src/lib/window-loader.ts @@ -24,9 +24,17 @@ export function registerRoute(props: { const url = `http://localhost:${env.DESKTOP_VITE_PORT}/#/`; console.log("[window-loader] Loading development URL:", url); props.browserWindow.loadURL(url); + } else if (process.platform === "win32") { + // Production (Windows): file:// breaks ES module dynamic imports + // (code-split route chunks) in Electron on Windows. The custom + // superset-app:// protocol — registered in main/index.ts — serves the + // same renderer files with proper module support so lazy routes load. + const url = "superset-app://app/index.html#/"; + console.log("[window-loader] Loading custom protocol URL:", url); + props.browserWindow.loadURL(url); } else { - // Production: load from file with hash routing - // TanStack Router uses hash-based routing, so we always start at #/ + // Production (macOS / Linux): load from file with hash routing. + // TanStack Router uses hash-based routing, so we always start at #/. console.log("[window-loader] Loading file:", props.htmlFile); props.browserWindow.loadFile(props.htmlFile, { hash: "/" }); } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index df771912681..de112d47706 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -656,6 +656,17 @@ protocol.registerSchemesAsPrivileged([ supportFetchAPI: true, }, }, + { + // Windows production loader uses this scheme so ES module dynamic imports + // (code-split route chunks) work — file:// breaks them on Windows. + scheme: "superset-app", + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, ]); const gotTheLock = app.requestSingleInstanceLock(); @@ -831,6 +842,53 @@ if (!gotTheLock) { .fromPartition("persist:superset") .protocol.handle("superset-workspace-media", workspaceMediaHandler); + // Windows production: serve renderer bundle through a custom protocol so + // ES module dynamic imports work (file:// breaks them on Windows). + if (PLATFORM.IS_WINDOWS && !IS_DEV) { + const rendererDir = path.join(__dirname, "../renderer"); + const appProtocolHandler = (request: Request) => { + let urlPath = new URL(request.url).pathname; + if (urlPath.startsWith("/")) urlPath = urlPath.slice(1); + const filePath = path.join(rendererDir, urlPath); + return net.fetch(pathToFileURL(filePath).toString()); + }; + protocol.handle("superset-app", appProtocolHandler); + session + .fromPartition("persist:superset") + .protocol.handle("superset-app", appProtocolHandler); + + // API server's CORS policy doesn't allow the custom scheme origin. + // Rewrite the outgoing Origin and echo back access-control-allow-origin + // so API / PostHog / Sentry calls succeed from superset-app://app. + const appSession = session.fromPartition("persist:superset"); + const apiHost = new URL( + process.env.NEXT_PUBLIC_API_URL || "https://api.superset.sh", + ).host; + const corsTargets = [ + `https://${apiHost}/*`, + "https://*.posthog.com/*", + "https://*.sentry.io/*", + ]; + appSession.webRequest.onBeforeSendHeaders( + { urls: corsTargets }, + (details, callback) => { + if (details.requestHeaders.Origin === "superset-app://app") { + delete details.requestHeaders.Origin; + } + callback({ requestHeaders: details.requestHeaders }); + }, + ); + appSession.webRequest.onHeadersReceived( + { urls: [`https://${apiHost}/*`] }, + (details, callback) => { + const headers = details.responseHeaders ?? {}; + headers["access-control-allow-origin"] = ["superset-app://app"]; + headers["access-control-allow-credentials"] = ["true"]; + callback({ responseHeaders: headers }); + }, + ); + } + // Serve user-uploaded icons for service-status definitions const serviceIconHandler = createServiceIconProtocolHandler(); protocol.handle("superset-service-icon", serviceIconHandler); diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts index 06ccefe25cb..2fe45ba2ed8 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts @@ -2,8 +2,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { + buildHookCommand, buildWrapperScript, createWrapper, + hookTemplateExtension, + IS_WIN_AGENT, isSupersetManagedHookCommand, writeFileIfChanged, } from "./agent-wrappers-common"; @@ -24,7 +27,7 @@ const OPENCODE_PLUGIN_TEMPLATE_PATH = path.join( const CODEX_WRAPPER_EXEC_TEMPLATE_PATH = path.join( __dirname, "templates", - "codex-wrapper-exec.template.sh", + `codex-wrapper-exec.template.${hookTemplateExtension()}`, ); /** @@ -78,6 +81,13 @@ function isPlainObject(value: unknown): value is Record { * shared ~/.claude/settings.json works for both dev and prod installs. */ export function getClaudeManagedHookCommand(): string { + if (IS_WIN_AGENT) { + // Claude Code on Windows invokes hook `command` strings via the user's + // default shell (PowerShell on modern installs). The wrapper resolves + // SUPERSET_HOME_DIR at runtime and dispatches notify.ps1 only when the + // file exists so the hook is a no-op outside Superset terminals. + return `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "if ($env:SUPERSET_HOME_DIR -and (Test-Path \\"$env:SUPERSET_HOME_DIR/${CLAUDE_DYNAMIC_NOTIFY_RELATIVE_PATH}\\")) { & powershell.exe -NoProfile -ExecutionPolicy Bypass -File \\"$env:SUPERSET_HOME_DIR/${CLAUDE_DYNAMIC_NOTIFY_RELATIVE_PATH}\\" }"`; + } return `[ -n "$SUPERSET_HOME_DIR" ] && [ -x "$SUPERSET_HOME_DIR/${CLAUDE_DYNAMIC_NOTIFY_RELATIVE_PATH}" ] && "$SUPERSET_HOME_DIR/${CLAUDE_DYNAMIC_NOTIFY_RELATIVE_PATH}" || true`; } @@ -279,6 +289,12 @@ export function createClaudeWrapper(): void { * session-log watcher for prompt/permission events inside Superset terminals. */ export function createCodexWrapper(): void { + if (IS_WIN_AGENT) { + // The Codex wrapper is a bash agent-wrapper that injects the session-log + // watcher. Wrapper generation is still Unix-only (see agent-setup/index.ts); + // on Windows we rely solely on the hooks.json integration below. + return; + } const notifyPath = getNotifyScriptPath(); const script = buildWrapperScript( "codex", @@ -392,19 +408,25 @@ export function getCodexGlobalHooksJsonContent( { eventName: "SessionStart", definition: { - hooks: [{ type: "command", command: notifyScriptPath }], + hooks: [ + { type: "command", command: buildHookCommand(notifyScriptPath) }, + ], }, }, { eventName: "UserPromptSubmit", definition: { - hooks: [{ type: "command", command: notifyScriptPath }], + hooks: [ + { type: "command", command: buildHookCommand(notifyScriptPath) }, + ], }, }, { eventName: "Stop", definition: { - hooks: [{ type: "command", command: notifyScriptPath }], + hooks: [ + { type: "command", command: buildHookCommand(notifyScriptPath) }, + ], }, }, ]; diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts index a6434b0000c..e5bd12f072d 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts @@ -6,6 +6,38 @@ import { BIN_DIR } from "./paths"; export const WRAPPER_MARKER = "# Superset agent-wrapper v1"; export { SUPERSET_MANAGED_BINARIES }; +export const IS_WIN_AGENT = process.platform === "win32"; + +/** Extension used for the concrete hook script on the host platform. */ +export function hookScriptExtension(): "ps1" | "sh" { + return IS_WIN_AGENT ? "ps1" : "sh"; +} + +/** Extension used for the bundled template shipped alongside the app. */ +export function hookTemplateExtension(): "ps1" | "sh" { + return IS_WIN_AGENT ? "ps1" : "sh"; +} + +/** + * Build the shell invocation used inside agent hook configs (hooks.json, + * settings.json, project-level hook files, etc.). On Windows we must go + * through `powershell.exe -NoProfile -ExecutionPolicy Bypass -File` because + * .ps1 files are not directly executable from foreign runtimes and the + * per-user execution policy would otherwise block unsigned scripts. + */ +export function buildHookCommand( + hookScriptPath: string, + ...args: string[] +): string { + const quotedArgs = args.map((arg) => `"${arg}"`).join(" "); + if (IS_WIN_AGENT) { + return `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${hookScriptPath}"${ + quotedArgs ? ` ${quotedArgs}` : "" + }`; + } + return quotedArgs ? `${hookScriptPath} ${quotedArgs}` : hookScriptPath; +} + // Dev setup (.superset/lib/setup/steps.sh) points SUPERSET_HOME_DIR at // $PWD/superset-dev-data — without a leading dot — so we must recognize that // variant to reap stale notify.sh paths from deleted worktrees. @@ -130,6 +162,14 @@ ${execLine} `; } +/** + * Platform-aware snippet embedded into agent hook templates. Windows hooks + * rely on the main process's powerSaveBlocker instead (#273 follow-up). + */ +export function getSleepInhibitorSnippet(): string { + return IS_WIN_AGENT ? "" : getSleepInhibitorShellSnippet(); +} + export function getSleepInhibitorShellSnippet(): string { return `_superset_manage_sleep_inhibitor() { [ -n "$SUPERSET_WRAPPER_PID" ] || return 0 @@ -194,6 +234,13 @@ _superset_manage_sleep_inhibitor } export function createWrapper(binaryName: string, script: string): void { + if (IS_WIN_AGENT) { + // Agent wrappers are bash scripts (`#!/bin/bash` + find_real_binary). + // Skipping them on Windows keeps agent-setup bootable while relying on + // hooks.json / settings.json for lifecycle integration. Wrapper-driven + // PATH injection and sleep-inhibitor are tracked as follow-ups in #273. + return; + } const changed = writeFileIfChanged(getWrapperPath(binaryName), script, 0o755); console.log( `[agent-setup] ${changed ? "Updated" : "Verified"} ${binaryName} wrapper`, diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts index fa2d92237be..d37c6526184 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts @@ -2,14 +2,18 @@ import fs from "node:fs"; import path from "node:path"; import { env } from "shared/env.shared"; import { + buildHookCommand, buildWrapperScript, createWrapper, - getSleepInhibitorShellSnippet, + getSleepInhibitorSnippet, + hookScriptExtension, + hookTemplateExtension, + IS_WIN_AGENT, writeFileIfChanged, } from "./agent-wrappers-common"; import { HOOKS_DIR } from "./paths"; -export const COPILOT_HOOK_SCRIPT_NAME = "copilot-hook.sh"; +export const COPILOT_HOOK_SCRIPT_NAME = `copilot-hook.${hookScriptExtension()}`; const COPILOT_HOOK_SIGNATURE = "# Superset copilot hook"; const COPILOT_HOOK_VERSION = "v1"; @@ -18,7 +22,7 @@ export const COPILOT_HOOK_MARKER = `${COPILOT_HOOK_SIGNATURE} ${COPILOT_HOOK_VER const COPILOT_HOOK_TEMPLATE_PATH = path.join( __dirname, "templates", - "copilot-hook.template.sh", + `copilot-hook.template.${hookTemplateExtension()}`, ); export function getCopilotHookScriptPath(): string { @@ -29,7 +33,7 @@ export function getCopilotHookScriptContent(): string { const template = fs.readFileSync(COPILOT_HOOK_TEMPLATE_PATH, "utf-8"); return template .replace("{{MARKER}}", COPILOT_HOOK_MARKER) - .replace("{{SLEEP_INHIBITOR_SNIPPET}}", getSleepInhibitorShellSnippet()) + .replace("{{SLEEP_INHIBITOR_SNIPPET}}", getSleepInhibitorSnippet()) .replace(/\{\{DEFAULT_PORT\}\}/g, String(env.DESKTOP_NOTIFICATIONS_PORT)); } @@ -43,36 +47,30 @@ export function createCopilotHookScript(): void { } export function getCopilotHooksJsonContent(hookScriptPath: string): string { + // Copilot CLI routes hook commands through its `bash` key on POSIX and + // through `powershell` on Windows. Always emit the correct one for the + // runtime so hook config is portable across dev machines. + const commandKey = IS_WIN_AGENT ? "powershell" : "bash"; + const cmd = (event: string): string => + buildHookCommand(hookScriptPath, event); const hooks = { version: 1, hooks: { sessionStart: [ - { - type: "command", - bash: `${hookScriptPath} sessionStart`, - timeoutSec: 5, - }, + { type: "command", [commandKey]: cmd("sessionStart"), timeoutSec: 5 }, ], sessionEnd: [ - { - type: "command", - bash: `${hookScriptPath} sessionEnd`, - timeoutSec: 5, - }, + { type: "command", [commandKey]: cmd("sessionEnd"), timeoutSec: 5 }, ], userPromptSubmitted: [ { type: "command", - bash: `${hookScriptPath} userPromptSubmitted`, + [commandKey]: cmd("userPromptSubmitted"), timeoutSec: 5, }, ], postToolUse: [ - { - type: "command", - bash: `${hookScriptPath} postToolUse`, - timeoutSec: 5, - }, + { type: "command", [commandKey]: cmd("postToolUse"), timeoutSec: 5 }, ], }, }; diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-cursor.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-cursor.ts index 2bbc25af7a4..2ae3fae5ca9 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-cursor.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-cursor.ts @@ -3,16 +3,19 @@ import os from "node:os"; import path from "node:path"; import { env } from "shared/env.shared"; import { + buildHookCommand, buildWrapperScript, createWrapper, - getSleepInhibitorShellSnippet, + getSleepInhibitorSnippet, + hookScriptExtension, + hookTemplateExtension, isSupersetManagedHookCommand, reconcileManagedEntries, writeFileIfChanged, } from "./agent-wrappers-common"; import { HOOKS_DIR } from "./paths"; -export const CURSOR_HOOK_SCRIPT_NAME = "cursor-hook.sh"; +export const CURSOR_HOOK_SCRIPT_NAME = `cursor-hook.${hookScriptExtension()}`; const CURSOR_HOOK_SIGNATURE = "# Superset cursor hook"; const CURSOR_HOOK_VERSION = "v1"; @@ -21,7 +24,7 @@ export const CURSOR_HOOK_MARKER = `${CURSOR_HOOK_SIGNATURE} ${CURSOR_HOOK_VERSIO const CURSOR_HOOK_TEMPLATE_PATH = path.join( __dirname, "templates", - "cursor-hook.template.sh", + `cursor-hook.template.${hookTemplateExtension()}`, ); interface CursorHookEntry { @@ -47,7 +50,7 @@ export function getCursorHookScriptContent(): string { const template = fs.readFileSync(CURSOR_HOOK_TEMPLATE_PATH, "utf-8"); return template .replace("{{MARKER}}", CURSOR_HOOK_MARKER) - .replace("{{SLEEP_INHIBITOR_SNIPPET}}", getSleepInhibitorShellSnippet()) + .replace("{{SLEEP_INHIBITOR_SNIPPET}}", getSleepInhibitorSnippet()) .replace(/\{\{DEFAULT_PORT\}\}/g, String(env.DESKTOP_NOTIFICATIONS_PORT)); } @@ -77,13 +80,13 @@ export function getCursorHooksJsonContent(hookScriptPath: string): string { } const ourHooks: Record = { - beforeSubmitPrompt: { command: `${hookScriptPath} Start` }, - stop: { command: `${hookScriptPath} Stop` }, + beforeSubmitPrompt: { command: buildHookCommand(hookScriptPath, "Start") }, + stop: { command: buildHookCommand(hookScriptPath, "Stop") }, beforeShellExecution: { - command: `${hookScriptPath} PermissionRequest`, + command: buildHookCommand(hookScriptPath, "PermissionRequest"), }, beforeMCPExecution: { - command: `${hookScriptPath} PermissionRequest`, + command: buildHookCommand(hookScriptPath, "PermissionRequest"), }, }; diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts index 123ba266e75..3d06ee8724f 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts @@ -3,16 +3,19 @@ import os from "node:os"; import path from "node:path"; import { env } from "shared/env.shared"; import { + buildHookCommand, buildWrapperScript, createWrapper, - getSleepInhibitorShellSnippet, + getSleepInhibitorSnippet, + hookScriptExtension, + hookTemplateExtension, isSupersetManagedHookCommand, reconcileManagedEntries, writeFileIfChanged, } from "./agent-wrappers-common"; import { HOOKS_DIR } from "./paths"; -export const GEMINI_HOOK_SCRIPT_NAME = "gemini-hook.sh"; +export const GEMINI_HOOK_SCRIPT_NAME = `gemini-hook.${hookScriptExtension()}`; const GEMINI_HOOK_SIGNATURE = "# Superset gemini hook"; const GEMINI_HOOK_VERSION = "v1"; @@ -21,7 +24,7 @@ export const GEMINI_HOOK_MARKER = `${GEMINI_HOOK_SIGNATURE} ${GEMINI_HOOK_VERSIO const GEMINI_HOOK_TEMPLATE_PATH = path.join( __dirname, "templates", - "gemini-hook.template.sh", + `gemini-hook.template.${hookTemplateExtension()}`, ); interface GeminiHookConfig { @@ -53,7 +56,7 @@ export function getGeminiHookScriptContent(): string { const template = fs.readFileSync(GEMINI_HOOK_TEMPLATE_PATH, "utf-8"); return template .replace("{{MARKER}}", GEMINI_HOOK_MARKER) - .replace("{{SLEEP_INHIBITOR_SNIPPET}}", getSleepInhibitorShellSnippet()) + .replace("{{SLEEP_INHIBITOR_SNIPPET}}", getSleepInhibitorSnippet()) .replace(/\{\{DEFAULT_PORT\}\}/g, String(env.DESKTOP_NOTIFICATIONS_PORT)); } @@ -88,7 +91,7 @@ export function getGeminiSettingsJsonContent(hookScriptPath: string): string { const current = existing.hooks[eventName]; const desiredEntries: GeminiHookDefinition[] = [ { - hooks: [{ type: "command", command: hookScriptPath }], + hooks: [{ type: "command", command: buildHookCommand(hookScriptPath) }], }, ]; const { entries } = reconcileManagedEntries({ diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts index a892dd42f48..6dd104a9e5d 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts @@ -310,7 +310,7 @@ exit 0 expect( beforeSubmitPrompt.some( - (entry) => entry.command === `${currentHookPath} Start`, + (entry) => entry.command === `${currentHookPath} "Start"`, ), ).toBe(true); expect( diff --git a/apps/desktop/src/main/lib/agent-setup/index.ts b/apps/desktop/src/main/lib/agent-setup/index.ts index 3b8ddd33324..d276f1e24b6 100644 --- a/apps/desktop/src/main/lib/agent-setup/index.ts +++ b/apps/desktop/src/main/lib/agent-setup/index.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { PLATFORM } from "shared/constants"; import { setupDesktopAgentCapabilities } from "./desktop-agent-setup"; import { BASH_DIR, @@ -18,16 +19,26 @@ import { export function setupAgentHooks(): void { console.log("[agent-setup] Initializing agent hooks..."); - fs.mkdirSync(BIN_DIR, { recursive: true }); + // Only the bash/zsh rc wrappers and the PATH-injection bin directory are + // strictly Unix-only; HOOKS_DIR and OPENCODE_PLUGIN_DIR are platform-neutral. fs.mkdirSync(HOOKS_DIR, { recursive: true }); - fs.mkdirSync(ZSH_DIR, { recursive: true }); - fs.mkdirSync(BASH_DIR, { recursive: true }); fs.mkdirSync(OPENCODE_PLUGIN_DIR, { recursive: true }); + if (!PLATFORM.IS_WINDOWS) { + fs.mkdirSync(BIN_DIR, { recursive: true }); + fs.mkdirSync(ZSH_DIR, { recursive: true }); + fs.mkdirSync(BASH_DIR, { recursive: true }); + } setupDesktopAgentCapabilities(); - createZshWrapper(); - createBashWrapper(); + if (!PLATFORM.IS_WINDOWS) { + createZshWrapper(); + createBashWrapper(); + } else { + console.log( + "[agent-setup] Skipping bash/zsh rc wrappers on Windows — hooks.json / settings.json integration still active", + ); + } console.log("[agent-setup] Agent hooks initialized"); } diff --git a/apps/desktop/src/main/lib/agent-setup/notify-hook.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.ts index 056833fe5bb..d6ce1b0e710 100644 --- a/apps/desktop/src/main/lib/agent-setup/notify-hook.ts +++ b/apps/desktop/src/main/lib/agent-setup/notify-hook.ts @@ -1,16 +1,21 @@ import fs from "node:fs"; import path from "node:path"; import { env } from "shared/env.shared"; -import { getSleepInhibitorShellSnippet } from "./agent-wrappers-common"; +import { + getSleepInhibitorSnippet, + hookScriptExtension, + hookTemplateExtension, + IS_WIN_AGENT, +} from "./agent-wrappers-common"; import { HOOKS_DIR } from "./paths"; -export const NOTIFY_SCRIPT_NAME = "notify.sh"; +export const NOTIFY_SCRIPT_NAME = IS_WIN_AGENT ? "notify.ps1" : "notify.sh"; export const NOTIFY_SCRIPT_MARKER = "# Superset agent notification hook"; const NOTIFY_SCRIPT_TEMPLATE_PATH = path.join( __dirname, "templates", - "notify-hook.template.sh", + `notify-hook.template.${hookTemplateExtension()}`, ); function writeFileIfChanged( @@ -38,11 +43,14 @@ export function getNotifyScriptPath(): string { return path.join(HOOKS_DIR, NOTIFY_SCRIPT_NAME); } +// Re-exported for wrappers that need to know the active extension at runtime. +export { hookScriptExtension }; + export function getNotifyScriptContent(): string { const template = fs.readFileSync(NOTIFY_SCRIPT_TEMPLATE_PATH, "utf-8"); return template .replaceAll("{{MARKER}}", NOTIFY_SCRIPT_MARKER) - .replace("{{SLEEP_INHIBITOR_SNIPPET}}", getSleepInhibitorShellSnippet()) + .replace("{{SLEEP_INHIBITOR_SNIPPET}}", getSleepInhibitorSnippet()) .replaceAll("{{DEFAULT_PORT}}", String(env.DESKTOP_NOTIFICATIONS_PORT)); } diff --git a/apps/desktop/src/main/lib/agent-setup/templates/codex-wrapper-exec.template.ps1 b/apps/desktop/src/main/lib/agent-setup/templates/codex-wrapper-exec.template.ps1 new file mode 100644 index 00000000000..658a6085b2f --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/templates/codex-wrapper-exec.template.ps1 @@ -0,0 +1,101 @@ +# PowerShell counterpart to codex-wrapper-exec.template.sh. +# +# Native ~/.codex/hooks.json already owns SessionStart / UserPromptSubmit / Stop. +# The wrapper keeps a tail-based session-log watcher only for per-prompt Start +# notifications and permission requests inside Superset terminals. +$ErrorActionPreference = 'Continue' + +$WatcherJob = $null + +if ($env:SUPERSET_TAB_ID -and (Test-Path -LiteralPath '{{NOTIFY_PATH}}')) { + $env:CODEX_TUI_RECORD_SESSION = '1' + if (-not $env:CODEX_TUI_SESSION_LOG_PATH) { + $ts = [int][double]::Parse((Get-Date -UFormat %s)) + $tmp = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() } + $env:CODEX_TUI_SESSION_LOG_PATH = Join-Path $tmp ("superset-codex-session-{0}_{1}.jsonl" -f $PID, $ts) + } + + $notifyPath = '{{NOTIFY_PATH}}' + $logPath = $env:CODEX_TUI_SESSION_LOG_PATH + + $WatcherJob = Start-Job -ScriptBlock { + param($log, $notify) + + function Send-HookEvent([string]$notifyScript, [string]$eventName) { + $payload = ('{{"hook_event_name":"{0}"}}' -f $eventName) + try { + powershell.exe -NoProfile -ExecutionPolicy Bypass -File $notifyScript $payload | Out-Null + } catch { + # Silent — lifecycle notifications must not block codex. + } + } + + $lastTurnId = '' + $lastApprovalId = '' + $lastExecCallId = '' + $approvalFallback = 0 + + # Wait (up to ~10s) for codex to create the session log. + for ($i = 0; $i -lt 200 -and -not (Test-Path -LiteralPath $log); $i++) { + Start-Sleep -Milliseconds 50 + } + if (-not (Test-Path -LiteralPath $log)) { return } + + Get-Content -LiteralPath $log -Wait -Tail 0 | ForEach-Object { + $line = $_ + + if ($line -match '"dir":"to_tui"' -and $line -match '"kind":"codex_event"' -and $line -match '"msg":\{"type":"task_started"') { + $m = [regex]::Match($line, '"turn_id":"([^"]*)"') + $turnId = if ($m.Success) { $m.Groups[1].Value } else { 'task_started' } + if ($turnId -ne $lastTurnId) { + $lastTurnId = $turnId + Send-HookEvent -notifyScript $notify -eventName 'Start' + } + return + } + + if ($line -match '"dir":"to_tui"' -and $line -match '"kind":"codex_event"' -and $line -match '"msg":\{"type":"[^"]*_approval_request"') { + $approvalId = '' + foreach ($field in @('id', 'approval_id', 'call_id')) { + $pattern = '"' + $field + '":"([^"]*)"' + $m = [regex]::Match($line, $pattern) + if ($m.Success) { $approvalId = $m.Groups[1].Value; break } + } + if (-not $approvalId) { + $approvalFallback++ + $approvalId = "approval_request_$approvalFallback" + } + if ($approvalId -ne $lastApprovalId) { + $lastApprovalId = $approvalId + Send-HookEvent -notifyScript $notify -eventName 'PermissionRequest' + } + return + } + + if ($line -match '"dir":"to_tui"' -and $line -match '"kind":"codex_event"' -and $line -match '"msg":\{"type":"exec_command_begin"') { + $m = [regex]::Match($line, '"call_id":"([^"]*)"') + $execCallId = if ($m.Success) { $m.Groups[1].Value } else { '' } + if ($execCallId) { + if ($execCallId -ne $lastExecCallId) { + $lastExecCallId = $execCallId + Send-HookEvent -notifyScript $notify -eventName 'Start' + } + } else { + Send-HookEvent -notifyScript $notify -eventName 'Start' + } + } + } + } -ArgumentList $logPath, $notifyPath +} + +try { + & '{{REAL_BIN}}' --enable codex_hooks @args + $codexStatus = $LASTEXITCODE +} finally { + if ($WatcherJob) { + Stop-Job -Job $WatcherJob -PassThru | Receive-Job -ErrorAction SilentlyContinue | Out-Null + Remove-Job -Job $WatcherJob -Force -ErrorAction SilentlyContinue | Out-Null + } +} + +exit $codexStatus diff --git a/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.ps1 b/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.ps1 new file mode 100644 index 00000000000..8e7c802e2ac --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.ps1 @@ -0,0 +1,55 @@ +# {{MARKER}} +# Called by GitHub Copilot CLI hooks to notify Superset of agent lifecycle events. +# The Copilot CLI expects valid JSON on stdout; emit it ASAP so the agent is not +# blocked while we fire the notification request. +[CmdletBinding()] +param([string]$EventName) + +$ErrorActionPreference = 'Continue' + +# Drain stdin so the agent isn't blocked by a broken pipe. +if ([Console]::IsInputRedirected) { + [Console]::In.ReadToEnd() | Out-Null +} + +switch ($EventName) { + 'sessionStart' { $EventType = 'Start' } + 'sessionEnd' { $EventType = 'Stop' } + 'userPromptSubmitted' { $EventType = 'Start' } + 'postToolUse' { $EventType = 'Start' } + 'preToolUse' { $EventType = 'PermissionRequest' } + default { + Write-Output '{}' + exit 0 + } +} + +# Must output valid JSON to avoid blocking the agent. +Write-Output '{}' + +if (-not $env:SUPERSET_TAB_ID) { exit 0 } + +{{SLEEP_INHIBITOR_SNIPPET}} + +$Port = if ($env:SUPERSET_PORT) { $env:SUPERSET_PORT } else { '{{DEFAULT_PORT}}' } +$Fields = [ordered]@{ + paneId = $env:SUPERSET_PANE_ID + tabId = $env:SUPERSET_TAB_ID + workspaceId = $env:SUPERSET_WORKSPACE_ID + eventType = $EventType + env = $env:SUPERSET_ENV + version = $env:SUPERSET_HOOK_VERSION +} +$Query = ($Fields.GetEnumerator() | ForEach-Object { + $value = if ($null -eq $_.Value) { '' } else { [string]$_.Value } + "{0}={1}" -f [Uri]::EscapeDataString($_.Key), [Uri]::EscapeDataString($value) +}) -join '&' + +try { + Invoke-WebRequest -Uri "http://127.0.0.1:$Port/hook/complete?$Query" ` + -Method Get -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop | Out-Null +} catch { + # Silent — the agent must not be blocked by transient notification failures. +} + +exit 0 diff --git a/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.ps1 b/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.ps1 new file mode 100644 index 00000000000..d7b4886b296 --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.ps1 @@ -0,0 +1,50 @@ +# {{MARKER}} +# Called by cursor-agent hooks to notify Superset of agent lifecycle events. +# Permission hooks must respond with {"continue":true} so execution is not blocked. +[CmdletBinding()] +param([string]$EventName) + +$ErrorActionPreference = 'Continue' + +# Drain stdin so the agent isn't blocked by a broken pipe. +if ([Console]::IsInputRedirected) { + [Console]::In.ReadToEnd() | Out-Null +} + +switch ($EventName) { + 'Start' { $EventType = 'Start'; $NeedsResponse = $false } + 'Stop' { $EventType = 'Stop'; $NeedsResponse = $false } + 'PermissionRequest' { $EventType = 'PermissionRequest'; $NeedsResponse = $true } + default { exit 0 } +} + +if ($NeedsResponse) { + Write-Output '{"continue":true}' +} + +if (-not $env:SUPERSET_TAB_ID) { exit 0 } + +{{SLEEP_INHIBITOR_SNIPPET}} + +$Port = if ($env:SUPERSET_PORT) { $env:SUPERSET_PORT } else { '{{DEFAULT_PORT}}' } +$Fields = [ordered]@{ + paneId = $env:SUPERSET_PANE_ID + tabId = $env:SUPERSET_TAB_ID + workspaceId = $env:SUPERSET_WORKSPACE_ID + eventType = $EventType + env = $env:SUPERSET_ENV + version = $env:SUPERSET_HOOK_VERSION +} +$Query = ($Fields.GetEnumerator() | ForEach-Object { + $value = if ($null -eq $_.Value) { '' } else { [string]$_.Value } + "{0}={1}" -f [Uri]::EscapeDataString($_.Key), [Uri]::EscapeDataString($value) +}) -join '&' + +try { + Invoke-WebRequest -Uri "http://127.0.0.1:$Port/hook/complete?$Query" ` + -Method Get -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop | Out-Null +} catch { + # Silent. +} + +exit 0 diff --git a/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.ps1 b/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.ps1 new file mode 100644 index 00000000000..238c672ea59 --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.ps1 @@ -0,0 +1,58 @@ +# {{MARKER}} +# Called by Gemini CLI hooks to notify Superset of agent lifecycle events. +# Gemini passes JSON via stdin and expects valid JSON on stdout. +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Continue' + +function Get-JsonStringValue([string]$Text, [string]$Key) { + if (-not $Text) { return '' } + $pattern = '"' + [regex]::Escape($Key) + '"\s*:\s*"([^"]*)"' + $match = [regex]::Match($Text, $pattern) + if ($match.Success) { return $match.Groups[1].Value } + return '' +} + +$Input = [Console]::In.ReadToEnd() +$GeminiEvent = Get-JsonStringValue $Input 'hook_event_name' + +switch ($GeminiEvent) { + 'BeforeAgent' { $EventType = 'Start' } + 'AfterAgent' { $EventType = 'Stop' } + 'AfterTool' { $EventType = 'Start' } + default { + Write-Output '{}' + exit 0 + } +} + +# Output the required JSON response immediately to avoid blocking the agent. +Write-Output '{}' + +if (-not $env:SUPERSET_TAB_ID) { exit 0 } + +{{SLEEP_INHIBITOR_SNIPPET}} + +$Port = if ($env:SUPERSET_PORT) { $env:SUPERSET_PORT } else { '{{DEFAULT_PORT}}' } +$Fields = [ordered]@{ + paneId = $env:SUPERSET_PANE_ID + tabId = $env:SUPERSET_TAB_ID + workspaceId = $env:SUPERSET_WORKSPACE_ID + eventType = $EventType + env = $env:SUPERSET_ENV + version = $env:SUPERSET_HOOK_VERSION +} +$Query = ($Fields.GetEnumerator() | ForEach-Object { + $value = if ($null -eq $_.Value) { '' } else { [string]$_.Value } + "{0}={1}" -f [Uri]::EscapeDataString($_.Key), [Uri]::EscapeDataString($value) +}) -join '&' + +try { + Invoke-WebRequest -Uri "http://127.0.0.1:$Port/hook/complete?$Query" ` + -Method Get -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop | Out-Null +} catch { + # Silent. +} + +exit 0 diff --git a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.ps1 b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.ps1 new file mode 100644 index 00000000000..8612f89830f --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.ps1 @@ -0,0 +1,87 @@ +# {{MARKER}} +# Called by CLI agents (Claude Code, Codex, etc.) when they complete or need input. +# This is the PowerShell sibling of notify-hook.template.sh — keep the two in lockstep. +[CmdletBinding()] +param([string]$JsonArg) + +$ErrorActionPreference = 'Continue' + +function Get-JsonStringValue([string]$Text, [string]$Key) { + if (-not $Text) { return '' } + $pattern = '"' + [regex]::Escape($Key) + '"\s*:\s*"([^"]*)"' + $match = [regex]::Match($Text, $pattern) + if ($match.Success) { return $match.Groups[1].Value } + return '' +} + +# Codex passes JSON as argument, Claude pipes it to stdin. +$Json = if ($JsonArg) { $JsonArg } else { [Console]::In.ReadToEnd() } + +$HookSessionId = Get-JsonStringValue $Json 'session_id' +$ResourceId = Get-JsonStringValue $Json 'resourceId' +if (-not $ResourceId) { $ResourceId = Get-JsonStringValue $Json 'resource_id' } +$SessionId = if ($ResourceId) { $ResourceId } else { $HookSessionId } + +if (-not $env:SUPERSET_TAB_ID -and -not $SessionId) { exit 0 } + +$EventType = Get-JsonStringValue $Json 'hook_event_name' +if (-not $EventType) { + $CodexType = Get-JsonStringValue $Json 'type' + switch ($CodexType) { + 'agent-turn-complete' { $EventType = 'Stop' } + 'task_complete' { $EventType = 'Stop' } + 'task_started' { $EventType = 'Start' } + 'exec_approval_request' { $EventType = 'PermissionRequest' } + 'apply_patch_approval_request' { $EventType = 'PermissionRequest' } + 'request_user_input' { $EventType = 'PermissionRequest' } + } +} + +if ($EventType -eq 'UserPromptSubmit') { $EventType = 'Start' } +if (-not $EventType) { exit 0 } + +{{SLEEP_INHIBITOR_SNIPPET}} + +$DebugEnabled = $false +if ($env:SUPERSET_DEBUG_HOOKS) { + if ($env:SUPERSET_DEBUG_HOOKS -match '^(1|true|TRUE|True|yes|YES|on|ON)$') { + $DebugEnabled = $true + } +} elseif ($env:SUPERSET_ENV -eq 'development' -or $env:NODE_ENV -eq 'development') { + $DebugEnabled = $true +} + +if ($DebugEnabled) { + [Console]::Error.WriteLine("[notify-hook] event=$EventType sessionId=$SessionId hookSessionId=$HookSessionId resourceId=$ResourceId paneId=$env:SUPERSET_PANE_ID tabId=$env:SUPERSET_TAB_ID workspaceId=$env:SUPERSET_WORKSPACE_ID wrapperPid=$env:SUPERSET_WRAPPER_PID") +} + +$Port = if ($env:SUPERSET_PORT) { $env:SUPERSET_PORT } else { '{{DEFAULT_PORT}}' } +$Fields = [ordered]@{ + paneId = $env:SUPERSET_PANE_ID + tabId = $env:SUPERSET_TAB_ID + workspaceId = $env:SUPERSET_WORKSPACE_ID + sessionId = $SessionId + hookSessionId = $HookSessionId + resourceId = $ResourceId + eventType = $EventType + env = $env:SUPERSET_ENV + version = $env:SUPERSET_HOOK_VERSION +} +$Query = ($Fields.GetEnumerator() | ForEach-Object { + $value = if ($null -eq $_.Value) { '' } else { [string]$_.Value } + "{0}={1}" -f [Uri]::EscapeDataString($_.Key), [Uri]::EscapeDataString($value) +}) -join '&' + +try { + $response = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/hook/complete?$Query" ` + -Method Get -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop + if ($DebugEnabled) { + [Console]::Error.WriteLine("[notify-hook] dispatched status=$($response.StatusCode)") + } +} catch { + if ($DebugEnabled) { + [Console]::Error.WriteLine("[notify-hook] dispatch failed: $_") + } +} + +exit 0 diff --git a/apps/desktop/src/main/lib/agent-sleep/windows-sleep-blocker.ts b/apps/desktop/src/main/lib/agent-sleep/windows-sleep-blocker.ts new file mode 100644 index 00000000000..6b3aee879a3 --- /dev/null +++ b/apps/desktop/src/main/lib/agent-sleep/windows-sleep-blocker.ts @@ -0,0 +1,104 @@ +import { settings } from "@superset/local-db"; +import { powerSaveBlocker } from "electron"; +import { localDb } from "main/lib/local-db"; +import { DEFAULT_PREVENT_AGENT_SLEEP, PLATFORM } from "shared/constants"; +import type { AgentLifecycleEvent } from "shared/notification-types"; + +/** + * Windows sleep-prevention, driven by agent lifecycle events. + * + * macOS and Linux run a bash-embedded `caffeinate` / `systemd-inhibit` inside + * each agent wrapper so inhibition follows the wrapper process lifetime. On + * Windows we don't ship wrappers (no bash runtime, PowerShell profile injection + * is a follow-up), so we centralise inhibit tracking in the main process via + * Electron's `powerSaveBlocker`. + * + * An entry is added to `active` when an agent transitions into Start or + * PermissionRequest, and removed on Stop or on terminal exit. While at least + * one entry is present, a single `prevent-app-suspension` blocker is held; + * `prevent-display-sleep` is intentionally avoided — it keeps the monitor on, + * which is a user-hostile default for "don't sleep while agents are running." + */ + +const active = new Set(); +let blockerId: number | null = null; + +function preventSleepSettingEnabled(): boolean { + try { + return ( + localDb.select().from(settings).get()?.preventAgentSleep ?? + DEFAULT_PREVENT_AGENT_SLEEP + ); + } catch { + return DEFAULT_PREVENT_AGENT_SLEEP; + } +} + +function buildKey(event: AgentLifecycleEvent): string { + return `${event.workspaceId ?? "-"}:${event.tabId ?? "-"}:${event.paneId ?? "-"}`; +} + +function ensureBlocker(): void { + if (blockerId !== null) return; + try { + blockerId = powerSaveBlocker.start("prevent-app-suspension"); + } catch (error) { + console.warn( + "[windows-sleep-blocker] Failed to start powerSaveBlocker:", + error, + ); + } +} + +function releaseBlocker(): void { + if (blockerId === null) return; + try { + powerSaveBlocker.stop(blockerId); + } catch (error) { + console.warn( + "[windows-sleep-blocker] Failed to stop powerSaveBlocker:", + error, + ); + } + blockerId = null; +} + +export function handleAgentLifecycleForWindowsSleep( + event: AgentLifecycleEvent, +): void { + if (!PLATFORM.IS_WINDOWS) return; + if (!preventSleepSettingEnabled()) { + // Setting was toggled off mid-flight — drop any inhibitor we were holding. + if (blockerId !== null) { + active.clear(); + releaseBlocker(); + } + return; + } + + const key = buildKey(event); + switch (event.eventType) { + case "Start": + case "PermissionRequest": + active.add(key); + ensureBlocker(); + break; + case "Stop": + active.delete(key); + if (active.size === 0) releaseBlocker(); + break; + default: + break; + } +} + +export function handleTerminalExitForWindowsSleep(paneId: string): void { + if (!PLATFORM.IS_WINDOWS) return; + // Drop every tracked entry belonging to this pane; a terminal exit + // always means no more agent work will come from that pane. + const suffix = `:${paneId}`; + for (const key of Array.from(active)) { + if (key.endsWith(suffix)) active.delete(key); + } + if (active.size === 0) releaseBlocker(); +} diff --git a/apps/desktop/src/main/lib/play-sound.ts b/apps/desktop/src/main/lib/play-sound.ts index 17fdf7a5dcc..9471be49f2e 100644 --- a/apps/desktop/src/main/lib/play-sound.ts +++ b/apps/desktop/src/main/lib/play-sound.ts @@ -12,8 +12,11 @@ interface PlaySoundCallbacks { * Plays a sound file at the given volume using platform-specific commands. * Returns the primary ChildProcess, or null if playback was skipped. * - * On macOS, volume is controlled via afplay -v (0.0-1.0). - * On Linux, volume is controlled via paplay --volume (0-65536), with aplay fallback. + * - macOS: afplay -v (0.0-1.0) + * - Linux: paplay --volume (0-65536), with aplay fallback + * - Windows: PowerShell + System.Media.SoundPlayer (WAV) or MediaPlayer (other). + * System.Media.SoundPlayer doesn't support volume control, so the requested + * volume is honored only as a mute toggle (volume === 0 → skip playback). */ export function playSoundFile( soundPath: string, @@ -33,6 +36,26 @@ export function playSoundFile( ); } + if (process.platform === "win32") { + if (volume === 0) { + callbacks?.onComplete?.(); + return null; + } + // PowerShell arguments are single-quoted to avoid shell injection; any + // single quote in the path is escaped per PowerShell conventions. + const escapedPath = soundPath.replace(/'/g, "''"); + const isWav = /\.wav$/i.test(soundPath); + const script = isWav + ? `$p = New-Object Media.SoundPlayer '${escapedPath}'; $p.PlaySync()` + : `Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([System.Uri]::new('${escapedPath}')); $p.Volume = ${volumeDecimal}; $p.Play(); Start-Sleep -Milliseconds 500; while ($p.NaturalDuration.HasTimeSpan -and $p.Position -lt $p.NaturalDuration.TimeSpan) { Start-Sleep -Milliseconds 200 }`; + return execFile( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", script], + { windowsHide: true }, + () => callbacks?.onComplete?.(), + ); + } + // Linux: paplay --volume accepts 0-65536 (65536 = 100%) const paVolume = Math.round(volumeDecimal * 65536); return execFile( diff --git a/apps/desktop/src/main/lib/vibrancy/index.ts b/apps/desktop/src/main/lib/vibrancy/index.ts index 8c35904d4c3..4e0d6b9b477 100644 --- a/apps/desktop/src/main/lib/vibrancy/index.ts +++ b/apps/desktop/src/main/lib/vibrancy/index.ts @@ -38,7 +38,10 @@ const DARK_RGB = { r: 21, g: 17, b: 16 }; const LIGHT_RGB = { r: 255, g: 255, b: 255 }; export function isVibrancySupported(): boolean { - return PLATFORM.IS_MAC; + // macOS uses NSVisualEffectView. Windows 11 22H2+ uses Mica via + // BrowserWindow.setBackgroundMaterial; on older Windows versions the call + // is a no-op so gating on IS_WINDOWS is safe. + return PLATFORM.IS_MAC || PLATFORM.IS_WINDOWS; } /** @@ -77,10 +80,11 @@ export function normalizeVibrancyState( /** * Whether the native CIGaussianBlur addon loaded successfully on this * machine. When false, the vibrancy slider UI should fall back to the - * four-step blurLevel selection. + * four-step blurLevel selection. macOS only — Windows Mica is a binary + * on/off material, not a blur radius. */ export function isNativeContinuousBlurSupported(): boolean { - return isVibrancySupported() && isNativeBlurAvailable(); + return PLATFORM.IS_MAC && isNativeBlurAvailable(); } function toHexAlpha(opacityPercent: number): string { @@ -130,6 +134,11 @@ export function applyVibrancy( if (window.isDestroyed()) return; if (!isVibrancySupported()) return; + if (PLATFORM.IS_WINDOWS) { + applyWindowsBackgroundMaterial(window, state, isDark); + return; + } + const vibrancyType = resolveVibrancyType(state); const backgroundColor = computeBackgroundColor(state, isDark); @@ -142,6 +151,32 @@ export function applyVibrancy( scheduleNativeBlur(window, state); } +/** + * Windows 11 22H2+ Mica fallback. Uses `setBackgroundMaterial('mica' | 'none')`. + * On older Windows versions the call silently no-ops; the opaque backgroundColor + * below keeps the chrome looking intentional even when Mica isn't available. + */ +function applyWindowsBackgroundMaterial( + window: BrowserWindow, + state: VibrancyState, + isDark: boolean, +): void { + type WithSetBackgroundMaterial = BrowserWindow & { + setBackgroundMaterial?: ( + material: "auto" | "none" | "mica" | "acrylic" | "tabbed", + ) => void; + }; + const withMaterial = window as WithSetBackgroundMaterial; + try { + withMaterial.setBackgroundMaterial?.(state.enabled ? "mica" : "none"); + } catch (error) { + console.warn("[vibrancy] setBackgroundMaterial failed on Windows:", error); + } + // Even when Mica is active we keep backgroundColor at the brand opaque + // value — the renderer decides per-region whether to let Mica show through. + window.setBackgroundColor(isDark ? OPAQUE_DARK : OPAQUE_LIGHT); +} + // --- Native blur scheduling ---------------------------------------------- // Each window tracks the "latest requested radius" plus a list of pending // retry timers. When a new applyVibrancy call arrives we: @@ -217,6 +252,7 @@ export function getInitialWindowOptions( transparent?: boolean; vibrancy?: "sidebar" | "header" | "content" | "fullscreen-ui"; visualEffectState?: "followWindow" | "active" | "inactive"; + backgroundMaterial?: "auto" | "none" | "mica" | "acrylic" | "tabbed"; backgroundColor: string; } { if (!isVibrancySupported()) { @@ -225,6 +261,13 @@ export function getInitialWindowOptions( }; } + if (PLATFORM.IS_WINDOWS) { + return { + backgroundMaterial: state.enabled ? "mica" : "none", + backgroundColor: isDark ? OPAQUE_DARK : OPAQUE_LIGHT, + }; + } + const backgroundColor = computeBackgroundColor(state, isDark); // Always attach NSVisualEffectView at construction time, even when the // user has vibrancy disabled. The opaque backgroundColor fully covers diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 7b6f41df895..64076ac1df5 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -16,6 +16,10 @@ import { import type { AgentLifecycleEvent } from "shared/notification-types"; import { createIPCHandler } from "trpc-electron/main"; import { productName } from "~/package.json"; +import { + handleAgentLifecycleForWindowsSleep, + handleTerminalExitForWindowsSleep, +} from "../lib/agent-sleep/windows-sleep-blocker"; import { appState } from "../lib/app-state"; import { browserManager } from "../lib/browser/browser-manager"; import { createApplicationMenu } from "../lib/menu"; @@ -283,6 +287,7 @@ export function initNotifications(): void { notificationManager.start(); agentLifecycleListener = (event: AgentLifecycleEvent) => { + handleAgentLifecycleForWindowsSleep(event); notificationManager?.handleAgentLifecycle(event); }; notificationsEmitter.on( @@ -297,6 +302,9 @@ export function initNotifications(): void { signal: event.signal, reason: event.reason, }); + // Release any Windows powerSaveBlocker that was held for agent activity + // in this pane — terminal exit means no more agent events will arrive. + handleTerminalExitForWindowsSleep(event.paneId); }; getWorkspaceRuntimeRegistry() .getDefault() @@ -384,7 +392,20 @@ export async function MainWindow() { autoHideMenuBar: true, frame: false, titleBarStyle: "hidden", - trafficLightPosition: { x: 16, y: 16 }, + // Windows has no traffic-light controls; use the Electron overlay so the + // built-in minimize/maximize/close buttons render on top of the custom + // title bar. macOS keeps the familiar red/yellow/green indent. + ...(PLATFORM.IS_WINDOWS + ? { + titleBarOverlay: { + color: nativeTheme.shouldUseDarkColors ? "#1e1e1e" : "#ffffff", + symbolColor: nativeTheme.shouldUseDarkColors + ? "#ffffff" + : "#000000", + height: 35, + }, + } + : { trafficLightPosition: { x: 16, y: 16 } }), webPreferences: { preload: join(__dirname, "../preload/index.js"), webviewTag: true, @@ -404,6 +425,35 @@ export async function MainWindow() { window.webContents.setBackgroundThrottling(false); } + // Windows: forward renderer warnings/errors to the main process stdout so + // black-screen-style startup failures show up in the Electron log rather + // than being trapped inside the DevTools that the user cannot open. + if (PLATFORM.IS_WINDOWS) { + window.webContents.on( + "console-message", + (_event, level, message, line, sourceId) => { + if (level < 2) return; + const levelStr = + ["verbose", "info", "warning", "error"][level] ?? "unknown"; + const source = sourceId ? ` (${sourceId}:${line})` : ""; + const formatted = `[renderer:${levelStr}] ${message}${source}`; + if (level === 3) console.error(formatted); + else console.warn(formatted); + }, + ); + + // Keep the title-bar overlay contrast aligned with the OS theme — it is + // a Windows-only API so the call is safely gated. + nativeTheme.on("updated", () => { + if (window.isDestroyed()) return; + window.setTitleBarOverlay?.({ + color: nativeTheme.shouldUseDarkColors ? "#1e1e1e" : "#ffffff", + symbolColor: nativeTheme.shouldUseDarkColors ? "#ffffff" : "#000000", + height: 35, + }); + }); + } + if (ipcHandler) { ipcHandler.attachWindow(window); } else { diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx index d22a8090fdd..31e6ede9b1f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx @@ -155,7 +155,7 @@ function ScriptTextarea({ diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 67af5f66b6b..9088a554ab8 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -55,8 +55,11 @@ export const DEFAULT_TELEMETRY_ENABLED = true; export const DEFAULT_SHOW_RESOURCE_MONITOR = true; export const DEFAULT_OPEN_LINKS_IN_APP = false; export const DEFAULT_PREVENT_AGENT_SLEEP = false; +// Mac/Linux go through wrapper-embedded caffeinate / systemd-inhibit. Windows +// uses Electron's powerSaveBlocker wired to the notification emitter in the +// main process (lib/agent-sleep/windows-sleep-blocker.ts). export const SUPPORTS_AGENT_SLEEP_PREVENTION = - PLATFORM.IS_MAC || PLATFORM.IS_LINUX; + PLATFORM.IS_MAC || PLATFORM.IS_LINUX || PLATFORM.IS_WINDOWS; export function clampRightSidebarOpenViewWidth(width: number): number { return Math.max( diff --git a/apps/desktop/vite/helpers.ts b/apps/desktop/vite/helpers.ts index b2552e2dba6..84a5d8a87b1 100644 --- a/apps/desktop/vite/helpers.ts +++ b/apps/desktop/vite/helpers.ts @@ -20,7 +20,10 @@ export function defineEnv( value: string | undefined, fallback?: string, ): string { - return JSON.stringify(value ?? fallback); + // `||` instead of `??` so empty strings from unresolved CI secrets fall + // back to the default (matters when a CI workflow references a missing + // env var and injects the empty string). + return JSON.stringify(value || fallback); } const RESOURCES_TO_COPY = [ @@ -66,6 +69,25 @@ export function copyResourcesPlugin(): Plugin { }; } +/** + * Strips the `crossorigin` attribute that Vite injects on