feat: add validation job to GitHub Actions for desktop packaging

- Introduced a new job `validate` in the GitHub Actions workflow to validate the desktop package.
- Added steps for checking out the repository, setting up Node.js, installing dependencies, type checking, and running unit tests.
- Updated the `package` job to depend on the `validate` job.

chore: update package-lock.json and package.json for vitest integration

- Added `vitest` as a development dependency in `package.json`.
- Updated `typecheck` script to include a new TypeScript configuration for vitest.
- Updated `package-lock.json` to reflect the new dependencies.

refactor: modularize runtime path handling in main process

- Created a new module `runtime-paths.ts` to handle static path normalization and request handling.
- Refactored `main.ts` to utilize the new `normalizeStaticPath`, `getRequestedPath`, and `getPackagedRendererEntry` functions.

test: add unit tests for runtime path utilities

- Implemented tests for `normalizeStaticPath`, `getRequestedPath`, and `getPackagedRendererEntry` using Vitest.
- Ensured coverage for directory traversal attempts and null-byte paths.

chore: configure Vitest for testing

- Added `vitest.config.ts` for Vitest configuration.
- Created `tsconfig.vitest.json` to include types for Vitest in TypeScript compilation.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Sparsh 2026-04-24 13:45:23 +05:30
parent 1274b4bc99
commit 610a102397
11 changed files with 602 additions and 48 deletions

View file

@ -14,10 +14,37 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
validate:
name: Validate desktop package
runs-on: ubuntu-latest
permissions:
contents: read
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: Typecheck desktop package
run: npm run typecheck
working-directory: gitnexus-desktop
- name: Run desktop unit tests
run: npm test
working-directory: gitnexus-desktop
package:
name: Build ${{ matrix.label }}
runs-on: ${{ matrix.os }}
timeout-minutes: 90
needs: validate
permissions:
contents: read
strategy:
@ -85,8 +112,13 @@ jobs:
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
- name: Start Xvfb
if: matrix.os == 'ubuntu-latest'
run: |
Xvfb :99 -screen 0 1024x768x24 &
echo "DISPLAY=:99" >> "$GITHUB_ENV"
- name: Smoke test unpacked desktop bundle
if: matrix.os == 'windows-latest'
run: node scripts/smoke-unpacked.mjs
working-directory: gitnexus-desktop

View file

@ -17,7 +17,8 @@
"electron-builder": "26.8.1",
"electron-vite": "^5.0.0",
"typescript": "^6.0.3",
"vite": "^7.0.0"
"vite": "^7.0.0",
"vitest": "^4.0.18"
},
"engines": {
"node": ">=20.0.0"
@ -1791,6 +1792,13 @@
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@szmarczak/http-timer": {
"version": "4.0.6",
"dev": true,
@ -1850,6 +1858,17 @@
"@types/responselike": "^1.0.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/debug": {
"version": "4.1.13",
"dev": true,
@ -1858,6 +1877,13 @@
"@types/ms": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"dev": true,
@ -1969,6 +1995,119 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.5",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.5",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"@vitest/utils": "4.1.5",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"dev": true,
@ -2207,6 +2346,16 @@
"node": ">=0.8"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/astral-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
@ -2590,6 +2739,16 @@
"node": ">= 0.4"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"dev": true,
@ -3371,6 +3530,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"dev": true,
@ -3462,6 +3628,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/exponential-backoff": {
"version": "3.1.3",
"dev": true,
@ -4650,6 +4836,17 @@
"node": ">= 0.4"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/once": {
"version": "1.4.0",
"dev": true,
@ -4768,6 +4965,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/pe-library": {
"version": "0.4.1",
"dev": true,
@ -5186,6 +5390,13 @@
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "3.0.7",
"dev": true,
@ -5306,6 +5517,13 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/stat-mode": {
"version": "1.0.0",
"dev": true,
@ -5314,6 +5532,13 @@
"node": ">= 6"
}
},
"node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"dev": true,
@ -5487,6 +5712,23 @@
"semver": "bin/semver"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
"integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"dev": true,
@ -5502,6 +5744,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tmp": {
"version": "0.2.5",
"dev": true,
@ -6201,6 +6453,96 @@
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/vitest": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
"@vitest/pretty-format": "4.1.5",
"@vitest/runner": "4.1.5",
"@vitest/snapshot": "4.1.5",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.5",
"@vitest/browser-preview": "4.1.5",
"@vitest/browser-webdriverio": "4.1.5",
"@vitest/coverage-istanbul": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"@vitest/ui": "4.1.5",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/wcwidth": {
"version": "1.0.1",
"dev": true,
@ -6223,6 +6565,23 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"dev": true,

View file

@ -21,7 +21,8 @@
"build:mac": "node scripts/package.mjs --mac dmg",
"build:linux": "node scripts/package.mjs --linux AppImage",
"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"
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.renderer.json && tsc --noEmit -p tsconfig.vitest.json",
"test": "vitest run"
},
"devDependencies": {
"@types/node": "^25.6.0",
@ -32,6 +33,7 @@
"electron-builder": "26.8.1",
"electron-vite": "^5.0.0",
"typescript": "^6.0.3",
"vite": "^7.0.0"
"vite": "^7.0.0",
"vitest": "^4.0.18"
}
}

View file

@ -165,6 +165,9 @@ const gitnexusDesktopRuntimeDependencyNames = [
'pandemonium',
'uuid',
];
const unmanagedGitNexusRuntimeDependencyNames = Object.keys(gitnexusPackageJson.dependencies ?? {})
.filter((packageName) => !gitnexusDesktopRuntimeDependencyNames.includes(packageName))
.sort();
const gitnexusDesktopBuildDependencyNames = ['@types/node', 'gitnexus-shared', 'typescript'];
@ -300,13 +303,14 @@ const overlayDependencyPackages = (sourceDirectory, destinationDirectory, depend
const isLockedPathError =
(errorCode === 'EPIPE' || errorCode === 'EBUSY' || errorCode === 'EPERM') &&
existsSync(destinationPath);
const isExistingPathError = errorCode === 'EEXIST' && existsSync(destinationPath);
if (!isLockedPathError) {
if (!isLockedPathError && !isExistingPathError) {
throw error;
}
console.warn(
`[gitnexus-desktop] Skipping locked dependency path during repair: ${destinationPath}`,
`[gitnexus-desktop] Skipping already-present dependency path during repair: ${destinationPath}`,
);
}
}
@ -526,6 +530,13 @@ if (
runNpm(['ci'], sharedRoot);
}
if (unmanagedGitNexusRuntimeDependencyNames.length > 0) {
console.warn(
'[gitnexus-desktop] Runtime repair does not yet manage these direct GitNexus dependencies:\n' +
unmanagedGitNexusRuntimeDependencyNames.map((packageName) => ` - ${packageName}`).join('\n'),
);
}
if (isGitNexusRuntimeInstallStale()) {
repairGitNexusPackages(gitnexusDesktopRuntimeDependencies, 'Repairing GitNexus runtime packages');
}

View file

@ -30,7 +30,6 @@ const electronBuilderVersion =
desktopPackageJson.devDependencies?.['electron-builder']?.replace(/^[^\d]*/, '') ?? '26.8.1';
// electron-rebuild v4 requires Node 22+, but desktop packaging currently runs on Node 20.
const electronRebuildVersion = '3.7.2';
const bundledNodeExecutableName = process.platform === 'win32' ? 'node.exe' : 'node';
const electronBuilderCliPath = path.join(packageRoot, 'node_modules', 'electron-builder', 'cli.js');
const builderUtilRequire = createRequire(
path.join(packageRoot, 'node_modules', 'builder-util', 'out', 'util.js'),
@ -141,8 +140,6 @@ const builderEnvironment = {
),
GITNEXUS_DESKTOP_GITNEXUS_SKILLS: toBuilderRelativePath(path.join(gitnexusRoot, 'skills')),
GITNEXUS_DESKTOP_GITNEXUS_VENDOR: toBuilderRelativePath(path.join(gitnexusRoot, 'vendor')),
GITNEXUS_DESKTOP_NODE_EXECUTABLE: process.execPath,
GITNEXUS_DESKTOP_NODE_RESOURCE_PATH: `gitnexus-node/${bundledNodeExecutableName}`,
GITNEXUS_DESKTOP_WEB_DIST: toBuilderRelativePath(path.join(gitnexusWebRoot, 'dist')),
};

View file

@ -1,4 +1,4 @@
import { spawn } from 'node:child_process';
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@ -106,6 +106,26 @@ const resolveLatestExecutable = () => {
};
};
const prepareExecutableForSmokeTest = (executablePath) => {
if (process.platform !== 'darwin') {
return;
}
const result = spawnSync('xattr', ['-cr', executablePath], { stdio: 'inherit' });
if (result.error) {
throw result.error;
}
if ((result.status ?? 0) !== 0) {
throw new Error(`Failed to clear macOS quarantine attributes for ${executablePath}.`);
}
};
const getSmokeTestArguments = () => {
return process.platform === 'linux' ? ['--no-sandbox'] : [];
};
const runSmokeTest = async () => {
const { executablePath, releaseDir: latestReleaseDir } = resolveLatestExecutable();
@ -119,10 +139,11 @@ const runSmokeTest = async () => {
throw new Error(`Unable to locate the unpacked desktop executable under ${latestReleaseDir}.`);
}
prepareExecutableForSmokeTest(executablePath);
console.info(`[gitnexus-desktop] Smoke testing unpacked app: ${executablePath}`);
await new Promise((resolve, reject) => {
const childProcess = spawn(executablePath, [], {
const childProcess = spawn(executablePath, getSmokeTestArguments(), {
cwd: path.dirname(executablePath),
env: {
...process.env,

View file

@ -5,6 +5,12 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
getPackagedRendererEntry,
getRequestedPath,
normalizeStaticPath,
} from './runtime-paths.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DESKTOP_APP_NAME = 'GitNexus Desktop';
@ -251,6 +257,7 @@ const getNodeProcessEnvironment = (overrides: NodeJS.ProcessEnv = {}): NodeJS.Pr
};
if (app.isPackaged) {
// Packaged mode reuses Electron's embedded Node runtime via process.execPath.
environment.ELECTRON_RUN_AS_NODE = '1';
}
@ -367,55 +374,56 @@ const ensureWebDevServerStarted = async (): Promise<void> => {
);
};
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 => {
export 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()) {
try {
if (!existsSync(assetPath) || !statSync(assetPath).isFile()) {
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Not found');
return;
}
} catch {
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 stream = createReadStream(assetPath);
const getRequestedPath = (requestUrl: string): string | null => {
try {
return decodeURIComponent(new URL(requestUrl, 'http://127.0.0.1').pathname);
} catch {
return null;
}
stream.once('error', () => {
if (!response.headersSent) {
response.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Internal server error');
return;
}
response.destroy();
});
response.once('close', () => {
if (!stream.destroyed) {
stream.destroy();
}
});
stream.once('open', () => {
if (response.destroyed) {
stream.destroy();
return;
}
response.writeHead(200, {
'Cache-Control': 'no-store',
'Content-Type': contentType,
});
stream.pipe(response);
});
};
const handlePackagedWebRequest = (request: IncomingMessage, response: ServerResponse): void => {
@ -565,6 +573,7 @@ const updateContentViewBounds = (window: BrowserWindow): void => {
};
const createEmbeddedContentView = (contentUrl: string): BrowserView => {
// BrowserView is deprecated in Electron; keep this isolated until we migrate to WebContentsView.
const contentView = new BrowserView({
webPreferences: {
contextIsolation: true,
@ -603,7 +612,7 @@ const loadShellRenderer = async (window: BrowserWindow): Promise<void> => {
return;
}
const rendererEntry = path.join(__dirname, '../renderer/index.html');
const rendererEntry = getPackagedRendererEntry(__dirname);
console.info(`[gitnexus-desktop] Loading shell renderer file: ${rendererEntry}`);
await window.loadFile(rendererEntry);
};

View file

@ -0,0 +1,46 @@
import { existsSync, statSync } from 'node:fs';
import path from 'node:path';
export const normalizeStaticPath = (rootDir: string, requestPath: string): string | null => {
if (requestPath.includes('\0')) {
return 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;
}
try {
if (existsSync(resolvedPath) && statSync(resolvedPath).isFile()) {
return resolvedPath;
}
} catch {
return null;
}
if (requestPath === '/' || path.extname(requestedFile) === '') {
return path.join(normalizedRoot, 'index.html');
}
return resolvedPath;
};
export const getRequestedPath = (requestUrl: string): string | null => {
try {
const requestedPath = decodeURIComponent(new URL(requestUrl, 'http://127.0.0.1').pathname);
return requestedPath.includes('\0') ? null : requestedPath;
} catch {
return null;
}
};
export const getPackagedRendererEntry = (currentDir: string): string => {
return path.join(currentDir, '../renderer/index.html');
};

View file

@ -0,0 +1,61 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
getPackagedRendererEntry,
getRequestedPath,
normalizeStaticPath,
} from '../src/main/runtime-paths.js';
describe('normalizeStaticPath', () => {
let rootDir: string;
beforeEach(() => {
rootDir = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-desktop-static-'));
writeFileSync(path.join(rootDir, 'index.html'), '<html></html>');
writeFileSync(path.join(rootDir, 'asset.js'), 'console.log("ok");');
});
afterEach(() => {
rmSync(rootDir, { force: true, recursive: true });
});
it('rejects directory traversal attempts', () => {
expect(normalizeStaticPath(rootDir, '/../../etc/passwd')).toBeNull();
});
it('rejects null-byte paths', () => {
expect(normalizeStaticPath(rootDir, '/\0asset.js')).toBeNull();
});
it('returns a real asset path when the file exists', () => {
expect(normalizeStaticPath(rootDir, '/asset.js')).toBe(path.join(rootDir, 'asset.js'));
});
it('falls back to index.html for extensionless routes', () => {
expect(normalizeStaticPath(rootDir, '/desktop')).toBe(path.join(rootDir, 'index.html'));
});
});
describe('getRequestedPath', () => {
it('returns null for malformed percent-encoding', () => {
expect(getRequestedPath('/%GG')).toBeNull();
});
it('returns null for decoded null bytes', () => {
expect(getRequestedPath('/%00')).toBeNull();
});
});
describe('getPackagedRendererEntry', () => {
it('resolves the packaged renderer relative to dist/main', () => {
const rendererEntry = getPackagedRendererEntry(path.join('dist', 'main'));
expect(path.normalize(rendererEntry)).toBe(
path.normalize(path.join('dist', 'renderer', 'index.html')),
);
});
});

View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.node.json",
"compilerOptions": {
"types": ["node", "vitest/globals"]
},
"include": ["vitest.config.ts", "test/**/*.ts", "src/main/runtime-paths.ts"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
globals: true,
include: ['test/**/*.test.ts'],
},
});