fix(desktop): restore native OS window controls (minimize/maximize/close)

This commit is contained in:
Sparsh 2026-04-19 22:47:48 +05:30
parent 31755de553
commit b95eebca2e
17 changed files with 1816 additions and 123 deletions

78
.github/workflows/desktop-packaging.yml vendored Normal file
View file

@ -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

View file

@ -58,7 +58,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
<!-- gitnexus:start -->
# 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` |
<!-- gitnexus:end -->

View file

@ -54,7 +54,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
See the `<!-- gitnexus:start -->
# 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` |
<!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** for the canonical MCP tools, impact analysis rules, and index instructions.

View file

@ -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/<timestamp>/`.
## 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

View file

@ -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:

View file

@ -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({
},
},
},
});
};

View file

@ -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"

View file

@ -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"

View file

@ -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);
}

View file

@ -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}`);
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}`);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View file

@ -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 = ['<title>GitNexus</title>', '<div id="root"></div>'];
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<void>;
const MIME_TYPES: Record<string, string> = {
'.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<void> | 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<BrowserWindow>();
const contentViews = new WeakMap<BrowserWindow, BrowserView>();
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<void> => {
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
};
const fetchUrlText = async (url: string, timeoutMs: number): Promise<string | null> => {
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<boolean> => {
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<boolean> => {
for (const url of urls) {
if (await isHttpUrlReady(url, expectedMarkers)) {
return true;
}
}
return false;
};
const waitForUrlReady = async (url: string, timeoutMs: number, expectedMarkers?: string[]): Promise<void> => {
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<void> => {
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<void> => {
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<string> => {
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<void>((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<void> => {
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<GitNexusServerModule> => {
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<void> => {
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<void> => {
};
async function createWindow(): Promise<void> {
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();

View file

@ -1,3 +1,53 @@
import { contextBridge } from 'electron';
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron';
contextBridge.exposeInMainWorld('gitnexusDesktop', {});
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<DesktopShellState | null> => {
return ipcRenderer.invoke(DESKTOP_WINDOW_ACTION_CHANNEL, action) as Promise<DesktopShellState | null>;
};
contextBridge.exposeInMainWorld('gitnexusDesktop', {
appName: DESKTOP_APP_NAME,
platform: process.platform,
titleBarHeight: DESKTOP_SHELL_TITLEBAR_HEIGHT,
close: async (): Promise<void> => {
await invokeWindowAction('close');
},
getShellState: (): Promise<DesktopShellState> => {
return ipcRenderer.invoke(DESKTOP_GET_SHELL_STATE_CHANNEL) as Promise<DesktopShellState>;
},
minimize: (): Promise<DesktopShellState | null> => {
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<DesktopShellState | null> => {
return invokeWindowAction('toggle-always-on-top');
},
toggleMaximize: (): Promise<DesktopShellState | null> => {
return invokeWindowAction('toggle-maximize');
},
});

View file

@ -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<void>;
getShellState: () => Promise<DesktopShellState>;
minimize: () => Promise<DesktopShellState | null>;
onWindowStateChanged: (callback: (state: DesktopShellState) => void) => () => void;
platform: string;
titleBarHeight: number;
toggleAlwaysOnTop: () => Promise<DesktopShellState | null>;
toggleMaximize: () => Promise<DesktopShellState | null>;
};
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 (
<svg viewBox="0 0 10 10" aria-hidden="true">
<path d="M2 5h6" />
</svg>
);
}
function MaximizeIcon({ isMaximized }: { isMaximized: boolean }) {
if (isMaximized) {
return (
<svg viewBox="0 0 10 10" aria-hidden="true">
<path d="M2.25 3.25h4.5v4.5h-4.5z" />
<path d="M3.25 2.25h4.5v4.5" />
</svg>
);
}
return (
<svg viewBox="0 0 10 10" aria-hidden="true">
<path d="M2.25 2.25h5.5v5.5h-5.5z" />
</svg>
);
}
function CloseIcon() {
return (
<svg viewBox="0 0 10 10" aria-hidden="true">
<path d="M2 2l6 6" />
<path d="M8 2L2 8" />
</svg>
);
}
export default function App() {
return <div className="app-shell">GitNexus Desktop</div>;
const [shellState, setShellState] = useState<DesktopShellState>({
...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<void> => {
const nextState = await desktopApi.minimize();
if (nextState) {
setShellState(nextState);
}
};
const handleToggleMaximize = async (): Promise<void> => {
const nextState = await desktopApi.toggleMaximize();
if (nextState) {
setShellState(nextState);
}
};
const handleClose = async (): Promise<void> => {
await desktopApi.close();
};
return (
<>
<style>{styles}</style>
<div className="desktop-shell">
<header className="desktop-shell__titlebar">
<div className="desktop-shell__side">
{isMac ? (
<div className="traffic-lights" aria-label="Window controls">
<button
type="button"
className="traffic-light traffic-light--close"
aria-label="Close window"
onClick={handleClose}
/>
<button
type="button"
className="traffic-light traffic-light--minimize"
aria-label="Minimize window"
onClick={handleMinimize}
/>
<button
type="button"
className="traffic-light traffic-light--maximize"
aria-label={shellState.isMaximized ? 'Restore window' : 'Maximize window'}
onClick={handleToggleMaximize}
/>
</div>
) : (
<div className="desktop-shell__spacer" aria-hidden="true" />
)}
</div>
<div className="desktop-shell__title">{shellState.appName}</div>
<div className="desktop-shell__side desktop-shell__side--end">
{isMac ? (
<div className="desktop-shell__spacer" aria-hidden="true" />
) : (
<div className="window-controls" aria-label="Window controls">
<button
type="button"
className="window-control"
aria-label="Minimize window"
onClick={handleMinimize}
>
<MinimizeIcon />
</button>
<button
type="button"
className="window-control"
aria-label={shellState.isMaximized ? 'Restore window' : 'Maximize window'}
onClick={handleToggleMaximize}
>
<MaximizeIcon isMaximized={shellState.isMaximized} />
</button>
<button
type="button"
className="window-control window-control--close"
aria-label="Close window"
onClick={handleClose}
>
<CloseIcon />
</button>
</div>
)}
</div>
</header>
<main className="desktop-shell__content" aria-hidden="true" />
</div>
</>
);
}

View file

@ -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<void>;
getShellState: () => Promise<DesktopShellState>;
minimize: () => Promise<DesktopShellState | null>;
onWindowStateChanged: (callback: (state: DesktopShellState) => void) => () => void;
platform: string;
titleBarHeight: number;
toggleAlwaysOnTop: () => Promise<DesktopShellState | null>;
toggleMaximize: () => Promise<DesktopShellState | null>;
};
const rootElement = document.getElementById('root');
@ -10,8 +24,183 @@ if (!rootElement) {
throw new Error('Renderer root element was not found.');
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
);
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);
});

View file

@ -17,5 +17,5 @@
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src/renderer/src"]
"include": ["src/renderer/src/main.tsx"]
}