mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
fix(desktop): validate unpacked app startup in packaging flow
This commit is contained in:
parent
c5238debf6
commit
a84380a7a5
6 changed files with 374 additions and 178 deletions
5
.github/workflows/desktop-packaging.yml
vendored
5
.github/workflows/desktop-packaging.yml
vendored
|
|
@ -7,12 +7,11 @@ on:
|
|||
- gitnexus-desktop/**
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
package:
|
||||
|
|
|
|||
|
|
@ -17,15 +17,16 @@ npm run build:linux
|
|||
- `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`.
|
||||
- `npm run smoke:unpacked` launches the latest unpacked desktop build, verifies startup, and exits.
|
||||
|
||||
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`.
|
||||
The `Desktop Packaging` GitHub Actions workflow builds an unpacked desktop app, smoke-tests startup, then uploads preview desktop artifacts for pull requests that touch `.github/workflows/desktop-packaging.yml`, `gitnexus-desktop/**`, `gitnexus/**`, `gitnexus-web/**`, or `gitnexus-shared/**`.
|
||||
|
||||
- `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.
|
||||
GitHub artifact URLs are run-specific and expire, so use the artifact names above from the latest successful PR workflow run.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
|
|
@ -10,9 +10,124 @@ 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 gitnexusCliEntry = path.join(gitnexusRoot, 'dist', 'cli', 'index.js');
|
||||
const desktopRendererPort = 5174;
|
||||
const shouldCleanupDevPort = process.argv.includes('--cleanup-dev-port');
|
||||
|
||||
const getEntryMtimeMs = (targetPath) => {
|
||||
if (!existsSync(targetPath)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return statSync(targetPath).mtimeMs;
|
||||
};
|
||||
|
||||
const getPathMtimeMs = (targetPath) => {
|
||||
if (!existsSync(targetPath)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const stats = statSync(targetPath);
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return stats.mtimeMs;
|
||||
}
|
||||
|
||||
let newestMtimeMs = stats.mtimeMs;
|
||||
|
||||
for (const entry of readdirSync(targetPath, { withFileTypes: true })) {
|
||||
if (entry.name === '.git' || entry.name === 'dist' || entry.name === 'node_modules') {
|
||||
continue;
|
||||
}
|
||||
|
||||
newestMtimeMs = Math.max(newestMtimeMs, getPathMtimeMs(path.join(targetPath, entry.name)));
|
||||
}
|
||||
|
||||
return newestMtimeMs;
|
||||
};
|
||||
|
||||
const getNewestMtimeMs = (paths) => {
|
||||
return paths.reduce((latestMtimeMs, targetPath) => {
|
||||
return Math.max(latestMtimeMs, getPathMtimeMs(targetPath));
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const getOldestOutputMtimeMs = (paths) => {
|
||||
let oldestMtimeMs = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const targetPath of paths) {
|
||||
const mtimeMs = getPathMtimeMs(targetPath);
|
||||
|
||||
if (mtimeMs === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
oldestMtimeMs = Math.min(oldestMtimeMs, mtimeMs);
|
||||
}
|
||||
|
||||
return Number.isFinite(oldestMtimeMs) ? oldestMtimeMs : 0;
|
||||
};
|
||||
|
||||
const gitnexusSharedSourceInputs = [
|
||||
path.join(sharedRoot, 'src'),
|
||||
path.join(sharedRoot, 'package.json'),
|
||||
path.join(sharedRoot, 'package-lock.json'),
|
||||
path.join(sharedRoot, 'tsconfig.json'),
|
||||
];
|
||||
|
||||
const gitnexusSharedInstallInputs = [
|
||||
path.join(sharedRoot, 'package.json'),
|
||||
path.join(sharedRoot, 'package-lock.json'),
|
||||
path.join(sharedRoot, 'tsconfig.json'),
|
||||
];
|
||||
|
||||
const gitnexusSharedInstallMarkerPaths = [path.join(sharedRoot, 'node_modules', 'typescript')];
|
||||
|
||||
const gitnexusInstallInputs = [
|
||||
path.join(gitnexusRoot, 'package.json'),
|
||||
path.join(gitnexusRoot, 'package-lock.json'),
|
||||
path.join(sharedRoot, 'package.json'),
|
||||
path.join(sharedRoot, 'package-lock.json'),
|
||||
];
|
||||
|
||||
const gitnexusInstallMarkerPaths = [
|
||||
path.join(gitnexusRoot, 'node_modules', 'tsx'),
|
||||
path.join(gitnexusRoot, 'node_modules', 'typescript'),
|
||||
];
|
||||
|
||||
const gitnexusRuntimeInputs = [
|
||||
path.join(gitnexusRoot, 'src'),
|
||||
path.join(gitnexusRoot, 'scripts'),
|
||||
path.join(gitnexusRoot, 'package.json'),
|
||||
path.join(gitnexusRoot, 'package-lock.json'),
|
||||
path.join(gitnexusRoot, 'tsconfig.json'),
|
||||
...gitnexusSharedSourceInputs,
|
||||
];
|
||||
|
||||
const gitnexusRuntimeOutputs = [gitnexusServerEntry, gitnexusCliEntry];
|
||||
|
||||
const isInstallStale = (inputPaths, installMarkerPaths) => {
|
||||
if (installMarkerPaths.some((targetPath) => !existsSync(targetPath))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const installMarkerMtimeMs = Math.min(...installMarkerPaths.map(getEntryMtimeMs));
|
||||
|
||||
return getNewestMtimeMs(inputPaths) > installMarkerMtimeMs;
|
||||
};
|
||||
|
||||
const isGitNexusInstallStale = () => {
|
||||
return isInstallStale(gitnexusInstallInputs, gitnexusInstallMarkerPaths);
|
||||
};
|
||||
|
||||
const isGitNexusSharedInstallStale = () => {
|
||||
return isInstallStale(gitnexusSharedInstallInputs, gitnexusSharedInstallMarkerPaths);
|
||||
};
|
||||
|
||||
const isGitNexusBuildStale = () => {
|
||||
return getNewestMtimeMs(gitnexusRuntimeInputs) > getOldestOutputMtimeMs(gitnexusRuntimeOutputs);
|
||||
};
|
||||
|
||||
const runNpmAttempt = (args, cwd) => {
|
||||
const result = spawnSync('npm', args, {
|
||||
cwd,
|
||||
|
|
@ -139,11 +254,16 @@ if (shouldCleanupDevPort) {
|
|||
ensureDesktopRendererPortAvailable();
|
||||
}
|
||||
|
||||
if (!existsSync(path.join(sharedRoot, 'node_modules', 'typescript'))) {
|
||||
if (
|
||||
!existsSync(path.join(sharedRoot, 'node_modules', 'typescript')) ||
|
||||
isGitNexusSharedInstallStale()
|
||||
) {
|
||||
console.info('[gitnexus-desktop] Refreshing gitnexus-shared dependencies.');
|
||||
runNpm(['ci'], sharedRoot);
|
||||
}
|
||||
|
||||
if (!existsSync(path.join(gitnexusRoot, 'node_modules', 'tsx'))) {
|
||||
if (!existsSync(path.join(gitnexusRoot, 'node_modules', 'tsx')) || isGitNexusInstallStale()) {
|
||||
console.info('[gitnexus-desktop] Refreshing GitNexus dependencies.');
|
||||
runNpm(['ci'], gitnexusRoot);
|
||||
}
|
||||
|
||||
|
|
@ -151,6 +271,7 @@ if (!existsSync(path.join(gitnexusWebRoot, 'node_modules', 'vite'))) {
|
|||
runNpm(['ci'], gitnexusWebRoot);
|
||||
}
|
||||
|
||||
if (!existsSync(gitnexusServerEntry)) {
|
||||
if (getOldestOutputMtimeMs(gitnexusRuntimeOutputs) === 0 || isGitNexusBuildStale()) {
|
||||
console.info('[gitnexus-desktop] Rebuilding GitNexus runtime artifacts.');
|
||||
runNpm(['run', 'build'], gitnexusRoot);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -21,12 +22,19 @@ const electronVersion =
|
|||
desktopPackageJson.devDependencies?.electron?.replace(/^[^\d]*/, '') ?? '41.2.1';
|
||||
const electronBuilderVersion =
|
||||
desktopPackageJson.devDependencies?.['electron-builder']?.replace(/^[^\d]*/, '') ?? '26.8.1';
|
||||
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'),
|
||||
);
|
||||
const requiredBuilderRuntimeModules = ['app-builder-bin'];
|
||||
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 latestReleasePointerPath = path.join(releaseRoot, '.latest-unpacked-release');
|
||||
const electronBuilderConfigPath = path.join(packageRoot, 'electron-builder.yml');
|
||||
const requiredNsisTemplateFiles = [
|
||||
path.join(packageRoot, 'node_modules', 'app-builder-lib', 'templates', 'nsis', 'messages.yml'),
|
||||
path.join(
|
||||
|
|
@ -48,78 +56,23 @@ const artifactFileExtensions = new Set(['.AppImage', '.dmg', '.exe', '.msi', '.z
|
|||
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 builderEnvironment = {
|
||||
GITNEXUS_DESKTOP_GITNEXUS_DIST: path.join(gitnexusRoot, 'dist'),
|
||||
GITNEXUS_DESKTOP_GITNEXUS_HOOKS: path.join(gitnexusRoot, 'hooks'),
|
||||
GITNEXUS_DESKTOP_GITNEXUS_NODE_MODULES: path.join(gitnexusRoot, 'node_modules'),
|
||||
GITNEXUS_DESKTOP_GITNEXUS_PACKAGE_JSON: path.join(gitnexusRoot, 'package.json'),
|
||||
GITNEXUS_DESKTOP_GITNEXUS_SKILLS: path.join(gitnexusRoot, 'skills'),
|
||||
GITNEXUS_DESKTOP_GITNEXUS_VENDOR: path.join(gitnexusRoot, 'vendor'),
|
||||
GITNEXUS_DESKTOP_NODE_EXECUTABLE: process.execPath,
|
||||
GITNEXUS_DESKTOP_NODE_RESOURCE_PATH: `gitnexus-node/${bundledNodeExecutableName}`,
|
||||
GITNEXUS_DESKTOP_WEB_DIST: path.join(gitnexusWebRoot, 'dist'),
|
||||
};
|
||||
|
||||
const builderCommand = [
|
||||
`npx --yes -p electron@${electronVersion} -p electron-builder@${electronBuilderVersion} electron-builder`,
|
||||
`--config "${generatedBuilderConfigPath}"`,
|
||||
`node "${electronBuilderCliPath}"`,
|
||||
`--config "${electronBuilderConfigPath}"`,
|
||||
`-c.electronVersion=${electronVersion}`,
|
||||
`-c.directories.output="${outputDir}"`,
|
||||
'--publish never',
|
||||
...builderArgs,
|
||||
].join(' ');
|
||||
|
|
@ -144,6 +97,15 @@ const tryRunCommand = (command, cwd, extraEnv = {}) => {
|
|||
}
|
||||
};
|
||||
|
||||
const canResolveBuilderRuntimeModule = (moduleName) => {
|
||||
try {
|
||||
builderUtilRequire.resolve(moduleName);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const overlayDirectoryContents = (sourceDirectory, destinationDirectory) => {
|
||||
fs.mkdirSync(destinationDirectory, { recursive: true });
|
||||
|
||||
|
|
@ -202,56 +164,62 @@ const assertSupportedHostForRequestedTargets = () => {
|
|||
};
|
||||
|
||||
const ensureDesktopToolchainHealthy = () => {
|
||||
if (!requestedTargets.includes('--win')) {
|
||||
const missingBuilderRuntimeModules = requiredBuilderRuntimeModules.filter(
|
||||
(moduleName) => !canResolveBuilderRuntimeModule(moduleName),
|
||||
);
|
||||
const missingNsisTemplates = requestedTargets.includes('--win')
|
||||
? requiredNsisTemplateFiles.filter((filePath) => !fs.existsSync(filePath))
|
||||
: [];
|
||||
|
||||
if (missingBuilderRuntimeModules.length === 0 && missingNsisTemplates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const missingNsisTemplates = requiredNsisTemplateFiles.filter(
|
||||
(filePath) => !fs.existsSync(filePath),
|
||||
);
|
||||
if (missingNsisTemplates.length > 0) {
|
||||
console.warn(
|
||||
'[build] electron-builder NSIS templates are missing. Restoring app-builder-lib package contents first...',
|
||||
);
|
||||
|
||||
if (missingNsisTemplates.length === 0) {
|
||||
try {
|
||||
repairAppBuilderLibPackage();
|
||||
} catch {
|
||||
// Fall through to install-based repair paths below.
|
||||
}
|
||||
}
|
||||
|
||||
const remainingMissingBuilderRuntimeModules = requiredBuilderRuntimeModules.filter(
|
||||
(moduleName) => !canResolveBuilderRuntimeModule(moduleName),
|
||||
);
|
||||
const remainingMissingNsisTemplates = requestedTargets.includes('--win')
|
||||
? requiredNsisTemplateFiles.filter((filePath) => !fs.existsSync(filePath))
|
||||
: [];
|
||||
|
||||
if (
|
||||
remainingMissingBuilderRuntimeModules.length === 0 &&
|
||||
remainingMissingNsisTemplates.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
'[build] electron-builder NSIS templates are missing. Restoring app-builder-lib package contents first...',
|
||||
'[build] electron-builder installation is incomplete. Restoring desktop dependencies with npm ci...',
|
||||
);
|
||||
runCommand('npm ci', packageRoot);
|
||||
|
||||
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 unresolvedBuilderRuntimeModules = requiredBuilderRuntimeModules.filter(
|
||||
(moduleName) => !canResolveBuilderRuntimeModule(moduleName),
|
||||
);
|
||||
|
||||
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) {
|
||||
if (unresolvedBuilderRuntimeModules.length > 0 || unresolvedTemplates.length > 0) {
|
||||
throw new Error(
|
||||
`electron-builder is missing required NSIS templates after reinstall:\n${unresolvedTemplates.join('\n')}`,
|
||||
[
|
||||
'electron-builder is missing required runtime modules after reinstall:',
|
||||
...unresolvedBuilderRuntimeModules,
|
||||
...unresolvedTemplates,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -282,7 +250,7 @@ const listArtifacts = (directoryPath) => {
|
|||
};
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
fs.writeFileSync(generatedBuilderConfigPath, JSON.stringify(createBuilderConfig(), null, 2));
|
||||
fs.writeFileSync(latestReleasePointerPath, `${outputDir}\n`);
|
||||
|
||||
assertSupportedHostForRequestedTargets();
|
||||
ensureDesktopToolchainHealthy();
|
||||
|
|
@ -293,7 +261,7 @@ runCommand('npm run bundle', packageRoot);
|
|||
|
||||
runCommand('npm run build', gitnexusWebRoot);
|
||||
|
||||
runCommand(builderCommand, packageRoot);
|
||||
runCommand(builderCommand, packageRoot, builderEnvironment);
|
||||
|
||||
const artifacts = listArtifacts(outputDir);
|
||||
|
||||
|
|
|
|||
164
gitnexus-desktop/scripts/smoke-unpacked.mjs
Normal file
164
gitnexus-desktop/scripts/smoke-unpacked.mjs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
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 releaseRoot = path.join(packageRoot, 'release');
|
||||
const latestReleasePointerPath = path.join(releaseRoot, '.latest-unpacked-release');
|
||||
const productName = 'GitNexus Desktop';
|
||||
const smokeTimeoutMs = 120_000;
|
||||
const releaseDirectoryPattern = /^\d{4}-\d{2}-\d{2}T.*Z$/;
|
||||
|
||||
const listTimestampedReleaseDirs = () => {
|
||||
if (!existsSync(releaseRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return readdirSync(releaseRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && releaseDirectoryPattern.test(entry.name))
|
||||
.map((entry) => path.join(releaseRoot, entry.name))
|
||||
.sort((leftPath, rightPath) => statSync(rightPath).mtimeMs - statSync(leftPath).mtimeMs);
|
||||
};
|
||||
|
||||
const findFirstMatch = (rootDir, predicate) => {
|
||||
for (const entry of readdirSync(rootDir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(rootDir, entry.name);
|
||||
|
||||
if (predicate(entry, entryPath)) {
|
||||
return entryPath;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const nestedMatch = findFirstMatch(entryPath, predicate);
|
||||
|
||||
if (nestedMatch) {
|
||||
return nestedMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveExecutablePath = (releaseDir) => {
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(releaseDir, 'win-unpacked', `${productName}.exe`);
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
return findFirstMatch(releaseDir, (entry, entryPath) => {
|
||||
return (
|
||||
entry.isFile() &&
|
||||
entry.name === productName &&
|
||||
entryPath.includes(`${productName}.app${path.sep}Contents${path.sep}MacOS${path.sep}`)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const linuxUnpackedDir = path.join(releaseDir, 'linux-unpacked');
|
||||
|
||||
if (!existsSync(linuxUnpackedDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidateNames = [productName, 'gitnexus-desktop'];
|
||||
|
||||
for (const candidateName of candidateNames) {
|
||||
const candidatePath = path.join(linuxUnpackedDir, candidateName);
|
||||
|
||||
if (existsSync(candidatePath) && statSync(candidatePath).isFile()) {
|
||||
return candidatePath;
|
||||
}
|
||||
}
|
||||
|
||||
return findFirstMatch(linuxUnpackedDir, (entry, entryPath) => {
|
||||
return entry.isFile() && path.extname(entryPath) === '';
|
||||
});
|
||||
};
|
||||
|
||||
const resolveLatestExecutable = () => {
|
||||
if (existsSync(latestReleasePointerPath)) {
|
||||
const releaseDir = readFileSync(latestReleasePointerPath, 'utf8').trim();
|
||||
const executablePath = releaseDir ? resolveExecutablePath(releaseDir) : null;
|
||||
|
||||
return {
|
||||
executablePath: executablePath && existsSync(executablePath) ? executablePath : null,
|
||||
releaseDir: releaseDir || null,
|
||||
};
|
||||
}
|
||||
|
||||
const releaseDirs = listTimestampedReleaseDirs();
|
||||
|
||||
for (const releaseDir of releaseDirs) {
|
||||
const executablePath = resolveExecutablePath(releaseDir);
|
||||
|
||||
if (executablePath && existsSync(executablePath)) {
|
||||
return { executablePath, releaseDir };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
executablePath: null,
|
||||
releaseDir: releaseDirs[0] ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const runSmokeTest = async () => {
|
||||
const { executablePath, releaseDir: latestReleaseDir } = resolveLatestExecutable();
|
||||
|
||||
if (!latestReleaseDir) {
|
||||
throw new Error(
|
||||
'No timestamped desktop release directory was found. Run npm run build:dir first.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!executablePath || !existsSync(executablePath)) {
|
||||
throw new Error(`Unable to locate the unpacked desktop executable under ${latestReleaseDir}.`);
|
||||
}
|
||||
|
||||
console.info(`[gitnexus-desktop] Smoke testing unpacked app: ${executablePath}`);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const childProcess = spawn(executablePath, [], {
|
||||
cwd: path.dirname(executablePath),
|
||||
env: {
|
||||
...process.env,
|
||||
GITNEXUS_DESKTOP_SMOKE_TEST: '1',
|
||||
},
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
childProcess.kill(process.platform === 'win32' ? undefined : 'SIGTERM');
|
||||
reject(
|
||||
new Error(`Timed out waiting for unpacked desktop smoke test after ${smokeTimeoutMs}ms.`),
|
||||
);
|
||||
}, smokeTimeoutMs);
|
||||
|
||||
childProcess.once('error', (error) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
childProcess.once('exit', (code, signal) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
|
||||
if (code === 0) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(
|
||||
new Error(
|
||||
`Unpacked desktop smoke test exited with code ${code ?? 'null'}${signal ? ` (signal: ${signal})` : ''}.`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
await runSmokeTest();
|
||||
|
|
@ -57,37 +57,6 @@ const MIME_TYPES: Record<string, string> = {
|
|||
'.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 = '';
|
||||
|
|
@ -430,20 +399,7 @@ const sendStaticResponse = (assetPath: string | null, response: ServerResponse):
|
|||
|
||||
const handlePackagedWebRequest = (request: IncomingMessage, response: ServerResponse): void => {
|
||||
const requestUrl = request.url ?? '/';
|
||||
let requestedPath: string;
|
||||
|
||||
try {
|
||||
requestedPath = decodeURIComponent(new URL(requestUrl, 'http://127.0.0.1').pathname);
|
||||
} catch (error) {
|
||||
if (error instanceof URIError) {
|
||||
response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
response.end('Bad request');
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const requestedPath = decodeURIComponent(new URL(requestUrl, 'http://127.0.0.1').pathname);
|
||||
sendStaticResponse(normalizeStaticPath(GITNEXUS_WEB_PACKAGED_DIR, requestedPath), response);
|
||||
};
|
||||
|
||||
|
|
@ -533,7 +489,7 @@ const getDesktopShellState = (window: BrowserWindow): DesktopShellState => {
|
|||
isAlwaysOnTop: window.isAlwaysOnTop(),
|
||||
isMaximized: window.isMaximized() || window.isFullScreen(),
|
||||
platform: process.platform,
|
||||
titleBarHeight: getDesktopShellTitlebarHeight(),
|
||||
titleBarHeight: DESKTOP_SHELL_TITLEBAR_HEIGHT,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -555,10 +511,6 @@ const getWindowFromSender = (sender: Electron.WebContents): BrowserWindow => {
|
|||
return window;
|
||||
};
|
||||
|
||||
const getDesktopShellTitlebarHeight = (): number => {
|
||||
return process.platform === 'darwin' ? DESKTOP_SHELL_TITLEBAR_HEIGHT : 0;
|
||||
};
|
||||
|
||||
const updateContentViewBounds = (window: BrowserWindow): void => {
|
||||
const contentView = contentViews.get(window);
|
||||
|
||||
|
|
@ -567,13 +519,12 @@ const updateContentViewBounds = (window: BrowserWindow): void => {
|
|||
}
|
||||
|
||||
const [width, height] = window.getContentSize();
|
||||
const titlebarHeight = getDesktopShellTitlebarHeight();
|
||||
|
||||
contentView.setBounds({
|
||||
x: 0,
|
||||
y: titlebarHeight,
|
||||
y: DESKTOP_SHELL_TITLEBAR_HEIGHT,
|
||||
width: Math.max(width, 1),
|
||||
height: Math.max(height - titlebarHeight, 1),
|
||||
height: Math.max(height - DESKTOP_SHELL_TITLEBAR_HEIGHT, 1),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -604,14 +555,6 @@ const createEmbeddedContentView = (contentUrl: string): BrowserView => {
|
|||
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;
|
||||
};
|
||||
|
||||
|
|
@ -624,7 +567,7 @@ const loadShellRenderer = async (window: BrowserWindow): Promise<void> => {
|
|||
return;
|
||||
}
|
||||
|
||||
const rendererEntry = path.join(__dirname, '../renderer/index.html');
|
||||
const rendererEntry = path.join(__dirname, '../renderer/src/renderer/index.html');
|
||||
console.info(`[gitnexus-desktop] Loading shell renderer file: ${rendererEntry}`);
|
||||
await window.loadFile(rendererEntry);
|
||||
};
|
||||
|
|
@ -723,9 +666,9 @@ async function createWindow(): Promise<void> {
|
|||
title: DESKTOP_APP_NAME,
|
||||
icon: DESKTOP_APP_ICON_PATH,
|
||||
backgroundColor: DESKTOP_BACKGROUND_COLOR,
|
||||
frame: false,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '../preload/preload.js'),
|
||||
contextIsolation: true,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue