diff --git a/.github/workflows/desktop-packaging.yml b/.github/workflows/desktop-packaging.yml new file mode 100644 index 000000000..371973a6a --- /dev/null +++ b/.github/workflows/desktop-packaging.yml @@ -0,0 +1,78 @@ +name: Desktop Packaging + +on: + pull_request: + paths: + - .github/workflows/desktop-packaging.yml + - gitnexus-desktop/** + workflow_dispatch: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + package: + name: Build ${{ matrix.label }} + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + label: Windows NSIS + command: npm run build:win + artifact_name: gitnexus-desktop-windows + artifact_paths: | + gitnexus-desktop/release/*/GitNexus Desktop Setup *.exe + gitnexus-desktop/release/*/win-unpacked/** + - os: macos-latest + label: macOS DMG + command: npm run build:mac + artifact_name: gitnexus-desktop-macos + artifact_paths: | + gitnexus-desktop/release/*/*.dmg + gitnexus-desktop/release/*/mac*/** + - os: ubuntu-latest + label: Linux AppImage + command: npm run build:linux + artifact_name: gitnexus-desktop-linux + artifact_paths: | + gitnexus-desktop/release/*/*.AppImage + gitnexus-desktop/release/*/linux-unpacked/** + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 20 + cache: npm + cache-dependency-path: gitnexus-desktop/package-lock.json + + - name: Install desktop dependencies + run: npm ci + working-directory: gitnexus-desktop + + - name: Build unpacked desktop bundle + run: npm run build:dir + working-directory: gitnexus-desktop + env: + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + + - name: Build desktop package + run: ${{ matrix.command }} + working-directory: gitnexus-desktop + env: + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + + - name: Upload desktop artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.artifact_name }} + path: ${{ matrix.artifact_paths }} + if-no-files-found: error + retention-days: 14 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index d51fb0b9d..a8d401d89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING. # GitNexus — Code Intelligence -This project is indexed by GitNexus as **gitnexus** (17439 symbols, 24477 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **gitnexus** (18208 symbols, 25369 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -154,26 +154,6 @@ To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats. | Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | | Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | | Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | -| Work in the Ingestion area (224 symbols) | `.claude/skills/generated/ingestion/SKILL.md` | -| Work in the Type-extractors area (206 symbols) | `.claude/skills/generated/type-extractors/SKILL.md` | -| Work in the Cli area (86 symbols) | `.claude/skills/generated/cli/SKILL.md` | -| Work in the Wiki area (69 symbols) | `.claude/skills/generated/wiki/SKILL.md` | -| Work in the Group area (67 symbols) | `.claude/skills/generated/group/SKILL.md` | -| Work in the Components area (63 symbols) | `.claude/skills/generated/components/SKILL.md` | -| Work in the Embeddings area (62 symbols) | `.claude/skills/generated/embeddings/SKILL.md` | -| Work in the Server area (58 symbols) | `.claude/skills/generated/server/SKILL.md` | -| Work in the Local area (55 symbols) | `.claude/skills/generated/local/SKILL.md` | -| Work in the Workers area (54 symbols) | `.claude/skills/generated/workers/SKILL.md` | -| Work in the Scope-resolution area (53 symbols) | `.claude/skills/generated/scope-resolution/SKILL.md` | -| Work in the Extractors area (52 symbols) | `.claude/skills/generated/extractors/SKILL.md` | -| Work in the Lbug area (49 symbols) | `.claude/skills/generated/lbug/SKILL.md` | -| Work in the Model area (40 symbols) | `.claude/skills/generated/model/SKILL.md` | -| Work in the Configs area (39 symbols) | `.claude/skills/generated/configs/SKILL.md` | -| Work in the Unit area (38 symbols) | `.claude/skills/generated/unit/SKILL.md` | -| Work in the Services area (36 symbols) | `.claude/skills/generated/services/SKILL.md` | -| Work in the Cobol area (34 symbols) | `.claude/skills/generated/cobol/SKILL.md` | -| Work in the Import-resolvers area (32 symbols) | `.claude/skills/generated/import-resolvers/SKILL.md` | -| Work in the Mcp area (28 symbols) | `.claude/skills/generated/mcp/SKILL.md` | diff --git a/CLAUDE.md b/CLAUDE.md index 2ce6aaa00..6e15782b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g See the ` # GitNexus — Code Intelligence -This project is indexed by GitNexus as **gitnexus** (17439 symbols, 24477 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **gitnexus** (18208 symbols, 25369 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -150,25 +150,5 @@ To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats. | Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | | Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | | Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | -| Work in the Ingestion area (224 symbols) | `.claude/skills/generated/ingestion/SKILL.md` | -| Work in the Type-extractors area (206 symbols) | `.claude/skills/generated/type-extractors/SKILL.md` | -| Work in the Cli area (86 symbols) | `.claude/skills/generated/cli/SKILL.md` | -| Work in the Wiki area (69 symbols) | `.claude/skills/generated/wiki/SKILL.md` | -| Work in the Group area (67 symbols) | `.claude/skills/generated/group/SKILL.md` | -| Work in the Components area (63 symbols) | `.claude/skills/generated/components/SKILL.md` | -| Work in the Embeddings area (62 symbols) | `.claude/skills/generated/embeddings/SKILL.md` | -| Work in the Server area (58 symbols) | `.claude/skills/generated/server/SKILL.md` | -| Work in the Local area (55 symbols) | `.claude/skills/generated/local/SKILL.md` | -| Work in the Workers area (54 symbols) | `.claude/skills/generated/workers/SKILL.md` | -| Work in the Scope-resolution area (53 symbols) | `.claude/skills/generated/scope-resolution/SKILL.md` | -| Work in the Extractors area (52 symbols) | `.claude/skills/generated/extractors/SKILL.md` | -| Work in the Lbug area (49 symbols) | `.claude/skills/generated/lbug/SKILL.md` | -| Work in the Model area (40 symbols) | `.claude/skills/generated/model/SKILL.md` | -| Work in the Configs area (39 symbols) | `.claude/skills/generated/configs/SKILL.md` | -| Work in the Unit area (38 symbols) | `.claude/skills/generated/unit/SKILL.md` | -| Work in the Services area (36 symbols) | `.claude/skills/generated/services/SKILL.md` | -| Work in the Cobol area (34 symbols) | `.claude/skills/generated/cobol/SKILL.md` | -| Work in the Import-resolvers area (32 symbols) | `.claude/skills/generated/import-resolvers/SKILL.md` | -| Work in the Mcp area (28 symbols) | `.claude/skills/generated/mcp/SKILL.md` | ` block in **[AGENTS.md](AGENTS.md)** for the canonical MCP tools, impact analysis rules, and index instructions. diff --git a/gitnexus-desktop/README.md b/gitnexus-desktop/README.md new file mode 100644 index 000000000..207b77a06 --- /dev/null +++ b/gitnexus-desktop/README.md @@ -0,0 +1,31 @@ +# GitNexus Desktop + +Electron desktop shell for GitNexus. The app starts the local backend and loads the real `gitnexus-web` UI inside the desktop window. + +## Commands + +```bash +npm run dev +npm run build:dir +npm run build:win +npm run build:mac +npm run build:linux +``` + +- `npm run dev` starts the Electron shell plus the local GitNexus backend. +- `npm run build:dir` creates an unpacked desktop app directory. +- `npm run build:win` creates a Windows NSIS installer `.exe`. +- `npm run build:mac` creates a macOS `.dmg`. +- `npm run build:linux` creates a Linux `.AppImage`. + +Build output is written under `gitnexus-desktop/release//`. + +## PR Artifacts + +The `Desktop Packaging` GitHub Actions workflow uploads preview desktop artifacts for pull requests that touch `gitnexus-desktop/**` or `.github/workflows/desktop-packaging.yml`. + +- `gitnexus-desktop-windows` contains the Windows NSIS installer `.exe` and `win-unpacked/`. +- `gitnexus-desktop-macos` contains the macOS `.dmg` and `mac*/` output. +- `gitnexus-desktop-linux` contains the Linux `.AppImage` and `linux-unpacked/`. + +GitHub artifact URLs are run-specific and expire, so use the artifact names above from the latest successful PR workflow run. \ No newline at end of file diff --git a/gitnexus-desktop/build/icon.png b/gitnexus-desktop/build/icon.png new file mode 100644 index 000000000..1685c8617 Binary files /dev/null and b/gitnexus-desktop/build/icon.png differ diff --git a/gitnexus-desktop/electron-builder.yml b/gitnexus-desktop/electron-builder.yml index 5902e1171..63789686b 100644 --- a/gitnexus-desktop/electron-builder.yml +++ b/gitnexus-desktop/electron-builder.yml @@ -1,22 +1,52 @@ appId: io.github.abhigyanpatwari.gitnexus.desktop productName: GitNexus Desktop directories: - output: release/artifacts + buildResources: build files: - dist/**/* - package.json +extraResources: + - from: '${env.GITNEXUS_DESKTOP_WEB_DIST}' + to: gitnexus-web + filter: + - '**/*' + - from: '${env.GITNEXUS_DESKTOP_GITNEXUS_DIST}' + to: gitnexus/dist + filter: + - '**/*' + - from: '${env.GITNEXUS_DESKTOP_GITNEXUS_NODE_MODULES}' + to: gitnexus/node_modules + filter: + - '**/*' + - from: '${env.GITNEXUS_DESKTOP_GITNEXUS_HOOKS}' + to: gitnexus/hooks + filter: + - '**/*' + - from: '${env.GITNEXUS_DESKTOP_GITNEXUS_SKILLS}' + to: gitnexus/skills + filter: + - '**/*' + - from: '${env.GITNEXUS_DESKTOP_GITNEXUS_VENDOR}' + to: gitnexus/vendor + filter: + - '**/*' + - from: '${env.GITNEXUS_DESKTOP_GITNEXUS_PACKAGE_JSON}' + to: gitnexus/package.json asar: false npmRebuild: false win: signAndEditExecutable: false + icon: build/icon.png target: - nsis mac: category: public.app-category.developer-tools + icon: build/icon.png target: - dmg linux: category: Development + icon: build/icon.png target: - AppImage nsis: diff --git a/gitnexus-desktop/electron.vite.config.ts b/gitnexus-desktop/electron.vite.config.ts index a65d03bc7..cfbb761cd 100644 --- a/gitnexus-desktop/electron.vite.config.ts +++ b/gitnexus-desktop/electron.vite.config.ts @@ -1,8 +1,6 @@ -import { defineConfig } from 'electron-vite'; -import react from '@vitejs/plugin-react'; import { resolve } from 'node:path'; -export default defineConfig({ +export default { main: { build: { outDir: 'dist/main', @@ -20,13 +18,24 @@ export default defineConfig({ input: { preload: resolve(__dirname, 'src/main/preload.ts'), }, + output: { + format: 'cjs', + entryFileNames: 'preload.js', + }, }, }, }, renderer: { root: '.', base: './', - plugins: [react()], + server: { + host: 'localhost', + port: 5174, + strictPort: true, + watch: { + ignored: ['**/release/**'], + }, + }, build: { outDir: 'dist/renderer', rollupOptions: { @@ -36,4 +45,4 @@ export default defineConfig({ }, }, }, -}); \ No newline at end of file +}; \ No newline at end of file diff --git a/gitnexus-desktop/package-lock.json b/gitnexus-desktop/package-lock.json index bf933a2ea..b44096184 100644 --- a/gitnexus-desktop/package-lock.json +++ b/gitnexus-desktop/package-lock.json @@ -8,17 +8,13 @@ "name": "gitnexus-desktop", "version": "0.1.0", "license": "PolyForm-Noncommercial-1.0.0", - "dependencies": { - "react": "^19.2.5", - "react-dom": "^19.2.5" - }, "devDependencies": { "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", - "electron": "^41.2.1", - "electron-builder": "^26.8.1", + "electron": "41.2.1", + "electron-builder": "26.8.1", "electron-vite": "^5.0.0", "typescript": "^6.0.3", "vite": "^7.0.0" diff --git a/gitnexus-desktop/package.json b/gitnexus-desktop/package.json index 3b6e99e0f..82ec26adc 100644 --- a/gitnexus-desktop/package.json +++ b/gitnexus-desktop/package.json @@ -11,10 +11,10 @@ "node": ">=20.0.0" }, "scripts": { - "predev": "node scripts/ensure-gitnexus-runtime.mjs", - "dev": "electron-vite dev --outDir=dist", - "start": "electron-vite preview --outDir=dist", - "bundle": "electron-vite build --outDir=dist", + "predev": "node scripts/ensure-gitnexus-runtime.mjs --cleanup-dev-port", + "dev": "npx --yes -p electron@41.2.1 -p electron-vite@5.0.0 electron-vite dev --outDir=dist", + "start": "npx --yes -p electron@41.2.1 -p electron-vite@5.0.0 electron-vite preview --outDir=dist", + "bundle": "npx --yes -p electron@41.2.1 -p electron-vite@5.0.0 electron-vite build --outDir=dist", "build": "node scripts/package.mjs --dir", "build:dir": "node scripts/package.mjs --dir", "build:win": "node scripts/package.mjs --win nsis", @@ -23,17 +23,13 @@ "build:all": "node scripts/package.mjs --win nsis --mac dmg --linux AppImage", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.renderer.json" }, - "dependencies": { - "react": "^19.2.5", - "react-dom": "^19.2.5" - }, "devDependencies": { "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", - "electron": "^41.2.1", - "electron-builder": "^26.8.1", + "electron": "41.2.1", + "electron-builder": "26.8.1", "electron-vite": "^5.0.0", "typescript": "^6.0.3", "vite": "^7.0.0" diff --git a/gitnexus-desktop/scripts/ensure-gitnexus-runtime.mjs b/gitnexus-desktop/scripts/ensure-gitnexus-runtime.mjs index 31a74798b..a55a52848 100644 --- a/gitnexus-desktop/scripts/ensure-gitnexus-runtime.mjs +++ b/gitnexus-desktop/scripts/ensure-gitnexus-runtime.mjs @@ -8,9 +8,12 @@ const __dirname = path.dirname(__filename); const workspaceRoot = path.resolve(__dirname, '..', '..'); const sharedRoot = path.join(workspaceRoot, 'gitnexus-shared'); const gitnexusRoot = path.join(workspaceRoot, 'gitnexus'); +const gitnexusWebRoot = path.join(workspaceRoot, 'gitnexus-web'); const gitnexusServerEntry = path.join(gitnexusRoot, 'dist', 'server', 'api.js'); +const desktopRendererPort = 5174; +const shouldCleanupDevPort = process.argv.includes('--cleanup-dev-port'); -const runNpm = (args, cwd) => { +const runNpmAttempt = (args, cwd) => { const result = spawnSync('npm', args, { cwd, stdio: 'inherit', @@ -21,11 +24,119 @@ const runNpm = (args, cwd) => { throw result.error; } - if (result.status !== 0) { - process.exit(result.status ?? 1); + return result.status ?? 1; +}; + +const runNpm = (args, cwd) => { + const status = runNpmAttempt(args, cwd); + + if (status !== 0) { + process.exit(status); } }; +const runCommand = (command, args) => { + return spawnSync(command, args, { + encoding: 'utf8', + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); +}; + +const findPortOwner = (port) => { + if (process.platform === 'win32') { + const lookup = runCommand('powershell.exe', [ + '-NoProfile', + '-Command', + [ + `$connection = Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Listen' } | Select-Object -First 1;`, + 'if (-not $connection) { return }', + `$process = Get-CimInstance Win32_Process -Filter \"ProcessId = $($connection.OwningProcess)\";`, + '$payload = [PSCustomObject]@{', + ' pid = $connection.OwningProcess;', + ' name = $process.Name;', + ' executablePath = $process.ExecutablePath;', + ' commandLine = $process.CommandLine', + '};', + '$payload | ConvertTo-Json -Compress', + ].join(' '), + ]); + + if (lookup.status !== 0 || !lookup.stdout.trim()) { + return null; + } + + return JSON.parse(lookup.stdout.trim()); + } + + const lookup = runCommand('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpcn']); + + if (lookup.status !== 0 || !lookup.stdout.trim()) { + return null; + } + + const owner = { pid: null, name: '', commandLine: '' }; + + for (const line of lookup.stdout.split(/\r?\n/)) { + if (line.startsWith('p')) { + owner.pid = Number.parseInt(line.slice(1), 10); + } else if (line.startsWith('c')) { + owner.name = line.slice(1); + } else if (line.startsWith('n')) { + owner.commandLine = line.slice(1); + } + } + + return owner.pid ? owner : null; +}; + +const killProcessTree = (pid) => { + if (!pid) { + return; + } + + if (process.platform === 'win32') { + const result = spawnSync('taskkill', ['/pid', String(pid), '/t', '/f'], { + stdio: 'inherit', + windowsHide: true, + }); + + if ((result.status ?? 1) !== 0) { + process.exit(result.status ?? 1); + } + + return; + } + + process.kill(pid, 'SIGTERM'); +}; + +const ensureDesktopRendererPortAvailable = () => { + const owner = findPortOwner(desktopRendererPort); + + if (!owner) { + return; + } + + const signature = `${owner.name ?? ''} ${owner.commandLine ?? ''}`.toLowerCase(); + const isStaleElectronViteProcess = signature.includes('electron-vite'); + + if (!isStaleElectronViteProcess) { + console.error( + `Port ${desktopRendererPort} is already in use by ${owner.name ?? 'another process'} (PID ${owner.pid}). Stop that process and try again.`, + ); + process.exit(1); + } + + console.info(`Stopping stale desktop renderer process on port ${desktopRendererPort} (PID ${owner.pid}).`); + killProcessTree(owner.pid); +}; + +if (shouldCleanupDevPort) { + ensureDesktopRendererPortAvailable(); +} + if (!existsSync(path.join(sharedRoot, 'node_modules', 'typescript'))) { runNpm(['ci'], sharedRoot); } @@ -34,6 +145,10 @@ if (!existsSync(path.join(gitnexusRoot, 'node_modules', 'tsx'))) { runNpm(['ci'], gitnexusRoot); } +if (!existsSync(path.join(gitnexusWebRoot, 'node_modules', 'vite'))) { + runNpm(['ci'], gitnexusWebRoot); +} + if (!existsSync(gitnexusServerEntry)) { runNpm(['run', 'build'], gitnexusRoot); } diff --git a/gitnexus-desktop/scripts/package.mjs b/gitnexus-desktop/scripts/package.mjs index 90a1f9c1f..d6917b5b9 100644 --- a/gitnexus-desktop/scripts/package.mjs +++ b/gitnexus-desktop/scripts/package.mjs @@ -1,34 +1,278 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const packageRoot = path.resolve(__dirname, '..'); +const workspaceRoot = path.resolve(packageRoot, '..'); +const gitnexusRoot = path.join(workspaceRoot, 'gitnexus'); +const gitnexusWebRoot = path.join(workspaceRoot, 'gitnexus-web'); const releaseRoot = path.join(packageRoot, 'release'); +const desktopPackageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')); +const desktopPackageLock = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package-lock.json'), 'utf8')); +const electronVersion = desktopPackageJson.devDependencies?.electron?.replace(/^[^\d]*/, '') ?? '41.2.1'; +const electronBuilderVersion = desktopPackageJson.devDependencies?.['electron-builder']?.replace(/^[^\d]*/, '') ?? '26.8.1'; +const appBuilderLibVersion = + desktopPackageLock.packages?.['node_modules/app-builder-lib']?.version ?? electronBuilderVersion.replace(/^[^\d]*/, ''); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const outputDir = path.join(releaseRoot, stamp); +const generatedBuilderConfigPath = path.join(outputDir, 'electron-builder.generated.json'); +const requiredNsisTemplateFiles = [ + path.join(packageRoot, 'node_modules', 'app-builder-lib', 'templates', 'nsis', 'messages.yml'), + path.join(packageRoot, 'node_modules', 'app-builder-lib', 'templates', 'nsis', 'assistedMessages.yml'), +]; +const supportedTargetHosts = { + '--linux': 'linux', + '--mac': 'darwin', + '--win': 'win32', +}; +const artifactFileExtensions = new Set(['.AppImage', '.dmg', '.exe', '.msi', '.zip']); const builderArgs = process.argv.slice(2); +const requestedTargets = builderArgs.filter((argument) => argument in supportedTargetHosts); + +const createBuilderConfig = () => ({ + appId: 'io.github.abhigyanpatwari.gitnexus.desktop', + electronVersion, + productName: 'GitNexus Desktop', + directories: { + output: outputDir, + buildResources: path.join(packageRoot, 'build'), + }, + files: ['dist/**/*', 'package.json'], + extraResources: [ + { + from: path.join(gitnexusWebRoot, 'dist'), + to: 'gitnexus-web', + filter: ['**/*'], + }, + { + from: path.join(gitnexusRoot, 'dist'), + to: 'gitnexus/dist', + filter: ['**/*'], + }, + { + from: path.join(gitnexusRoot, 'node_modules'), + to: 'gitnexus/node_modules', + filter: ['**/*'], + }, + { + from: path.join(gitnexusRoot, 'hooks'), + to: 'gitnexus/hooks', + filter: ['**/*'], + }, + { + from: path.join(gitnexusRoot, 'skills'), + to: 'gitnexus/skills', + filter: ['**/*'], + }, + { + from: path.join(gitnexusRoot, 'vendor'), + to: 'gitnexus/vendor', + filter: ['**/*'], + }, + { + from: path.join(gitnexusRoot, 'package.json'), + to: 'gitnexus/package.json', + }, + ], + asar: false, + npmRebuild: false, + win: { + signAndEditExecutable: false, + icon: path.join(packageRoot, 'build', 'icon.png'), + target: ['nsis'], + }, + mac: { + category: 'public.app-category.developer-tools', + icon: path.join(packageRoot, 'build', 'icon.png'), + target: ['dmg'], + }, + linux: { + category: 'Development', + icon: path.join(packageRoot, 'build', 'icon.png'), + target: ['AppImage'], + }, + nsis: { + oneClick: false, + allowToChangeInstallationDirectory: true, + }, +}); + const builderCommand = [ - 'npx electron-builder', - '--config electron-builder.yml', - `--config.directories.output=release/${stamp}`, + `npx --yes -p electron@${electronVersion} -p electron-builder@${electronBuilderVersion} electron-builder`, + `--config "${generatedBuilderConfigPath}"`, + `-c.electronVersion=${electronVersion}`, '--publish never', ...builderArgs, ].join(' '); +const runCommand = (command, cwd, extraEnv = {}) => { + execSync(command, { + cwd, + env: { + ...process.env, + ...extraEnv, + }, + stdio: 'inherit', + }); +}; + +const tryRunCommand = (command, cwd, extraEnv = {}) => { + try { + runCommand(command, cwd, extraEnv); + return true; + } catch { + return false; + } +}; + +const overlayDirectoryContents = (sourceDirectory, destinationDirectory) => { + fs.mkdirSync(destinationDirectory, { recursive: true }); + + for (const entry of fs.readdirSync(sourceDirectory, { withFileTypes: true })) { + fs.cpSync(path.join(sourceDirectory, entry.name), path.join(destinationDirectory, entry.name), { + force: true, + recursive: true, + }); + } +}; + +const repairAppBuilderLibPackage = () => { + const repairDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-desktop-app-builder-lib-')); + + try { + const tarballName = `app-builder-lib-${appBuilderLibVersion}.tgz`; + const tarballPath = path.join(repairDirectory, tarballName); + const extractedPackageRoot = path.join(repairDirectory, 'package'); + const installedPackageRoot = path.join(packageRoot, 'node_modules', 'app-builder-lib'); + + runCommand(`npm pack app-builder-lib@${appBuilderLibVersion} --pack-destination "${repairDirectory}"`, packageRoot); + runCommand(`tar -xzf "${tarballPath}" -C "${repairDirectory}"`, packageRoot); + overlayDirectoryContents(extractedPackageRoot, installedPackageRoot); + } finally { + fs.rmSync(repairDirectory, { force: true, recursive: true }); + } +}; + +const assertSupportedHostForRequestedTargets = () => { + const unsupportedTargets = requestedTargets.filter((target) => supportedTargetHosts[target] !== process.platform); + + if (unsupportedTargets.length === 0) { + return; + } + + const messages = unsupportedTargets.map((target) => { + if (target === '--mac') { + return 'macOS DMG builds must run on a macOS host. Use the Desktop Packaging GitHub Actions workflow for .dmg artifacts.'; + } + + if (target === '--linux') { + return 'Linux AppImage builds should run on a Linux host. Use the Desktop Packaging GitHub Actions workflow for Linux installers.'; + } + + return 'Windows NSIS builds must run on a Windows host.'; + }); + + throw new Error(messages.join('\n')); +}; + +const ensureDesktopToolchainHealthy = () => { + if (!requestedTargets.includes('--win')) { + return; + } + + const missingNsisTemplates = requiredNsisTemplateFiles.filter((filePath) => !fs.existsSync(filePath)); + + if (missingNsisTemplates.length === 0) { + return; + } + + console.warn('[build] electron-builder NSIS templates are missing. Restoring app-builder-lib package contents first...'); + + try { + repairAppBuilderLibPackage(); + } catch { + // Fall through to install-based repair paths below. + } + + if (requiredNsisTemplateFiles.every((filePath) => fs.existsSync(filePath))) { + return; + } + + console.warn('[build] Package overlay repair was not enough. Reinstalling electron-builder next...'); + + const targetedRepairWorked = tryRunCommand( + `npm install --no-save --package-lock=false electron-builder@${electronBuilderVersion}`, + packageRoot, + ); + + const targetedRepairResolvedTemplates = + targetedRepairWorked && requiredNsisTemplateFiles.every((filePath) => fs.existsSync(filePath)); + + if (!targetedRepairResolvedTemplates) { + console.warn('[build] Targeted repair was not enough. Falling back to npm ci...'); + runCommand('npm ci', packageRoot); + } + + const unresolvedTemplates = requiredNsisTemplateFiles.filter((filePath) => !fs.existsSync(filePath)); + + if (unresolvedTemplates.length > 0) { + throw new Error( + `electron-builder is missing required NSIS templates after reinstall:\n${unresolvedTemplates.join('\n')}`, + ); + } +}; + +const listArtifacts = (directoryPath) => { + const results = []; + const entries = fs.readdirSync(directoryPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(directoryPath, entry.name); + + if (entry.isDirectory()) { + if (entry.name === 'win-unpacked') { + results.push(fullPath); + continue; + } + + results.push(...listArtifacts(fullPath)); + continue; + } + + if (artifactFileExtensions.has(path.extname(entry.name))) { + results.push(fullPath); + } + } + + return results; +}; + fs.mkdirSync(outputDir, { recursive: true }); +fs.writeFileSync(generatedBuilderConfigPath, JSON.stringify(createBuilderConfig(), null, 2)); -execSync('npm run bundle', { - cwd: packageRoot, - stdio: 'inherit', -}); +assertSupportedHostForRequestedTargets(); +ensureDesktopToolchainHealthy(); -execSync(builderCommand, { - cwd: packageRoot, - stdio: 'inherit', -}); +runCommand('node scripts/ensure-gitnexus-runtime.mjs', packageRoot); -console.log(`[build] artifacts written to release/${stamp}`); \ No newline at end of file +runCommand('npm run bundle', packageRoot); + +runCommand('npm run build', gitnexusWebRoot); + +runCommand(builderCommand, packageRoot); + +const artifacts = listArtifacts(outputDir); + +console.log(`[build] artifacts written to release/${stamp}`); + +if (artifacts.length > 0) { + console.log('[build] artifacts:'); + for (const artifact of artifacts) { + console.log(` - ${artifact}`); + } +} \ No newline at end of file diff --git a/gitnexus-desktop/src/126312502.png b/gitnexus-desktop/src/126312502.png new file mode 100644 index 000000000..c338812a9 Binary files /dev/null and b/gitnexus-desktop/src/126312502.png differ diff --git a/gitnexus-desktop/src/main/main.ts b/gitnexus-desktop/src/main/main.ts index ff4abd7d7..fbd84ee57 100644 --- a/gitnexus-desktop/src/main/main.ts +++ b/gitnexus-desktop/src/main/main.ts @@ -1,18 +1,112 @@ -import { app, BrowserWindow, dialog } from 'electron'; +import { app, BrowserView, BrowserWindow, dialog, ipcMain, shell } from 'electron'; +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const DESKTOP_APP_NAME = 'GitNexus Desktop'; +const DESKTOP_SHELL_TITLEBAR_HEIGHT = 38; +const DESKTOP_BACKGROUND_COLOR = '#0d0d0d'; +const DESKTOP_APP_ICON_PATH = app.isPackaged + ? path.join(process.resourcesPath, 'icon.png') + : path.resolve(__dirname, '../../build/icon.png'); +const DESKTOP_GET_SHELL_STATE_CHANNEL = 'gitnexus-desktop:get-shell-state'; +const DESKTOP_WINDOW_ACTION_CHANNEL = 'gitnexus-desktop:window-action'; +const DESKTOP_WINDOW_STATE_CHANGED_CHANNEL = 'gitnexus-desktop:window-state-changed'; const GITNEXUS_PORT = 4747; const GITNEXUS_HOST = 'localhost'; -const GITNEXUS_SERVER_ENTRY = path.resolve(__dirname, '../../../gitnexus/dist/server/api.js'); +const GITNEXUS_SERVER_URL = `http://${GITNEXUS_HOST}:${GITNEXUS_PORT}`; +const GITNEXUS_SERVER_HEALTH_URLS = [ + `${GITNEXUS_SERVER_URL}/api/repos`, + `http://127.0.0.1:${GITNEXUS_PORT}/api/repos`, + `http://[::1]:${GITNEXUS_PORT}/api/repos`, +]; +const GITNEXUS_DEV_RUNTIME_DIR = path.resolve(__dirname, '../../../gitnexus'); +const GITNEXUS_WEB_DEV_HOST = 'localhost'; +const GITNEXUS_WEB_DEV_PORT = 5173; +const GITNEXUS_WEB_DEV_URL = `http://${GITNEXUS_WEB_DEV_HOST}:${GITNEXUS_WEB_DEV_PORT}`; +const GITNEXUS_WEB_DEV_ROOT = path.resolve(__dirname, '../../gitnexus-web'); +const GITNEXUS_WEB_DEV_ROOT_FALLBACK = path.resolve(__dirname, '../../../gitnexus-web'); +const GITNEXUS_PACKAGED_RUNTIME_DIR = path.join(process.resourcesPath, 'gitnexus'); +const GITNEXUS_WEB_PACKAGED_DIR = path.join(process.resourcesPath, 'gitnexus-web'); +const GITNEXUS_WEB_EXPECTED_MARKERS = ['GitNexus', '
']; +const GITNEXUS_WEB_READY_TIMEOUT_MS = 30_000; +const GITNEXUS_WEB_READY_POLL_MS = 500; +const PACKAGED_WEB_SERVER_HOST = '127.0.0.1'; -type GitNexusServerModule = { - createServer: (port: number, host?: string) => Promise; +const MIME_TYPES: Record = { + '.css': 'text/css; charset=utf-8', + '.gif': 'image/gif', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ttf': 'font/ttf', + '.txt': 'text/plain; charset=utf-8', + '.wasm': 'application/wasm', + '.woff': 'font/woff', + '.woff2': 'font/woff2', }; +const EMBEDDED_APP_SCROLLBAR_CSS = ` + :root { + scrollbar-color: rgba(148, 163, 184, 0.42) rgba(7, 10, 18, 0.08); + } + + * { + scrollbar-width: thin; + } + + ::-webkit-scrollbar { + width: 12px; + height: 12px; + } + + ::-webkit-scrollbar-track { + background: rgba(7, 10, 18, 0.08); + } + + ::-webkit-scrollbar-thumb { + border: 3px solid transparent; + border-radius: 999px; + background: rgba(148, 163, 184, 0.34); + background-clip: padding-box; + } + + ::-webkit-scrollbar-thumb:hover { + background: rgba(148, 163, 184, 0.52); + background-clip: padding-box; + } +`; + let serverReadyPromise: Promise | null = null; +let gitNexusServerProcess: ChildProcess | null = null; +let gitNexusServerOutput = ''; +let webDevServerProcess: ChildProcess | null = null; +let webDevServerOutput = ''; +let packagedWebServer: Server | null = null; +let packagedWebServerUrl: string | null = null; +const openWindows = new Set(); +const contentViews = new WeakMap(); + +type DesktopShellState = { + appName: string; + isAlwaysOnTop: boolean; + isMaximized: boolean; + platform: NodeJS.Platform; + titleBarHeight: number; +}; + +type DesktopWindowAction = 'close' | 'minimize' | 'toggle-always-on-top' | 'toggle-maximize'; const getErrorMessage = (error: unknown): string => { if (error instanceof Error) { @@ -26,7 +120,506 @@ const isAddressInUseError = (error: unknown): boolean => { return (error as NodeJS.ErrnoException | undefined)?.code === 'EADDRINUSE'; }; +const isAllowedExternalUrl = (value: string): boolean => { + try { + const url = new URL(value); + + return url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'mailto:'; + } catch { + return false; + } +}; + +const openExternalUrlIfSafe = (value: string): void => { + if (!isAllowedExternalUrl(value)) { + return; + } + + void shell.openExternal(value); +}; + +const getUrlOrigin = (value: string): string | null => { + try { + return new URL(value).origin; + } catch { + return null; + } +}; + +const appendProcessOutput = (chunk: Buffer | string): void => { + webDevServerOutput = `${webDevServerOutput}${chunk.toString()}`.slice(-8_192); +}; + +const appendGitNexusServerOutput = (chunk: Buffer | string): void => { + gitNexusServerOutput = `${gitNexusServerOutput}${chunk.toString()}`.slice(-8_192); +}; + +const sleep = async (ms: number): Promise => { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +}; + +const fetchUrlText = async (url: string, timeoutMs: number): Promise => { + const controller = new AbortController(); + const timeoutHandle = setTimeout(() => { + controller.abort(); + }, timeoutMs); + + try { + const response = await fetch(url, { + cache: 'no-store', + method: 'GET', + signal: controller.signal, + }); + + if (!response.ok) { + return null; + } + + return await response.text(); + } catch { + return null; + } finally { + clearTimeout(timeoutHandle); + } +}; + +const isHttpUrlReady = async (url: string, expectedMarkers?: string[]): Promise => { + const responseText = await fetchUrlText(url, 2_000); + + if (!responseText) { + return false; + } + + if (!expectedMarkers || expectedMarkers.length === 0) { + return true; + } + + return expectedMarkers.every((marker) => responseText.includes(marker)); +}; + +const isAnyHttpUrlReady = async (urls: string[], expectedMarkers?: string[]): Promise => { + for (const url of urls) { + if (await isHttpUrlReady(url, expectedMarkers)) { + return true; + } + } + + return false; +}; + +const waitForUrlReady = async (url: string, timeoutMs: number, expectedMarkers?: string[]): Promise => { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (await isHttpUrlReady(url, expectedMarkers)) { + return; + } + + if (webDevServerProcess?.exitCode !== null && webDevServerProcess?.exitCode !== undefined) { + const output = webDevServerOutput.trim(); + throw new Error( + output + ? `GitNexus web dev server exited before it became ready.\n\n${output}` + : 'GitNexus web dev server exited before it became ready.', + ); + } + + await sleep(GITNEXUS_WEB_READY_POLL_MS); + } + + const output = webDevServerOutput.trim(); + throw new Error( + output + ? `Timed out waiting for GitNexus web dev server at ${url}.\n\n${output}` + : `Timed out waiting for GitNexus web dev server at ${url}.`, + ); +}; + +const getGitNexusRuntimeDir = (): string => { + return app.isPackaged ? GITNEXUS_PACKAGED_RUNTIME_DIR : GITNEXUS_DEV_RUNTIME_DIR; +}; + +const getGitNexusCliEntry = (): string => { + return path.join(getGitNexusRuntimeDir(), 'dist', 'cli', 'index.js'); +}; + +const getGitNexusWebDevRoot = (): string => { + return existsSync(GITNEXUS_WEB_DEV_ROOT) ? GITNEXUS_WEB_DEV_ROOT : GITNEXUS_WEB_DEV_ROOT_FALLBACK; +}; + +const getNodeCommand = (): string => { + return process.env.npm_node_execpath || process.env.NODE || (process.platform === 'win32' ? 'node.exe' : 'node'); +}; + +const spawnGitNexusServer = (): ChildProcess => { + const childProcess = spawn(getNodeCommand(), [getGitNexusCliEntry(), 'serve', '--host', GITNEXUS_HOST], { + cwd: getGitNexusRuntimeDir(), + env: { + ...process.env, + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + childProcess.stdout?.on('data', (chunk) => { + appendGitNexusServerOutput(chunk); + process.stdout.write(`[gitnexus-server] ${chunk.toString()}`); + }); + + childProcess.stderr?.on('data', (chunk) => { + appendGitNexusServerOutput(chunk); + process.stderr.write(`[gitnexus-server] ${chunk.toString()}`); + }); + + childProcess.once('error', (error) => { + appendGitNexusServerOutput(getErrorMessage(error)); + }); + + return childProcess; +}; + +const waitForGitNexusServerReady = async (): Promise => { + const deadline = Date.now() + GITNEXUS_WEB_READY_TIMEOUT_MS; + + while (Date.now() < deadline) { + if (await isAnyHttpUrlReady(GITNEXUS_SERVER_HEALTH_URLS)) { + return; + } + + if (gitNexusServerProcess?.exitCode !== null && gitNexusServerProcess?.exitCode !== undefined) { + const output = gitNexusServerOutput.trim(); + throw new Error( + output + ? `GitNexus backend exited before it became ready.\n\n${output}` + : 'GitNexus backend exited before it became ready.', + ); + } + + await sleep(GITNEXUS_WEB_READY_POLL_MS); + } + + const output = gitNexusServerOutput.trim(); + throw new Error( + output + ? `Timed out waiting for GitNexus backend at ${GITNEXUS_SERVER_HEALTH_URLS.join(', ')}.\n\n${output}` + : `Timed out waiting for GitNexus backend at ${GITNEXUS_SERVER_HEALTH_URLS.join(', ')}.`, + ); +}; + +const spawnWebDevServer = (): ChildProcess => { + const command = process.platform === 'win32' ? 'cmd.exe' : 'npm'; + const args = + process.platform === 'win32' + ? ['/d', '/s', '/c', `npm run dev -- --host ${GITNEXUS_WEB_DEV_HOST} --port ${GITNEXUS_WEB_DEV_PORT} --strictPort`] + : ['run', 'dev', '--', '--host', GITNEXUS_WEB_DEV_HOST, '--port', String(GITNEXUS_WEB_DEV_PORT), '--strictPort']; + + const childProcess = spawn(command, args, { + cwd: getGitNexusWebDevRoot(), + env: { + ...process.env, + BROWSER: 'none', + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + childProcess.stdout?.on('data', (chunk) => { + appendProcessOutput(chunk); + process.stdout.write(`[gitnexus-web] ${chunk.toString()}`); + }); + + childProcess.stderr?.on('data', (chunk) => { + appendProcessOutput(chunk); + process.stderr.write(`[gitnexus-web] ${chunk.toString()}`); + }); + + childProcess.once('error', (error) => { + appendProcessOutput(getErrorMessage(error)); + }); + + return childProcess; +}; + +const ensureWebDevServerStarted = async (): Promise => { + if (await isHttpUrlReady(GITNEXUS_WEB_DEV_URL, GITNEXUS_WEB_EXPECTED_MARKERS)) { + return; + } + + if (!webDevServerProcess || webDevServerProcess.exitCode !== null) { + webDevServerOutput = ''; + webDevServerProcess = spawnWebDevServer(); + } + + await waitForUrlReady(GITNEXUS_WEB_DEV_URL, GITNEXUS_WEB_READY_TIMEOUT_MS, GITNEXUS_WEB_EXPECTED_MARKERS); +}; + +const normalizeStaticPath = (rootDir: string, requestPath: string): string | null => { + const normalizedRoot = path.resolve(rootDir); + const requestedFile = requestPath === '/' ? '/index.html' : requestPath; + const resolvedPath = path.resolve(normalizedRoot, `.${requestedFile}`); + const isInsideRoot = + resolvedPath === normalizedRoot || resolvedPath.startsWith(`${normalizedRoot}${path.sep}`); + + if (!isInsideRoot) { + return null; + } + + if (existsSync(resolvedPath) && statSync(resolvedPath).isFile()) { + return resolvedPath; + } + + if (requestPath === '/' || path.extname(requestedFile) === '') { + return path.join(normalizedRoot, 'index.html'); + } + + return resolvedPath; +}; + +const sendStaticResponse = (assetPath: string | null, response: ServerResponse): void => { + if (!assetPath) { + response.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.end('Forbidden'); + return; + } + + if (!existsSync(assetPath) || !statSync(assetPath).isFile()) { + response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.end('Not found'); + return; + } + + const contentType = MIME_TYPES[path.extname(assetPath)] ?? 'application/octet-stream'; + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Type': contentType, + }); + createReadStream(assetPath).pipe(response); +}; + +const handlePackagedWebRequest = (request: IncomingMessage, response: ServerResponse): void => { + const requestUrl = request.url ?? '/'; + const requestedPath = decodeURIComponent(new URL(requestUrl, 'http://127.0.0.1').pathname); + sendStaticResponse(normalizeStaticPath(GITNEXUS_WEB_PACKAGED_DIR, requestedPath), response); +}; + +const ensurePackagedWebServerStarted = async (): Promise => { + if (packagedWebServerUrl) { + return packagedWebServerUrl; + } + + const packagedIndex = path.join(GITNEXUS_WEB_PACKAGED_DIR, 'index.html'); + + if (!existsSync(packagedIndex)) { + throw new Error( + `GitNexus web bundle was not found at ${packagedIndex}. Run the desktop packaging flow to stage gitnexus-web/dist.`, + ); + } + + packagedWebServer = createServer(handlePackagedWebRequest); + + await new Promise((resolve, reject) => { + packagedWebServer?.once('error', reject); + packagedWebServer?.listen(0, PACKAGED_WEB_SERVER_HOST, () => { + resolve(); + }); + }); + + const address = packagedWebServer.address(); + + if (!address || typeof address === 'string') { + throw new Error('Failed to resolve the packaged GitNexus web server address.'); + } + + packagedWebServerUrl = `http://${PACKAGED_WEB_SERVER_HOST}:${address.port}`; + return packagedWebServerUrl; +}; + +const stopWebDevServer = (): void => { + if (!webDevServerProcess || !webDevServerProcess.pid || webDevServerProcess.exitCode !== null) { + return; + } + + if (process.platform === 'win32') { + spawnSync('taskkill', ['/pid', String(webDevServerProcess.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true, + }); + } else { + webDevServerProcess.kill('SIGTERM'); + } + + webDevServerProcess = null; +}; + +const stopGitNexusServer = (): void => { + if (!gitNexusServerProcess || !gitNexusServerProcess.pid || gitNexusServerProcess.exitCode !== null) { + return; + } + + if (process.platform === 'win32') { + spawnSync('taskkill', ['/pid', String(gitNexusServerProcess.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true, + }); + } else { + gitNexusServerProcess.kill('SIGTERM'); + } + + gitNexusServerProcess = null; +}; + +const stopPackagedWebServer = (): void => { + if (!packagedWebServer) { + return; + } + + packagedWebServer.close(); + packagedWebServer = null; + packagedWebServerUrl = null; +}; + +const getDesktopShellState = (window: BrowserWindow): DesktopShellState => { + return { + appName: DESKTOP_APP_NAME, + isAlwaysOnTop: window.isAlwaysOnTop(), + isMaximized: window.isMaximized() || window.isFullScreen(), + platform: process.platform, + titleBarHeight: getDesktopShellTitlebarHeight(), + }; +}; + +const emitDesktopShellState = (window: BrowserWindow): void => { + if (window.isDestroyed()) { + return; + } + + window.webContents.send(DESKTOP_WINDOW_STATE_CHANGED_CHANNEL, getDesktopShellState(window)); +}; + +const getWindowFromSender = (sender: Electron.WebContents): BrowserWindow => { + const window = BrowserWindow.fromWebContents(sender); + + if (!window) { + throw new Error('Unable to resolve the active desktop window.'); + } + + return window; +}; + +const getDesktopShellTitlebarHeight = (): number => { + return process.platform === 'darwin' ? DESKTOP_SHELL_TITLEBAR_HEIGHT : 0; +}; + +const updateContentViewBounds = (window: BrowserWindow): void => { + const contentView = contentViews.get(window); + + if (!contentView) { + return; + } + + const [width, height] = window.getContentSize(); + const titlebarHeight = getDesktopShellTitlebarHeight(); + + contentView.setBounds({ + x: 0, + y: titlebarHeight, + width: Math.max(width, 1), + height: Math.max(height - titlebarHeight, 1), + }); +}; + +const createEmbeddedContentView = (contentUrl: string): BrowserView => { + const contentView = new BrowserView({ + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + const allowedOrigin = getUrlOrigin(contentUrl); + + contentView.webContents.setWindowOpenHandler(({ url }) => { + if (!allowedOrigin || getUrlOrigin(url) !== allowedOrigin) { + openExternalUrlIfSafe(url); + } + + return { action: 'deny' }; + }); + + contentView.webContents.on('will-navigate', (event, navigationUrl) => { + if (!allowedOrigin || getUrlOrigin(navigationUrl) === allowedOrigin) { + return; + } + + event.preventDefault(); + openExternalUrlIfSafe(navigationUrl); + }); + + contentView.webContents.on('dom-ready', () => { + void contentView.webContents.insertCSS(EMBEDDED_APP_SCROLLBAR_CSS).catch((error) => { + console.warn(`[gitnexus-desktop] Failed to inject embedded app CSS: ${getErrorMessage(error)}`); + }); + }); + + return contentView; +}; + +const loadShellRenderer = async (window: BrowserWindow): Promise => { + const rendererUrl = process.env.ELECTRON_RENDERER_URL; + + if (rendererUrl) { + console.info(`[gitnexus-desktop] Loading shell renderer: ${rendererUrl}`); + await window.loadURL(rendererUrl); + return; + } + + const rendererEntry = path.join(__dirname, '../renderer/src/renderer/index.html'); + console.info(`[gitnexus-desktop] Loading shell renderer file: ${rendererEntry}`); + await window.loadFile(rendererEntry); +}; + +const registerWindowIpcHandlers = (): void => { + ipcMain.removeHandler(DESKTOP_GET_SHELL_STATE_CHANNEL); + ipcMain.removeHandler(DESKTOP_WINDOW_ACTION_CHANNEL); + + ipcMain.handle(DESKTOP_GET_SHELL_STATE_CHANNEL, (event) => { + const window = getWindowFromSender(event.sender); + + return getDesktopShellState(window); + }); + + ipcMain.handle(DESKTOP_WINDOW_ACTION_CHANNEL, (event, action: DesktopWindowAction) => { + const window = getWindowFromSender(event.sender); + + switch (action) { + case 'close': + window.close(); + return null; + case 'minimize': + window.minimize(); + return getDesktopShellState(window); + case 'toggle-always-on-top': + window.setAlwaysOnTop(!window.isAlwaysOnTop()); + return getDesktopShellState(window); + case 'toggle-maximize': + if (window.isMaximized()) { + window.unmaximize(); + } else { + window.maximize(); + } + + return getDesktopShellState(window); + default: + throw new Error(`Unsupported desktop window action: ${String(action)}`); + } + }); +}; + const showStartupError = (error: unknown): void => { + console.error('[gitnexus-desktop] Startup failed.', error); + if (isAddressInUseError(error)) { dialog.showErrorBox( 'GitNexus Desktop Startup Failed', @@ -41,21 +634,19 @@ const showStartupError = (error: unknown): void => { ); }; -const loadGitNexusServerModule = async (): Promise => { - try { - return (await import(pathToFileURL(GITNEXUS_SERVER_ENTRY).href)) as GitNexusServerModule; - } catch (error) { - throw new Error( - `Could not load the GitNexus server runtime at ${GITNEXUS_SERVER_ENTRY}. ${getErrorMessage(error)}`, - ); - } -}; - const ensureGitNexusServerStarted = async (): Promise => { if (!serverReadyPromise) { serverReadyPromise = (async () => { - const { createServer } = await loadGitNexusServerModule(); - await createServer(GITNEXUS_PORT, GITNEXUS_HOST); + if (await isAnyHttpUrlReady(GITNEXUS_SERVER_HEALTH_URLS)) { + return; + } + + if (!gitNexusServerProcess || gitNexusServerProcess.exitCode !== null) { + gitNexusServerOutput = ''; + gitNexusServerProcess = spawnGitNexusServer(); + } + + await waitForGitNexusServerReady(); })().catch((error) => { serverReadyPromise = null; throw error; @@ -66,36 +657,82 @@ const ensureGitNexusServerStarted = async (): Promise => { }; async function createWindow(): Promise { - const mainWindow = new BrowserWindow({ + let embeddedAppUrl: string; + + if (app.isPackaged) { + embeddedAppUrl = await ensurePackagedWebServerStarted(); + } else { + await ensureWebDevServerStarted(); + embeddedAppUrl = GITNEXUS_WEB_DEV_URL; + } + + const window = new BrowserWindow({ width: 1280, height: 800, - title: 'GitNexus Desktop', + minWidth: 960, + minHeight: 640, + title: DESKTOP_APP_NAME, + icon: DESKTOP_APP_ICON_PATH, + backgroundColor: DESKTOP_BACKGROUND_COLOR, show: false, + autoHideMenuBar: true, + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', webPreferences: { - preload: path.join(__dirname, '../preload/preload.mjs'), + preload: path.join(__dirname, '../preload/preload.js'), contextIsolation: true, nodeIntegration: false, }, }); + const contentView = createEmbeddedContentView(embeddedAppUrl); - mainWindow.once('ready-to-show', () => { - if (!mainWindow.isDestroyed()) { - mainWindow.show(); - } + window.setMenuBarVisibility(false); + window.setBrowserView(contentView); + contentViews.set(window, contentView); + updateContentViewBounds(window); + + openWindows.add(window); + + window.on('resize', () => { + updateContentViewBounds(window); }); - const rendererUrl = process.env.ELECTRON_RENDERER_URL; + window.on('maximize', () => { + emitDesktopShellState(window); + }); - if (rendererUrl) { - await mainWindow.loadURL(rendererUrl); - return; + window.on('unmaximize', () => { + emitDesktopShellState(window); + }); + + window.on('enter-full-screen', () => { + emitDesktopShellState(window); + }); + + window.on('leave-full-screen', () => { + emitDesktopShellState(window); + }); + + window.on('always-on-top-changed', () => { + emitDesktopShellState(window); + }); + + window.once('closed', () => { + openWindows.delete(window); + contentViews.delete(window); + }); + + console.info(`[gitnexus-desktop] Loading embedded app URL: ${embeddedAppUrl}`); + await Promise.all([loadShellRenderer(window), contentView.webContents.loadURL(embeddedAppUrl)]); + emitDesktopShellState(window); + + if (!window.isDestroyed()) { + window.show(); } - - await mainWindow.loadFile(path.join(__dirname, '../renderer/src/renderer/index.html')); } app.whenReady().then(async () => { try { + registerWindowIpcHandlers(); await ensureGitNexusServerStarted(); await createWindow(); } catch (error) { @@ -105,7 +742,7 @@ app.whenReady().then(async () => { } app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { + if (openWindows.size === 0) { void createWindow().catch((error) => { showStartupError(error); app.quit(); @@ -114,6 +751,12 @@ app.whenReady().then(async () => { }); }); +app.on('before-quit', () => { + stopGitNexusServer(); + stopPackagedWebServer(); + stopWebDevServer(); +}); + app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); diff --git a/gitnexus-desktop/src/main/preload.ts b/gitnexus-desktop/src/main/preload.ts index 7730e4af7..26a246210 100644 --- a/gitnexus-desktop/src/main/preload.ts +++ b/gitnexus-desktop/src/main/preload.ts @@ -1,3 +1,53 @@ -import { contextBridge } from 'electron'; +import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; -contextBridge.exposeInMainWorld('gitnexusDesktop', {}); \ No newline at end of file +const DESKTOP_APP_NAME = 'GitNexus Desktop'; +const DESKTOP_GET_SHELL_STATE_CHANNEL = 'gitnexus-desktop:get-shell-state'; +const DESKTOP_WINDOW_ACTION_CHANNEL = 'gitnexus-desktop:window-action'; +const DESKTOP_WINDOW_STATE_CHANGED_CHANNEL = 'gitnexus-desktop:window-state-changed'; +const DESKTOP_SHELL_TITLEBAR_HEIGHT = process.platform === 'darwin' ? 38 : 0; + +type DesktopShellState = { + appName: string; + isAlwaysOnTop: boolean; + isMaximized: boolean; + platform: NodeJS.Platform; + titleBarHeight: number; +}; + +type DesktopWindowAction = 'close' | 'minimize' | 'toggle-always-on-top' | 'toggle-maximize'; + +const invokeWindowAction = (action: DesktopWindowAction): Promise => { + return ipcRenderer.invoke(DESKTOP_WINDOW_ACTION_CHANNEL, action) as Promise; +}; + +contextBridge.exposeInMainWorld('gitnexusDesktop', { + appName: DESKTOP_APP_NAME, + platform: process.platform, + titleBarHeight: DESKTOP_SHELL_TITLEBAR_HEIGHT, + close: async (): Promise => { + await invokeWindowAction('close'); + }, + getShellState: (): Promise => { + return ipcRenderer.invoke(DESKTOP_GET_SHELL_STATE_CHANNEL) as Promise; + }, + minimize: (): Promise => { + return invokeWindowAction('minimize'); + }, + onWindowStateChanged: (callback: (state: DesktopShellState) => void): (() => void) => { + const listener = (_event: IpcRendererEvent, state: DesktopShellState): void => { + callback(state); + }; + + ipcRenderer.on(DESKTOP_WINDOW_STATE_CHANGED_CHANNEL, listener); + + return () => { + ipcRenderer.removeListener(DESKTOP_WINDOW_STATE_CHANGED_CHANNEL, listener); + }; + }, + toggleAlwaysOnTop: (): Promise => { + return invokeWindowAction('toggle-always-on-top'); + }, + toggleMaximize: (): Promise => { + return invokeWindowAction('toggle-maximize'); + }, +}); \ No newline at end of file diff --git a/gitnexus-desktop/src/renderer/src/App.tsx b/gitnexus-desktop/src/renderer/src/App.tsx index 9abf6aa04..ff0da6b24 100644 --- a/gitnexus-desktop/src/renderer/src/App.tsx +++ b/gitnexus-desktop/src/renderer/src/App.tsx @@ -1,3 +1,355 @@ +import { useEffect, useState } from 'react'; + +type DesktopShellState = { + appName: string; + isAlwaysOnTop: boolean; + isMaximized: boolean; + platform: string; + titleBarHeight: number; +}; + +type DesktopApi = { + appName: string; + close: () => Promise; + getShellState: () => Promise; + minimize: () => Promise; + onWindowStateChanged: (callback: (state: DesktopShellState) => void) => () => void; + platform: string; + titleBarHeight: number; + toggleAlwaysOnTop: () => Promise; + toggleMaximize: () => Promise; +}; + +const defaultDesktopState: DesktopShellState = { + appName: 'GitNexus Desktop', + isAlwaysOnTop: false, + isMaximized: false, + platform: 'win32', + titleBarHeight: 38, +}; + +const fallbackDesktopApi: DesktopApi = { + appName: defaultDesktopState.appName, + close: async () => {}, + getShellState: async () => defaultDesktopState, + minimize: async () => defaultDesktopState, + onWindowStateChanged: () => () => {}, + platform: defaultDesktopState.platform, + titleBarHeight: defaultDesktopState.titleBarHeight, + toggleAlwaysOnTop: async () => defaultDesktopState, + toggleMaximize: async () => defaultDesktopState, +}; + +const desktopApi = (window as Window & { gitnexusDesktop?: DesktopApi }).gitnexusDesktop ?? fallbackDesktopApi; + +const styles = ` + :root { + color-scheme: dark; + } + + .desktop-shell { + --titlebar-height: 38px; + height: 100vh; + display: grid; + grid-template-rows: var(--titlebar-height) 1fr; + background: #0d0d0d; + color: #f4f4f5; + overflow: hidden; + } + + .desktop-shell__titlebar { + display: grid; + grid-template-columns: minmax(144px, 1fr) auto minmax(144px, 1fr); + align-items: center; + gap: 12px; + height: var(--titlebar-height); + padding: 0 10px; + background: #0d0d0d; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + -webkit-app-region: drag; + user-select: none; + } + + .desktop-shell__side { + display: flex; + align-items: center; + min-width: 144px; + min-height: 100%; + } + + .desktop-shell__side--end { + justify-content: flex-end; + } + + .desktop-shell__title { + justify-self: center; + font-family: 'Segoe UI Variable', 'Segoe UI', 'SF Pro Text', system-ui, sans-serif; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + color: rgba(244, 244, 245, 0.9); + white-space: nowrap; + } + + .desktop-shell__spacer { + min-width: 118px; + min-height: 1px; + } + + .desktop-shell__content { + background: #0d0d0d; + } + + .desktop-shell__content::after { + content: ''; + display: block; + width: 100%; + height: 100%; + background: #0d0d0d; + } + + .window-controls, + .traffic-lights { + display: flex; + align-items: center; + gap: 0; + -webkit-app-region: no-drag; + } + + .window-control { + width: 46px; + height: calc(var(--titlebar-height) - 8px); + border: 0; + border-radius: 8px; + background: transparent; + color: rgba(244, 244, 245, 0.88); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 120ms ease, color 120ms ease; + } + + .window-control:hover { + background: rgba(255, 255, 255, 0.08); + } + + .window-control--close:hover { + background: #c42b1c; + color: #ffffff; + } + + .window-control svg { + width: 10px; + height: 10px; + stroke: currentColor; + stroke-width: 1.2; + fill: none; + vector-effect: non-scaling-stroke; + } + + .traffic-lights { + gap: 8px; + } + + .traffic-light { + width: 12px; + height: 12px; + padding: 0; + border: 0; + border-radius: 999px; + cursor: pointer; + transition: filter 120ms ease, transform 120ms ease; + -webkit-app-region: no-drag; + } + + .traffic-light:hover { + filter: brightness(1.04); + } + + .traffic-light--close { + background: #ff5f57; + } + + .traffic-light--minimize { + background: #febc2e; + } + + .traffic-light--maximize { + background: #28c840; + } + + @media (max-width: 720px) { + .desktop-shell__titlebar { + grid-template-columns: minmax(116px, 1fr) auto minmax(116px, 1fr); + gap: 8px; + padding: 0 8px; + } + + .desktop-shell__side { + min-width: 116px; + } + + .window-control { + width: 42px; + } + } +`; + +function MinimizeIcon() { + return ( + + ); +} + +function MaximizeIcon({ isMaximized }: { isMaximized: boolean }) { + if (isMaximized) { + return ( + + ); + } + + return ( + + ); +} + +function CloseIcon() { + return ( + + ); +} + export default function App() { - return
GitNexus Desktop
; + const [shellState, setShellState] = useState({ + ...defaultDesktopState, + appName: desktopApi.appName, + platform: desktopApi.platform, + titleBarHeight: desktopApi.titleBarHeight, + }); + + useEffect(() => { + let cancelled = false; + + void desktopApi.getShellState().then((state) => { + if (!cancelled && state) { + setShellState(state); + } + }); + + const unsubscribe = desktopApi.onWindowStateChanged((state) => { + if (!cancelled) { + setShellState(state); + } + }); + + return () => { + cancelled = true; + unsubscribe(); + }; + }, []); + + const isMac = shellState.platform === 'darwin'; + const handleMinimize = async (): Promise => { + const nextState = await desktopApi.minimize(); + + if (nextState) { + setShellState(nextState); + } + }; + + const handleToggleMaximize = async (): Promise => { + const nextState = await desktopApi.toggleMaximize(); + + if (nextState) { + setShellState(nextState); + } + }; + + const handleClose = async (): Promise => { + await desktopApi.close(); + }; + + return ( + <> + +
+
+
+ {isMac ? ( +
+
+ ) : ( + + +
{shellState.appName}
+ +
+ {isMac ? ( + +
+ +
+
+ + ); } \ No newline at end of file diff --git a/gitnexus-desktop/src/renderer/src/main.tsx b/gitnexus-desktop/src/renderer/src/main.tsx index 34ab73358..6c73f3dd3 100644 --- a/gitnexus-desktop/src/renderer/src/main.tsx +++ b/gitnexus-desktop/src/renderer/src/main.tsx @@ -1,8 +1,22 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; +type DesktopShellState = { + appName: string; + isAlwaysOnTop: boolean; + isMaximized: boolean; + platform: string; + titleBarHeight: number; +}; -import App from './App'; -import './styles.css'; +type DesktopApi = { + appName: string; + close: () => Promise; + getShellState: () => Promise; + minimize: () => Promise; + onWindowStateChanged: (callback: (state: DesktopShellState) => void) => () => void; + platform: string; + titleBarHeight: number; + toggleAlwaysOnTop: () => Promise; + toggleMaximize: () => Promise; +}; const rootElement = document.getElementById('root'); @@ -10,8 +24,183 @@ if (!rootElement) { throw new Error('Renderer root element was not found.'); } -createRoot(rootElement).render( - - - , -); \ No newline at end of file +const defaultDesktopState: DesktopShellState = { + appName: 'GitNexus Desktop', + isAlwaysOnTop: false, + isMaximized: false, + platform: 'win32', + titleBarHeight: 38, +}; + +const fallbackDesktopApi: DesktopApi = { + appName: defaultDesktopState.appName, + close: async () => {}, + getShellState: async () => defaultDesktopState, + minimize: async () => defaultDesktopState, + onWindowStateChanged: () => () => {}, + platform: defaultDesktopState.platform, + titleBarHeight: defaultDesktopState.titleBarHeight, + toggleAlwaysOnTop: async () => defaultDesktopState, + toggleMaximize: async () => defaultDesktopState, +}; + +const desktopApi = (window as Window & { gitnexusDesktop?: DesktopApi }).gitnexusDesktop ?? fallbackDesktopApi; + +const shellState: DesktopShellState = { + ...defaultDesktopState, + appName: desktopApi.appName, + platform: desktopApi.platform, + titleBarHeight: desktopApi.titleBarHeight, +}; + +const styles = ` + :root { + color-scheme: dark; + } + + html, + body, + #root { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: #0d0d0d; + color: #f4f4f5; + } + + body { + font-family: 'Segoe UI Variable', 'Segoe UI', 'SF Pro Text', system-ui, sans-serif; + } + + .desktop-shell { + --titlebar-height: 38px; + height: 100%; + display: grid; + grid-template-rows: var(--titlebar-height) 1fr; + background: #0d0d0d; + overflow: hidden; + } + + .desktop-shell--native-frame { + grid-template-rows: 1fr; + } + + .desktop-shell--native-frame .desktop-shell__titlebar { + display: none; + } + + .desktop-shell__titlebar { + display: grid; + grid-template-columns: minmax(144px, 1fr) auto minmax(144px, 1fr); + align-items: center; + gap: 12px; + height: var(--titlebar-height); + padding: 0 10px; + box-sizing: border-box; + background: #0d0d0d; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + -webkit-app-region: drag; + user-select: none; + } + + .desktop-shell__side { + display: flex; + align-items: center; + min-width: 144px; + min-height: 100%; + } + + .desktop-shell__side--end { + justify-content: flex-end; + } + + .desktop-shell__title { + justify-self: center; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + color: rgba(244, 244, 245, 0.92); + white-space: nowrap; + } + + .desktop-shell__spacer { + min-width: 118px; + min-height: 1px; + } + + .desktop-shell__content { + background: #0d0d0d; + } + + @media (max-width: 720px) { + .desktop-shell__titlebar { + grid-template-columns: minmax(116px, 1fr) auto minmax(116px, 1fr); + gap: 8px; + padding: 0 8px; + } + + .desktop-shell__side { + min-width: 116px; + } + } +`; + +const styleElement = document.createElement('style'); +styleElement.textContent = styles; +document.head.append(styleElement); + +const shell = document.createElement('div'); +shell.className = 'desktop-shell'; + +const titlebar = document.createElement('header'); +titlebar.className = 'desktop-shell__titlebar'; + +const leadingSide = document.createElement('div'); +leadingSide.className = 'desktop-shell__side'; + +const titleElement = document.createElement('div'); +titleElement.className = 'desktop-shell__title'; + +const trailingSide = document.createElement('div'); +trailingSide.className = 'desktop-shell__side desktop-shell__side--end'; + +const content = document.createElement('main'); +content.className = 'desktop-shell__content'; +content.setAttribute('aria-hidden', 'true'); + +const spacer = (): HTMLDivElement => { + const element = document.createElement('div'); + element.className = 'desktop-shell__spacer'; + element.setAttribute('aria-hidden', 'true'); + return element; +}; + +leadingSide.append(spacer()); +trailingSide.append(spacer()); + +titlebar.append(leadingSide, titleElement, trailingSide); +shell.append(titlebar, content); +rootElement.replaceChildren(shell); + +const applyShellState = (state: DesktopShellState): void => { + shellState.appName = state.appName; + shellState.isAlwaysOnTop = state.isAlwaysOnTop; + shellState.isMaximized = state.isMaximized; + shellState.platform = state.platform; + shellState.titleBarHeight = state.titleBarHeight; + + shell.style.setProperty('--titlebar-height', `${state.titleBarHeight}px`); + shell.classList.toggle('desktop-shell--native-frame', state.titleBarHeight === 0); + titleElement.textContent = state.appName; +}; + +applyShellState(shellState); + +void desktopApi.getShellState().then((state) => { + applyShellState(state); +}); + +desktopApi.onWindowStateChanged((state) => { + applyShellState(state); +}); \ No newline at end of file diff --git a/gitnexus-desktop/tsconfig.renderer.json b/gitnexus-desktop/tsconfig.renderer.json index fa9ff8e34..fddcbdc58 100644 --- a/gitnexus-desktop/tsconfig.renderer.json +++ b/gitnexus-desktop/tsconfig.renderer.json @@ -17,5 +17,5 @@ "skipLibCheck": true, "types": ["vite/client"] }, - "include": ["src/renderer/src"] + "include": ["src/renderer/src/main.tsx"] } \ No newline at end of file