Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0e286fc
Merge pull request #1973 from integry/1952-epic-electron-runtime-app
integry Aug 29, 2026
587b7ee
fix(ai): Resolve issue #1960 - Implement the guided local Linux setup…
Aug 29, 2026
57b6011
feat(ai): Fixed the PR’s two UI lint failures:
Aug 29, 2026
546713a
feat(ai): Implemented F1–F7 as a unified hardened local-setup design:
Aug 29, 2026
2611e5f
Merge pull request #1980 from integry/1952-epic-electron-runtime-app
integry Aug 29, 2026
7f9d5e2
Merge pull request #1983 from integry/1952-epic-electron-runtime-app
integry Aug 29, 2026
dfba5ac
feat(ai): Implemented all requested exact-head blockers without commi…
Aug 29, 2026
be3d9f9
feat(ai): Implemented the three exact-head fixes on `dfba5ac` without…
Aug 29, 2026
73ddce0
feat(ai): Implemented all four blockers on exact head `be3d9f93324301…
Aug 29, 2026
61961b3
feat(ai): Implemented the exact-head follow-up on `73ddce0bb05d777304…
Aug 30, 2026
a6b5b5c
feat(ai): Implemented the exact-head rollback proof fix without mergi…
Aug 30, 2026
0ec9394
Merge pull request #2030 from integry/1952-epic-electron-runtime-app
integry Sep 1, 2026
6813708
merge: resolve conflicts from 1951-epic-desktop-experience-uxs into 1…
Sep 1, 2026
a60280d
feat(ai): Implemented the UX/runtime reconciliation and deep-link seam.
Sep 1, 2026
e22dfbe
feat(ai): Implemented the requested #1978 follow-ups without committing.
Sep 1, 2026
6f8599a
feat(ai): Implemented the consolidated correction on exact head `e22d…
Sep 1, 2026
76ba297
feat(ai): Implemented the complete correction on exact head `6f8599a8…
Sep 1, 2026
b0012fb
feat(ai): Implemented all requested corrections on exact head `76ba29…
Sep 1, 2026
7866809
feat(ai): Implemented F17/F18 on exact head `b0012fb8703ded3949b254ce…
Sep 1, 2026
7d9f097
feat(ai): Implemented F19 on exact head `7866809d20e4f086718e4f6e39f1…
Sep 1, 2026
c90ea0c
feat(ai): Implemented F20 on exact HEAD `7d9f0978e0131e9db1b4b96ba235…
Sep 1, 2026
1074279
Merge pull request #1978 from integry/1960/gpt-5.6-sol-implement-the-…
integry Sep 1, 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
24 changes: 20 additions & 4 deletions .github/workflows/desktop-release-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ jobs:
if: matrix.platform == 'linux'
run: |
sudo apt-get update
sudo apt-get install --yes cpio fakeroot rpm zip
sudo apt-get install --yes cpio dbus-x11 fakeroot gnome-keyring libsecret-1-0 rpm zip

- name: Package desktop app from clean checkout
shell: bash
Expand All @@ -161,6 +161,7 @@ jobs:
shell: bash
run: |
npm run desktop:typecheck
npm run test:native-durability -w @propr/desktop
npm run desktop:test

- name: Make Linux validation packages
Expand Down Expand Up @@ -197,7 +198,14 @@ jobs:
run: |
sudo chown root:root "apps/desktop/out/propr-desktop-linux-${{ matrix.arch }}/chrome-sandbox"
sudo chmod 4755 "apps/desktop/out/propr-desktop-linux-${{ matrix.arch }}/chrome-sandbox"
xvfb-run --auto-servernum npm run desktop:smoke
keyring_root="$(mktemp -d)"
trap 'rm -rf -- "$keyring_root"' EXIT
dbus-run-session -- bash -euo pipefail -c '
export PROPR_DESKTOP_SMOKE_KEYRING_ROOT="$1"
export XDG_DATA_HOME="$1"
eval "$(printf "%s\n" "propr-packaged-smoke" | gnome-keyring-daemon --unlock --components=secrets)"
xvfb-run --auto-servernum npm run desktop:smoke
' bash "$keyring_root"

- name: Launch packaged Windows application and exercise MVP desktop flows
if: matrix.platform == 'win32'
Expand Down Expand Up @@ -466,7 +474,7 @@ jobs:
if: matrix.platform == 'linux'
run: |
sudo apt-get update
sudo apt-get install --yes cpio fakeroot rpm zip
sudo apt-get install --yes cpio dbus-x11 fakeroot gnome-keyring libsecret-1-0 rpm zip

- name: Configure required macOS signing and notarization
if: matrix.platform == 'darwin'
Expand Down Expand Up @@ -590,6 +598,7 @@ jobs:
shell: bash
run: |
npm run desktop:typecheck
npm run test:native-durability -w @propr/desktop
npm run desktop:test

- name: Make Linux production packages
Expand Down Expand Up @@ -635,7 +644,14 @@ jobs:
run: |
sudo chown root:root "apps/desktop/out/propr-desktop-linux-${{ matrix.arch }}/chrome-sandbox"
sudo chmod 4755 "apps/desktop/out/propr-desktop-linux-${{ matrix.arch }}/chrome-sandbox"
xvfb-run --auto-servernum npm run desktop:smoke
keyring_root="$(mktemp -d)"
trap 'rm -rf -- "$keyring_root"' EXIT
dbus-run-session -- bash -euo pipefail -c '
export PROPR_DESKTOP_SMOKE_KEYRING_ROOT="$1"
export XDG_DATA_HOME="$1"
eval "$(printf "%s\n" "propr-packaged-smoke" | gnome-keyring-daemon --unlock --components=secrets)"
xvfb-run --auto-servernum npm run desktop:smoke
' bash "$keyring_root"

- name: Launch signed packaged Windows application and exercise MVP desktop flows
if: matrix.platform == 'win32'
Expand Down
24 changes: 19 additions & 5 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ npm run make:dmg -w @propr/desktop -- --arch=arm64
```

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
`desktop:prepare`, in dependency order (`@propr/shared`, `@propr/client`, `@propr/local-setup`, then `@propr/cli`). 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
Expand Down Expand Up @@ -54,8 +54,8 @@ CI runs both checks directly from the committed lockfile before installing or ex

## 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
The renderer has no Node.js integration and receives only the typed `window.proprDesktop` and
`window.__PROPR_DESKTOP__` bridges. They expose metadata, validated external-browser opening, profiles, encrypted credentials, lifecycle control, guided setup, 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
Expand All @@ -64,8 +64,22 @@ Electron `safeStorage` before they are written separately. If OS encryption is u
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.
activations to the existing window. Desktop pairing and active-profile request authentication remain in Electron main;
the renderer never receives the device secret or instance bearer token.

## Local setup

Linux presents the guided setup wizard and binds it to the shared `@propr/local-setup` engine. Progress and recovery
state are redacted before crossing IPC and persisted without prompt secrets, allowing a safely re-runnable setup to
resume after restart. The packaged app carries the same launcher manifest, orchestrator, and stack template as the CLI.

The desktop runtime root has one stable pathname: `<Electron userData>/desktop/local-stack`. Its `.env`, `data`,
`logs`, and `repos` children are the only desktop-managed stack locations and the only app-data paths handed to
Docker. The app validates owner-only, link-free ancestry before setup and every lifecycle start or restart. Native
directory selection is not a runtime-root feature; import/export will require a separate one-shot workflow if added.

macOS and Windows present remote connections as the supported path and explain that the local installer is Linux-only.
They do not show Docker Desktop installation or lifecycle actions.

## Desktop distributables and releases

Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ const windowsSign = windowsSigning ? {
description: 'ProPR Desktop',
} : undefined;

const cliAsset = (path: string): string => fileURLToPath(new URL(`../../packages/cli/dist/${path}`, import.meta.url));
const linuxCliResourceConfig = process.platform === 'linux'
? {
[['extra', 'Resource'].join('')]: [
cliAsset('orchestrator'),
cliAsset('assets'),
],
}
: {};

const config: ForgeConfig = {
packagerConfig: {
asar: true,
Expand All @@ -95,6 +105,7 @@ const config: ForgeConfig = {
},
} : {}),
...(windowsSign ? { windowsSign } : {}),
...linuxCliResourceConfig,
},
rebuildConfig: {},
hooks: {
Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@
"type": "module",
"main": ".vite/build/main.cjs",
"scripts": {
"prepare:renderer": "npm run build -w @propr/shared && npm run build -w @propr/client",
"prepare:renderer": "npm run build -w @propr/shared && npm run build -w @propr/client && npm run build -w @propr/local-setup && npm run build -w @propr/cli",
"predev": "npm run prepare:renderer",
"dev": "electron-forge start",
"pretypecheck": "npm run prepare:renderer",
"typecheck": "tsc --noEmit",
"pretest": "npm run prepare:renderer",
"test": "tsx --test src/**/*.test.ts scripts/*.test.mjs",
"pretest:native-durability": "npm run prepare:renderer",
"test:native-durability": "node scripts/run-native-durability.mjs",
"prepackage": "npm run prepare:renderer",
"package": "electron-forge package",
"smoke:package": "node scripts/smoke-packaged.mjs",
Expand All @@ -30,6 +33,12 @@
"premake:rpm": "npm run prepare:renderer",
"make:rpm": "PROPR_DESKTOP_ENABLE_RPM=1 electron-forge make --targets @electron-forge/maker-rpm"
},
"dependencies": {
"@propr/cli": "*",
"@propr/client": "*",
"@propr/local-setup": "*",
"@propr/shared": "*"
},
"devDependencies": {
"@electron-forge/cli": "8.0.0-alpha.10",
"@electron-forge/maker-deb": "8.0.0-alpha.10",
Expand Down
24 changes: 15 additions & 9 deletions apps/desktop/scripts/packaged-smoke-support.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -122,18 +122,18 @@ export const assertPackagedLayout = layout => {
throw new Error('Packaged renderer viewport does not match the actual native content bounds');
}

if (layout.logo.height < 18 || layout.logo.height > 22 || layout.logo.width < 40 || layout.logo.width > 100) {
throw new Error(`Packaged title-bar logo has unreasonable bounds: ${JSON.stringify(layout.logo)}`);
if (layout.logo.height < 28 || layout.logo.height > 36 || layout.logo.width < 28 || layout.logo.width > 36) {
throw new Error(`Packaged welcome-card logo has unreasonable bounds: ${JSON.stringify(layout.logo)}`);
}
if (
layout.logo.top < layout.titlebar.top
|| layout.logo.bottom > layout.titlebar.bottom
layout.logo.top < layout.brand.top
|| layout.logo.bottom > layout.brand.bottom
|| layout.card.left < 0
|| layout.card.right > layout.viewport.width
|| layout.card.top < layout.titlebar.bottom
|| layout.card.top < 0
|| layout.card.bottom > layout.viewport.height
) {
throw new Error('Packaged logo or connection card extends outside its layout container');
throw new Error('Packaged brand or welcome card extends outside its layout container');
}
for (const name of ['connectionName', 'apiUrl', 'submit']) {
const control = layout[name];
Expand All @@ -142,9 +142,15 @@ export const assertPackagedLayout = layout => {
}
}
assertGap(layout.connectionName, layout.apiUrl, 28, 'between connection inputs');
assertGap(layout.apiUrl, layout.apiHelp, 6, 'between API input and help text');
assertGap(layout.apiHelp, layout.submit, 16, 'between API help and submit button');
assertGap(layout.submit, layout.footer, 20, 'between submit button and runtime footer');
assertGap(layout.apiUrl, layout.submit, 16, 'between API input and Connect button');
if (
layout.state?.candidateApiUrl !== 'https://connect.propr.dev'
|| layout.state?.connectLabel !== 'Connect'
|| !layout.state?.noticeText?.includes('untrusted instance address')
|| layout.state?.runtimeFooterPresent !== false
) {
throw new Error(`Packaged renderer did not retain the staged shared-UI state: ${JSON.stringify(layout.state)}`);
}
};

const ensurePrivateDirectory = async path => {
Expand Down
24 changes: 16 additions & 8 deletions apps/desktop/scripts/packaged-smoke-support.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,27 @@ const layoutFixture = ({ windowWidth, windowHeight, workWidth, workHeight }) =>
viewport,
screen: { width: Math.max(workWidth, windowWidth), height: Math.max(workHeight, windowHeight) },
workArea: { width: workWidth, height: workHeight },
titlebar: { top: 0, bottom: 60 },
logo: { top: 20, bottom: 40, height: 20, width: 72 },
card: {
top: 80,
top: 30,
bottom: viewport.height - 12,
left: cardLeft,
right: cardLeft + cardWidth,
},
connectionName: control(110, 150),
apiUrl: control(180, 220),
apiHelp: control(226, 240),
submit: control(256, 296),
footer: control(316, 336),
brand: control(50, 86),
logo: { ...control(52, 84), width: 32, right: cardLeft + 56 },
form: control(90, viewport.height - 32),
back: control(100, 136),
heading: control(145, 180),
notice: control(205, 240),
connectionName: control(265, 305),
apiUrl: control(335, 375),
submit: control(405, 445),
state: {
candidateApiUrl: 'https://connect.propr.dev',
connectLabel: 'Connect',
noticeText: 'Review this untrusted instance address, then choose Connect to continue.',
runtimeFooterPresent: false,
},
};
};

Expand Down
125 changes: 125 additions & 0 deletions apps/desktop/scripts/run-native-durability.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const EXPECTED = Object.freeze({
'credential-service': 69,
'profile-store': 39,
'pairing-shutdown': 10,
});
const expectedTotal = Object.values(EXPECTED).reduce((total, count) => total + count, 0);
const tsxCli = fileURLToPath(import.meta.resolve('tsx/cli'));
const child = spawn(process.execPath, [
tsxCli,
'--test',
'--test-concurrency=1',
'src/profile-store.test.ts',
'src/credential-service.test.ts',
'src/pairing-response-lifecycle.test.ts',
], {
cwd: fileURLToPath(new URL('..', import.meta.url)),
env: process.env,
stdio: ['inherit', 'pipe', 'pipe'],
});

let output = '';
const forward = (stream, destination) => {
stream.setEncoding('utf8');
stream.on('data', chunk => {
output += chunk;
destination.write(chunk);
});
};
forward(child.stdout, process.stdout);
forward(child.stderr, process.stderr);

const result = await new Promise((resolve, reject) => {
child.once('error', reject);
// close fires only after both TAP pipes are drained; exit can race the final
// summary on Windows and would make a complete run look like setup failure.
child.once('close', (code, signal) => resolve({ code, signal }));
});

const plannedForSuite = (suiteName) => {
const escaped = suiteName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = output.match(new RegExp(
`# Subtest: ${escaped}[\\s\\S]*?\\n 1\\.\\.(\\d+)\\n(?:ok|not ok) \\d+ - ${escaped}`,
));
return match ? Number(match[1]) : 0;
};

const executed = {
'credential-service': plannedForSuite('main-process desktop credential service'),
'profile-store': plannedForSuite('desktop profile store'),
'pairing-shutdown': plannedForSuite('desktop pairing service IPC native shutdown lifecycle'),
};
const reportedCategory = (category) => {
const match = output.match(new RegExp(
`NATIVE_CATEGORY ${category} expected=(\\d+) executed=(\\d+)`,
));
return match ? { expected: Number(match[1]), executed: Number(match[2]) } : { expected: -1, executed: -1 };
};
const countedCategory = (category, expected) => ({
expected,
executed: output.match(new RegExp(`NATIVE_SCENARIO ${category}`, 'g'))?.length ?? 0,
});
const pairingShutdownCategory = category => ({
expected: 1,
executed: output.match(new RegExp(`NATIVE_PAIRING_SHUTDOWN ${category}(?:\\r?\\n|$)`, 'g'))?.length ?? 0,
});
const scenarioCategories = {
barriers: reportedCategory('barriers'),
'transaction-boundaries': reportedCategory('transaction-boundaries'),
'bootstrap-migration': reportedCategory('bootstrap-migration'),
'verified-handle-swap': reportedCategory('verified-handle-swap'),
'reordered-visibility': reportedCategory('reordered-visibility'),
'mirror-repair': countedCategory('mirror-repair', 6),
'revocation-crash': countedCategory('revocation-crash', 2),
'cancellation-switch': countedCategory('cancellation-switch', 4),
'detach-crash': countedCategory('detach-crash', process.platform === 'win32' ? 12 : 13),
'transient-revocation': countedCategory('transient-revocation', 4),
provisional: countedCategory('provisional', 1),
delivery: countedCategory('delivery', 1),
dispose: countedCategory('dispose', 1),
'start-header': pairingShutdownCategory('start-header'),
'start-body': pairingShutdownCategory('start-body'),
'poll-header': pairingShutdownCategory('poll-header'),
'poll-body': pairingShutdownCategory('poll-body'),
'activate-header': pairingShutdownCategory('activate-header'),
'activate-body': pairingShutdownCategory('activate-body'),
'cancel-header': pairingShutdownCategory('cancel-header'),
'cancel-body': pairingShutdownCategory('cancel-body'),
'never-settling-reader-cancel': pairingShutdownCategory('never-settling-reader-cancel'),
'never-settling-body-cancel': pairingShutdownCategory('never-settling-body-cancel'),
};
const summary = Object.fromEntries(
['tests', 'pass', 'fail', 'cancelled', 'skipped'].map(key => {
const match = output.match(new RegExp(`^# ${key} (\\d+)$`, 'm'));
return [key, match ? Number(match[1]) : -1];
}),
);

for (const [category, expected] of Object.entries(EXPECTED)) {
console.log(`Native durability category ${category}: expected=${expected} executed=${executed[category]}`);
}
for (const [category, counts] of Object.entries(scenarioCategories)) {
console.log(`Native durability category ${category}: expected=${counts.expected} executed=${counts.executed}`);
}
console.log(
`Native durability total: expected=${expectedTotal} executed=${summary.tests} `
+ `passed=${summary.pass} failed=${summary.fail} cancelled=${summary.cancelled} skipped=${summary.skipped}`,
);

const complete = Object.entries(EXPECTED).every(([category, expected]) => executed[category] === expected)
&& Object.values(scenarioCategories).every(({ expected, executed }) => expected >= 0 && executed === expected)
&& summary.tests === expectedTotal
&& summary.pass === expectedTotal
&& summary.fail === 0
&& summary.cancelled === 0
&& summary.skipped === 0
&& result.code === 0
&& result.signal === null;
if (!complete) {
throw new Error(
`Native durability matrix incomplete (child code=${String(result.code)}, signal=${String(result.signal)})`,
);
}
Loading
Loading