mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-17 23:52:36 +00:00
fix(desktop): restore native OS window controls (minimize/maximize/close)
This commit is contained in:
parent
31755de553
commit
b95eebca2e
17 changed files with 1816 additions and 123 deletions
78
.github/workflows/desktop-packaging.yml
vendored
Normal file
78
.github/workflows/desktop-packaging.yml
vendored
Normal 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
|
||||
22
AGENTS.md
22
AGENTS.md
|
|
@ -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 -->
|
||||
|
||||
|
|
|
|||
22
CLAUDE.md
22
CLAUDE.md
|
|
@ -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.
|
||||
|
|
|
|||
31
gitnexus-desktop/README.md
Normal file
31
gitnexus-desktop/README.md
Normal 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.
|
||||
BIN
gitnexus-desktop/build/icon.png
Normal file
BIN
gitnexus-desktop/build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 599 KiB |
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
8
gitnexus-desktop/package-lock.json
generated
8
gitnexus-desktop/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
BIN
gitnexus-desktop/src/126312502.png
Normal file
BIN
gitnexus-desktop/src/126312502.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -17,5 +17,5 @@
|
|||
"skipLibCheck": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/renderer/src"]
|
||||
"include": ["src/renderer/src/main.tsx"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue