Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5fc195c
fix(ai): Resolve issue #1956 - Scaffold the secure Electron desktop r…
Aug 29, 2026
21d0ff3
feat(ai): Implemented the packaging supply-chain follow-up without ch…
Aug 29, 2026
3275d74
feat(ai): Implemented the release-blocking packaging fix.
Aug 29, 2026
10c0cd7
feat(ai): Implemented the self-contained desktop preparation:
Aug 29, 2026
c95ad7a
feat(ai): Implemented the Electron entry-format repair and strengthen…
Aug 29, 2026
c706970
feat(ai): Implemented the Linux space-free package path while preserv…
Aug 29, 2026
d1d4e32
feat(ai): Fixed the full-suite failure in [test/orchestratorConfig.te…
Aug 29, 2026
9e9cd23
feat(ai): Implemented F1–F3 only.
Aug 29, 2026
7c39c48
feat(ai): Implemented only F4, F5, and F6.
Aug 29, 2026
7a26a6b
feat(ai): Implemented only F7, F8, and F9.
Aug 29, 2026
5b73b8f
feat(ai): Fixed the flaky full-suite failure in [webPushDispatcher.te…
Aug 29, 2026
85aee4b
Merge pull request #1967 from integry/1956/gpt-5.6-sol-scaffold-the-s…
integry Aug 29, 2026
a9fde0f
Merge remote-tracking branch 'origin/1950-epic-cross-platform-dsk' in…
Aug 29, 2026
4c99bb7
feat(ai): Implemented the requested follow-ups on synced head `a9fde0…
Aug 29, 2026
e319d2e
feat(ai): Fixed the CI failure in [App.tsx](/tmp/git-processor/worktr…
Aug 29, 2026
337f49c
Merge pull request #1968 from integry/1951-epic-desktop-experience-uxs
integry Aug 29, 2026
750e802
feat(ai): Implemented F2 only.
Aug 29, 2026
b3738cf
merge: resolve conflicts from 1950-epic-cross-platform-dsk into 1952-…
Aug 29, 2026
8ef74c1
feat(ai): Fixed the CI blocker in [App.tsx](/tmp/git-processor/worktr…
Aug 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .github/workflows/desktop-release-guard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: Desktop Release Guard

on:
pull_request:
paths:
- '.github/workflows/desktop-release-guard.yml'
- 'apps/desktop/**'
- 'package.json'
- 'package-lock.json'
- 'packages/client/**'
- 'packages/shared/**'
- 'propr-ui/**'
push:
tags:
- 'v*'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: desktop-release-guard-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
verify:
name: Audit and package desktop app
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Set up Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version-file: '.nvmrc'
cache: npm
cache-dependency-path: package-lock.json

# Audit the committed resolution before npm lifecycle or packaging code can run.
- name: Audit production runtime dependencies (low threshold)
run: npm run audit:runtime

- name: Audit desktop packaging toolchain (high threshold)
run: npm run desktop:audit:packaging

- name: Install locked dependencies
run: npm ci

- name: Package desktop app from clean checkout
run: |
test ! -e packages/shared/dist
test ! -e packages/client/dist
test ! -e apps/desktop/out
npm run desktop:package

- name: Typecheck desktop and renderer
run: npm run desktop:typecheck

- name: Test desktop runtime
run: npm run desktop:test

- name: Configure Chromium sandbox helper
run: |
sudo chown root:root apps/desktop/out/propr-desktop-linux-x64/chrome-sandbox
sudo chmod 4755 apps/desktop/out/propr-desktop-linux-x64/chrome-sandbox

- name: Launch packaged desktop app with sandboxing
run: xvfb-run --auto-servernum npm run desktop:smoke
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,7 @@ apps/release-site-videos/

# Standalone publish staging (scripts/build-publish.mjs)
dist-publish/

# Electron Forge build and package output
apps/desktop/.vite/
apps/desktop/out/
51 changes: 51 additions & 0 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# ProPR Desktop

This workspace packages the existing `propr-ui` React source as a sandboxed Electron renderer. The desktop entry is
`propr-ui/src/desktop.tsx`; the normal web entry, service worker, CLI, API, and self-hosted deployment remain unchanged.

## Commands

Run these from the repository root:

```sh
npm run desktop:dev
npm run desktop:typecheck
npm run desktop:test
npm run desktop:package
npm run desktop:smoke # Run under xvfb-run on a headless Linux host.
npm run desktop:make
npm run desktop:audit
# On Linux hosts with the corresponding native packaging tools installed:
npm run make:deb -w @propr/desktop
npm run make:rpm -w @propr/desktop
```

Desktop development, typecheck, package, and make commands build required renderer workspace dependencies through
`desktop:prepare`, in dependency order (`@propr/shared` then `@propr/client`). They do not depend on previously
generated workspace `dist` directories.

Development renderer URLs are accepted only when Electron Forge supplies an HTTP loopback URL. Packaged builds load
the generated renderer from the application ASAR through an app-owned protocol.

The packaged-binary smoke test verifies the hardened fuse states, launches the Linux artifact without a
sandbox-disabling flag, rejects main-process uncaught exceptions, and requires proof that `window.proprDesktop` is
exposed before accepting renderer-ready and a clean exit.

`desktop:audit` deliberately applies separate policies to the two dependency surfaces: low-or-higher advisories fail
the production-runtime audit, while high and critical advisories fail the desktop development/build-tool audit. Release
CI runs both checks directly from the committed lockfile before installing or executing the packaging toolchain.

## Security boundary

The renderer has no Node.js integration and receives only the typed `window.proprDesktop` bridge. It exposes metadata,
validated external-browser opening, profiles, encrypted credentials, lifecycle placeholders, and validated deep-link
events. It never exposes a shell, command runner, arbitrary IPC call, or filesystem path/API.

Profile metadata is stored in an app-owned, permission-restricted JSON file. Credential values are encrypted with
Electron `safeStorage` before they are written separately. If OS encryption is unavailable—or Linux selects the
`basic_text` backend—the app reports that state and refuses to persist or return credentials; there is no plaintext
fallback. Profiles remain usable because they contain only a display label and validated API endpoint.

`propr://connect` and `propr://open` are the only accepted deep-link actions. A single-instance lock routes later
activations to the existing window. Local lifecycle methods intentionally return `not-implemented`; this scaffold does
not download, install, start, or execute ProPR runtime components.
56 changes: 56 additions & 0 deletions apps/desktop/forge.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { ForgeConfig } from '@electron-forge/shared-types';
import { MakerDeb } from '@electron-forge/maker-deb';
import { MakerRpm } from '@electron-forge/maker-rpm';
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
import { MakerZIP } from '@electron-forge/maker-zip';
import { VitePlugin } from '@electron-forge/plugin-vite';
import { flipFuses, FuseV1Options, FuseVersion } from '@electron/fuses';
import { resolve } from 'node:path';

const config: ForgeConfig = {
packagerConfig: {
asar: true,
name: 'propr-desktop',
executableName: 'propr-desktop',
},
rebuildConfig: {},
hooks: {
packageAfterCopy: async (_forgeConfig, resourcesPath, _electronVersion, platform, arch) => {
const applePlatform = platform === 'darwin' || platform === 'mas';
const executableName = applePlatform ? 'Electron' : `electron${platform === 'win32' ? '.exe' : ''}`;
await flipFuses(resolve(resourcesPath, '..', '..', applePlatform ? 'MacOS' : '', executableName), {
version: FuseVersion.V1,
resetAdHocDarwinSignature: applePlatform && arch === 'arm64',
strictlyRequireAllFuses: true,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
[FuseV1Options.LoadBrowserProcessSpecificV8Snapshot]: false,
[FuseV1Options.GrantFileProtocolExtraPrivileges]: false,
[FuseV1Options.WasmTrapHandlers]: true,
});
},
},
makers: [
new MakerSquirrel({ name: 'propr_desktop' }),
new MakerZIP({}, ['darwin', 'linux']),
...(process.env.PROPR_DESKTOP_ENABLE_DEB === '1' ? [new MakerDeb({})] : []),
...(process.env.PROPR_DESKTOP_ENABLE_RPM === '1' ? [new MakerRpm({})] : []),
],
plugins: [
new VitePlugin({
build: [
{ entry: 'src/main.ts', config: 'vite.main.config.ts' },
{ entry: 'src/preload.ts', config: 'vite.preload.config.ts' },
],
renderer: [
{ name: 'main_window', config: 'vite.renderer.config.ts' },
],
}),
],
};

export default config;
45 changes: 45 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"name": "@propr/desktop",
"productName": "ProPR Desktop",
"version": "0.8.15",
"private": true,
"description": "Secure ProPR desktop application",
"author": "Unchained Development OÜ / Rinalds Uzkalns",
"license": "Apache-2.0",
"homepage": "https://github.com/integry/propr",
"type": "module",
"main": ".vite/build/main.cjs",
"scripts": {
"prepare:renderer": "npm run build -w @propr/shared && npm run build -w @propr/client",
"predev": "npm run prepare:renderer",
"dev": "electron-forge start",
"pretypecheck": "npm run prepare:renderer",
"typecheck": "tsc --noEmit",
"test": "tsx --test src/**/*.test.ts",
"prepackage": "npm run prepare:renderer",
"package": "electron-forge package",
"smoke:package": "node scripts/smoke-packaged.mjs",
"premake": "npm run prepare:renderer",
"make": "electron-forge make",
"premake:deb": "npm run prepare:renderer",
"make:deb": "PROPR_DESKTOP_ENABLE_DEB=1 electron-forge make --targets @electron-forge/maker-deb",
"premake:rpm": "npm run prepare:renderer",
"make:rpm": "PROPR_DESKTOP_ENABLE_RPM=1 electron-forge make --targets @electron-forge/maker-rpm"
},
"devDependencies": {
"@electron-forge/cli": "8.0.0-alpha.10",
"@electron-forge/maker-deb": "8.0.0-alpha.10",
"@electron-forge/maker-rpm": "8.0.0-alpha.10",
"@electron-forge/maker-squirrel": "8.0.0-alpha.10",
"@electron-forge/maker-zip": "8.0.0-alpha.10",
"@electron-forge/plugin-vite": "8.0.0-alpha.10",
"@electron-forge/shared-types": "8.0.0-alpha.10",
"@electron/fuses": "^2.1.3",
"@types/node": "^22.10.0",
"@vitejs/plugin-react": "^4.6.0",
"electron": "^44.0.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.5"
}
}
17 changes: 17 additions & 0 deletions apps/desktop/renderer.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https: http://127.0.0.1:* http://[::1]:* http://localhost:* ws://127.0.0.1:* ws://[::1]:* ws://localhost:* wss:; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f8fafc" />
<title>ProPR Desktop</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="../../propr-ui/src/desktop.tsx"></script>
</body>
</html>
146 changes: 146 additions & 0 deletions apps/desktop/scripts/smoke-packaged.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { access, mkdtemp, rm } from 'node:fs/promises';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { DESKTOP_RENDERER_ORIGIN } from '@propr/shared';
import {
FuseState,
FuseV1Options,
FuseVersion,
getCurrentFuseWire,
} from '@electron/fuses';

const READY_EVENT = 'desktop.renderer.ready';
const PRELOAD_BRIDGE_PROOF = '"preloadBridgeExposed":true';
const PROFILE_API_PROOF = 'desktop.renderer.profile_api.ready';
const MAIN_PROCESS_ERROR_MARKERS = [
'desktop.main_process.uncaught_exception',
'A JavaScript error occurred in the main process',
'Uncaught Exception:',
];
const TIMEOUT_MS = 30_000;
const binaryPath = resolve('out', `propr-desktop-linux-${process.arch}`, 'propr-desktop');

if (process.platform !== 'linux') {
throw new Error('The packaged-binary smoke test currently targets the Linux artifact');
}

await access(binaryPath);

const expectedFuses = new Map([
[FuseV1Options.RunAsNode, FuseState.DISABLE],
[FuseV1Options.EnableCookieEncryption, FuseState.ENABLE],
[FuseV1Options.EnableNodeOptionsEnvironmentVariable, FuseState.DISABLE],
[FuseV1Options.EnableNodeCliInspectArguments, FuseState.DISABLE],
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation, FuseState.ENABLE],
[FuseV1Options.OnlyLoadAppFromAsar, FuseState.ENABLE],
[FuseV1Options.LoadBrowserProcessSpecificV8Snapshot, FuseState.DISABLE],
[FuseV1Options.GrantFileProtocolExtraPrivileges, FuseState.DISABLE],
[FuseV1Options.WasmTrapHandlers, FuseState.ENABLE],
]);
const actualFuses = await getCurrentFuseWire(binaryPath);

if (actualFuses.version !== FuseVersion.V1) {
throw new Error(`Expected fuse wire version ${FuseVersion.V1}, received ${actualFuses.version}`);
}
for (const [fuse, expectedState] of expectedFuses) {
const actualState = actualFuses[fuse];
if (actualState !== expectedState) {
throw new Error(
`Unexpected ${FuseV1Options[fuse]} fuse state: expected ${FuseState[expectedState]}, received ${FuseState[actualState] ?? actualState}`,
);
}
}

const userDataPath = await mkdtemp(resolve(tmpdir(), 'propr-desktop-smoke-'));
const launchArguments = ['--disable-gpu', `--user-data-dir=${userDataPath}`];
if (launchArguments.some(argument => argument === '--no-sandbox' || argument === '--disable-sandbox')) {
throw new Error('The packaged-binary smoke test must not disable Electron sandboxing');
}

let output = '';
let receivedProfileApiOrigin;
const profileApiServer = createServer((request, response) => {
receivedProfileApiOrigin = request.headers.origin;
if (
request.method !== 'GET'
|| request.url !== '/api/compatibility'
|| receivedProfileApiOrigin !== DESKTOP_RENDERER_ORIGIN
) {
response.writeHead(403, { 'Content-Type': 'application/json' });
response.end('{"error":"CORS origin rejected"}');
return;
}
response.writeHead(200, {
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Origin': DESKTOP_RENDERER_ORIGIN,
'Content-Type': 'application/json',
});
response.end('{"profileEndpoint":true}');
});
profileApiServer.listen(0, '127.0.0.1');
await once(profileApiServer, 'listening');
const profileApiAddress = profileApiServer.address();
if (!profileApiAddress || typeof profileApiAddress === 'string') {
throw new Error('Packaged desktop smoke profile API did not bind to a TCP port');
}
const profileApiUrl = `http://127.0.0.1:${profileApiAddress.port}`;

try {
const child = spawn(binaryPath, launchArguments, {
env: {
...process.env,
PROPR_DESKTOP_SMOKE_PROFILE_API_URL: profileApiUrl,
PROPR_DESKTOP_SMOKE_TEST: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});

const capture = chunk => {
const text = chunk.toString();
output += text;
process.stdout.write(text);
};
child.stdout.on('data', capture);
child.stderr.on('data', capture);

const result = await new Promise((resolveResult, reject) => {
const timeout = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`Packaged desktop did not reach renderer-ready within ${TIMEOUT_MS / 1000} seconds`));
}, TIMEOUT_MS);
child.once('error', error => {
clearTimeout(timeout);
reject(error);
});
child.once('close', (code, signal) => {
clearTimeout(timeout);
resolveResult({ code, signal });
});
});

const mainProcessError = MAIN_PROCESS_ERROR_MARKERS.find(marker => output.includes(marker));
if (mainProcessError) {
throw new Error(`Packaged desktop reported a main-process uncaught exception (${mainProcessError})`);
}
if (result.code !== 0) {
throw new Error(`Packaged desktop exited with code ${result.code ?? 'null'} (signal ${result.signal ?? 'none'})`);
}
if (!output.includes(READY_EVENT)) {
throw new Error('Packaged desktop exited without reporting renderer-ready');
}
if (!output.includes(PRELOAD_BRIDGE_PROOF)) {
throw new Error('Packaged desktop reported renderer-ready without proving window.proprDesktop is exposed');
}
if (!output.includes(PROFILE_API_PROOF) || receivedProfileApiOrigin !== DESKTOP_RENDERER_ORIGIN) {
throw new Error('Packaged desktop did not complete a profile API request from its exact renderer origin');
}

console.log('Packaged Linux desktop reached renderer-ready and completed a profile API request with sandboxing enabled.');
} finally {
profileApiServer.closeAllConnections();
await new Promise(resolveClose => profileApiServer.close(resolveClose));
await rm(userDataPath, { recursive: true, force: true });
}
Loading
Loading