From 610a1023975ef07499cd4d745b4d8fd271cecf55 Mon Sep 17 00:00:00 2001 From: Sparsh Date: Fri, 24 Apr 2026 13:45:23 +0530 Subject: [PATCH] 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 --- .github/workflows/desktop-packaging.yml | 34 +- gitnexus-desktop/package-lock.json | 361 +++++++++++++++++- gitnexus-desktop/package.json | 6 +- .../scripts/ensure-gitnexus-runtime.mjs | 15 +- gitnexus-desktop/scripts/package.mjs | 3 - gitnexus-desktop/scripts/smoke-unpacked.mjs | 25 +- gitnexus-desktop/src/main/main.ts | 83 ++-- gitnexus-desktop/src/main/runtime-paths.ts | 46 +++ gitnexus-desktop/test/main-utils.test.ts | 61 +++ gitnexus-desktop/tsconfig.vitest.json | 7 + gitnexus-desktop/vitest.config.ts | 9 + 11 files changed, 602 insertions(+), 48 deletions(-) create mode 100644 gitnexus-desktop/src/main/runtime-paths.ts create mode 100644 gitnexus-desktop/test/main-utils.test.ts create mode 100644 gitnexus-desktop/tsconfig.vitest.json create mode 100644 gitnexus-desktop/vitest.config.ts diff --git a/.github/workflows/desktop-packaging.yml b/.github/workflows/desktop-packaging.yml index 2be54e8c8..fa4a7ab02 100644 --- a/.github/workflows/desktop-packaging.yml +++ b/.github/workflows/desktop-packaging.yml @@ -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 diff --git a/gitnexus-desktop/package-lock.json b/gitnexus-desktop/package-lock.json index cd492c405..ae3574f33 100644 --- a/gitnexus-desktop/package-lock.json +++ b/gitnexus-desktop/package-lock.json @@ -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, diff --git a/gitnexus-desktop/package.json b/gitnexus-desktop/package.json index 40f7fdb88..fa40212a3 100644 --- a/gitnexus-desktop/package.json +++ b/gitnexus-desktop/package.json @@ -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" } } diff --git a/gitnexus-desktop/scripts/ensure-gitnexus-runtime.mjs b/gitnexus-desktop/scripts/ensure-gitnexus-runtime.mjs index 1839ac76d..f8b90d80f 100644 --- a/gitnexus-desktop/scripts/ensure-gitnexus-runtime.mjs +++ b/gitnexus-desktop/scripts/ensure-gitnexus-runtime.mjs @@ -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'); } diff --git a/gitnexus-desktop/scripts/package.mjs b/gitnexus-desktop/scripts/package.mjs index 2d76a9207..f84df654f 100644 --- a/gitnexus-desktop/scripts/package.mjs +++ b/gitnexus-desktop/scripts/package.mjs @@ -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')), }; diff --git a/gitnexus-desktop/scripts/smoke-unpacked.mjs b/gitnexus-desktop/scripts/smoke-unpacked.mjs index d92229075..9c18bf5c2 100644 --- a/gitnexus-desktop/scripts/smoke-unpacked.mjs +++ b/gitnexus-desktop/scripts/smoke-unpacked.mjs @@ -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, diff --git a/gitnexus-desktop/src/main/main.ts b/gitnexus-desktop/src/main/main.ts index 8b30a6a04..f8a198a75 100644 --- a/gitnexus-desktop/src/main/main.ts +++ b/gitnexus-desktop/src/main/main.ts @@ -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 => { ); }; -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 => { 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); }; diff --git a/gitnexus-desktop/src/main/runtime-paths.ts b/gitnexus-desktop/src/main/runtime-paths.ts new file mode 100644 index 000000000..8a929c7ea --- /dev/null +++ b/gitnexus-desktop/src/main/runtime-paths.ts @@ -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'); +}; diff --git a/gitnexus-desktop/test/main-utils.test.ts b/gitnexus-desktop/test/main-utils.test.ts new file mode 100644 index 000000000..f34b6114c --- /dev/null +++ b/gitnexus-desktop/test/main-utils.test.ts @@ -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'), ''); + 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')), + ); + }); +}); diff --git a/gitnexus-desktop/tsconfig.vitest.json b/gitnexus-desktop/tsconfig.vitest.json new file mode 100644 index 000000000..fa26603fe --- /dev/null +++ b/gitnexus-desktop/tsconfig.vitest.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "types": ["node", "vitest/globals"] + }, + "include": ["vitest.config.ts", "test/**/*.ts", "src/main/runtime-paths.ts"] +} diff --git a/gitnexus-desktop/vitest.config.ts b/gitnexus-desktop/vitest.config.ts new file mode 100644 index 000000000..6d7b15856 --- /dev/null +++ b/gitnexus-desktop/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ['test/**/*.test.ts'], + }, +});