Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
42d1fb9
fix(ai): Resolve issue #1957 - Add cross-platform desktop packaging, …
Aug 29, 2026
78b2803
merge: resolve conflicts from 1952-epic-electron-runtime-app into 195…
Aug 29, 2026
a9dc441
feat(ai): Implemented the PR follow-ups.
Aug 29, 2026
7ba9e9b
feat(ai): Implemented F1–F4 without committing.
Aug 29, 2026
eef7396
feat(ai): Implemented the runtime download hardening without committi…
Aug 29, 2026
b36984e
feat(ai): Fixed the CI-only shortcut race in [DesktopExperience.tsx](…
Aug 29, 2026
872cd99
merge: resolve conflicts from 1952-epic-electron-runtime-app into 195…
Aug 29, 2026
f94a38d
Merge remote-tracking branch 'origin/1952-epic-electron-runtime-app' …
Aug 29, 2026
267e6b2
feat(ai): Implemented all requested follow-up blockers on exact head …
Aug 29, 2026
db3d69a
Merge remote-tracking branch 'origin/1952-epic-electron-runtime-app' …
Aug 29, 2026
fe68d40
feat(ai): Implemented F5–F8 on synchronized head `db3d69a2788ece2273c…
Aug 29, 2026
15e39bc
feat(ai): Implemented all four follow-ups without modifying F5–F8 or …
Aug 29, 2026
ef737aa
feat(ai): Implemented F9–F11 on the exact requested head without comm…
Aug 29, 2026
078eac2
feat(ai): Implemented the exact `ef737aacf73c77e4fa6c8a6d59dc3e5a16bb…
Aug 29, 2026
fb14a29
feat(ai): Implemented the narrow test-only fix in [release-architectu…
Aug 29, 2026
f088817
feat(ai): Implemented the two requested fixes on exact head `fb14a297…
Aug 30, 2026
20fcc8c
feat(ai): Implemented only the aggregate DMG finalization fix on exac…
Aug 30, 2026
4fba922
feat(ai): Implemented the two requested DMG blockers on exact head `2…
Aug 30, 2026
bcc7372
feat(ai): Implemented the exact-head DMG follow-up without merging, s…
Aug 30, 2026
ce7ce35
feat(ai): Implemented the two requested fixes on exact head `bcc73729…
Aug 30, 2026
f4cfdcd
feat(ai): Implemented F12 only.
Aug 30, 2026
fe26f93
feat(ai): Implemented F13 on exact head `f4cfdcd717be70804f435ef3955b…
Aug 30, 2026
a047008
feat(ai): Implemented on exact head `fe26f938c7ea8c2ae276f0abbb5079fd…
Aug 30, 2026
3737d95
feat(ai): Implemented the follow-up changes on exact head `a0470086c6…
Aug 30, 2026
e98a6f3
feat(ai): Implemented the requested follow-up changes, but completion…
Aug 30, 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
733 changes: 710 additions & 23 deletions .github/workflows/desktop-release-guard.yml

Large diffs are not rendered by default.

134 changes: 130 additions & 4 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ 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
# macOS only, after packaging the selected architecture:
npm run make:dmg -w @propr/desktop -- --arch=arm64
```

Desktop development, typecheck, package, and make commands build required renderer workspace dependencies through
Expand All @@ -27,10 +29,13 @@ 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 at 1280x820 without a
sandbox-disabling flag, rejects main-process uncaught exceptions, and requires proof that `window.proprDesktop` is
exposed. It also checks the real renderer bounds for the title-bar logo and connection-card controls before accepting
renderer-ready and a clean exit.
The packaged-binary smoke test verifies the hardened fuse states, launches artifacts where the host permits (at
1280x820 on Linux) without a sandbox-disabling flag, rejects main-process uncaught exceptions, and requires proof that
`window.proprDesktop` is exposed. It also checks the real renderer bounds for the title-bar logo and connection-card
controls before accepting renderer-ready and a clean exit. `desktop:smoke:inspect` performs executable and fuse
inspection without launching a window. Release CI launches both Linux architectures under Xvfb, inspects macOS and
Windows packages on their native runners, validates DMG/ZIP/DEB/RPM/NuGet containers, and validates configured OS
signatures.

`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
Expand All @@ -50,3 +55,124 @@ fallback. Profiles remain usable because they contain only a display label and v
`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.

## Desktop distributables and releases

Desktop releases have their own `desktop-v<major>.<minor>.<patch>` tags. They do not use or require the monorepo's
`v<version>` tag. `PROPR_DESKTOP_VERSION` propagates the tag version into the packaged application, renderer, native
metadata, Linux packages, Squirrel package, artifact names, and release manifest without changing the monorepo
package versions.

The native GitHub Actions matrix produces these assets for both x64 and arm64:

| Platform | Native runner | Direct-distribution artifacts |
| --- | --- | --- |
| Linux | `ubuntu-24.04`, `ubuntu-24.04-arm` | DEB, RPM, ZIP |
| macOS | `macos-15-intel`, `macos-15` | DMG, ZIP |
| Windows | `windows-2025`, `windows-11-arm` | Squirrel Setup.exe, full NuGet update package, RELEASES metadata |

Every matrix job stages names in the form `ProPR-Desktop-<version>-<platform>-<arch>-<kind>`. The final job rejects
missing targets or changed fragment checksums, emits `SHA256SUMS` and `desktop-release.json`, and attaches the complete
set to the matching GitHub release. Production publication is triggered only by a new, non-forced
`desktop-v<major>.<minor>.<patch>` tag push; there is no manual dispatch path. A secretless preflight must succeed before
any job can request the protected release environment or receive release secrets. Normal local packages are unsigned
and have updates disabled:

```sh
npm ci
npm run desktop:typecheck
npm run desktop:test
npm run desktop:package
xvfb-run --auto-servernum npm run desktop:smoke # Linux

# Full unsigned Linux release artifacts (requires dpkg-deb and rpmbuild/rpm):
PROPR_DESKTOP_VERSION=1.2.3 \
PROPR_DESKTOP_ENABLE_DEB=1 \
PROPR_DESKTOP_ENABLE_RPM=1 \
npm run make -w @propr/desktop -- --arch="$(node -p process.arch)"
```

### CI preflight, signing, and notarization configuration

Repository-ruleset inspection uses a dedicated GitHub App installed only on this repository. Configure the App with
exactly repository **Administration: read** and **Contents: read** (GitHub adds Metadata: read implicitly), with no
write permission and no Actions, Deployments, Environments, Releases, or other repository permission. Store its
private key only in a separate approval-protected `desktop-release-preflight` environment:

- Variable `PROPR_DESKTOP_PREFLIGHT_APP_ID`: the least-privilege preflight App ID.
- Secret `PROPR_DESKTOP_PREFLIGHT_APP_PRIVATE_KEY`: that App's private key.

Configure `desktop-release-preflight` with at least one required reviewer, custom deployment policies enabled,
protected-branch policies disabled, and exactly one deployment policy: the tag pattern `desktop-v*`. The workflow
uses a SHA-pinned token action to mint a short-lived installation token explicitly requesting only Administration read
and Contents read; workflow regression tests pin those exact inputs and reject any write or Actions permission. The
App installation itself must have the same exact least-privilege permission set. Preflight fails closed when the
ruleset API does not return `bypass_actors`. Pull requests do not schedule this job, and a nonmatching or unreviewed tag
cannot enter the environment or obtain the App credential. The preflight environment must contain no signing,
notarization, update-signing, release-publication, or production deployment secret.

Signing material is read only from the distinct approval-protected `desktop-release` GitHub environment and written
to runner-temporary files/keychains. Every value below is mandatory for a production `desktop-v*` tag; unsigned and
partially signed production releases fail before publication. Pull-request package validation and the preflight
environment receive none of these secrets and explicitly check that release-secret environment variables are absent.

GitHub Actions secrets:

- `PROPR_DESKTOP_MAC_CERTIFICATE_P12_BASE64`: base64 of the Developer ID Application `.p12`.
- `PROPR_DESKTOP_MAC_CERTIFICATE_PASSWORD`: password for that `.p12`.
- `PROPR_DESKTOP_APPLE_API_KEY_P8_BASE64`: base64 of the App Store Connect API `.p8` key.
- `PROPR_DESKTOP_APPLE_API_KEY_ID`: App Store Connect API key ID.
- `PROPR_DESKTOP_APPLE_API_ISSUER_ID`: App Store Connect issuer UUID.
- `PROPR_DESKTOP_WINDOWS_CERTIFICATE_PFX_BASE64`: base64 of the Authenticode `.pfx`.
- `PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD`: password for that `.pfx`.
- `PROPR_DESKTOP_UPDATE_PRIVATE_KEY`: base64 Ed25519 PKCS#8 DER key used only to sign update-channel metadata.

GitHub Actions variables (public configuration, not secrets):

- `PROPR_DESKTOP_MAC_SIGNING_IDENTITY`: exact Developer ID Application identity.
- `PROPR_DESKTOP_MAC_TEAM_ID`: exact Team ID embedded in signed macOS update builds and verified from produced apps.
- `PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY`: exact Authenticode certificate subject expected by installed builds.
- `PROPR_DESKTOP_WINDOWS_SIGNER_PINS`: sorted, unique comma-separated allowlist of one or more
`certificate-sha256:<64 lowercase hex>` or `spki-sha256:<64 lowercase hex>` fingerprints. Production Windows
packaging fails closed when this public operator pin is absent, malformed, or does not match the signing key.
- `PROPR_DESKTOP_UPDATE_PUBLIC_KEY`: base64 Ed25519 SPKI DER public key matching the update private key.
- `PROPR_DESKTOP_UPDATE_MANIFEST_URL`: stable HTTPS URL from which clients fetch `desktop-release.json`; the detached
signature must be published beside it as `desktop-release.json.sig`.
- `PROPR_DESKTOP_DARWIN_X64_FEED_URL`, `PROPR_DESKTOP_DARWIN_ARM64_FEED_URL`: Squirrel.Mac JSON feed URLs.
- `PROPR_DESKTOP_WINDOWS_X64_FEED_URL`, `PROPR_DESKTOP_WINDOWS_ARM64_FEED_URL`: Squirrel.Windows feed directories.

Generate the independent update-channel keys once and store only the public output as a repository variable:

```sh
openssl genpkey -algorithm ED25519 -outform DER -out desktop-update-private.der
openssl pkey -inform DER -in desktop-update-private.der -pubout -outform DER -out desktop-update-public.der
base64 < desktop-update-private.der # secret: PROPR_DESKTOP_UPDATE_PRIVATE_KEY
base64 < desktop-update-public.der # variable: PROPR_DESKTOP_UPDATE_PUBLIC_KEY
```

Do not commit either key file. The private key is available only to the approval-protected `desktop-release`
environment. Configure that environment with at least one required reviewer, custom deployment policies enabled,
protected-branch policies disabled, and exactly one deployment policy: the tag pattern `desktop-v*`. The repository's
default branch must be protected `main`. It must also have an active tag-targeting ruleset whose sole include is
`refs/tags/desktop-v*`, whose exclude and bypass-actor lists are empty, and whose rules block both tag updates and tag
deletions.

For each new, non-forced `desktop-v<major>.<minor>.<patch>` tag push, the read-only preflight verifies both protected
environments and the repository prerequisites through the GitHub API, proves the exact tag commit is reachable from
`main`, rejects an existing release, and rechecks the tag and immutability ruleset for changes. The active tag ruleset
must match exactly `refs/tags/desktop-v*`, have no exclusions or bypass actors, and block update and deletion. Pull-
request finalization produces unsigned validation metadata; trusted signing jobs depend on preflight, check out its
immutable SHA, revalidate the tag before publication, and fail closed if any signing, notarization, or signed-update
field is missing. A release operator must publish the exact signed manifest/signature, generated native feeds, and
bound packages to their configured HTTPS URLs. The manifest URL must not contain a query, so its companion is always
the documented pathname plus `.sig`.

Linux never checks for native updates. macOS and Windows operate as check-only channels: they verify the Ed25519
manifest, exact target/version/feed bytes, package URL/size/SHA-256, and the actual Team ID/designated requirement or
Authenticode certificate subject plus certificate/SPKI SHA-256 fingerprints extracted from the downloaded package.
Windows requires the identical valid, timestamped signer on the installer, packaged application, and the exact
`lib/net45/propr-desktop.exe` from the validated NUPKG; the runtime also requires its signed fingerprint evidence to
match the allowlist embedded in the installed build. Electron's `autoUpdater` is not initialized,
because it would re-fetch mutable URLs instead of installing the already verified bytes. Unsigned developer packages
remain update-disabled. The internal apply API exposes only a one-shot held-byte capability, never a verified mutable
pathname; without a platform adapter that can consume that held/locked capability, automatic apply fails closed.
117 changes: 112 additions & 5 deletions apps/desktop/forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,100 @@ 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 { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
readCompleteEnvironmentGroup,
requireProductionReleaseConfiguration,
resolveDesktopVersion,
resolveTrustedUpdateBuildConfig,
} from './src/release-config';
import { DESKTOP_EXECUTABLE_NAME, SQUIRREL_PACKAGE_NAME } from './src/squirrel-events';

const desktopPackage = JSON.parse(
readFileSync(fileURLToPath(new URL('./package.json', import.meta.url)), 'utf8'),
) as { version: string };
const releaseVersion = resolveDesktopVersion(desktopPackage.version);
const updateConfig = resolveTrustedUpdateBuildConfig();
const macSigning = readCompleteEnvironmentGroup(
process.env,
['PROPR_DESKTOP_MAC_SIGNING_IDENTITY'],
'macOS signing',
);
const macNotarization = readCompleteEnvironmentGroup(
process.env,
[
'PROPR_DESKTOP_APPLE_API_KEY_FILE',
'PROPR_DESKTOP_APPLE_API_KEY_ID',
'PROPR_DESKTOP_APPLE_API_ISSUER_ID',
],
'macOS notarization',
);
const windowsSigning = readCompleteEnvironmentGroup(
process.env,
['PROPR_DESKTOP_WINDOWS_CERTIFICATE_FILE', 'PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD'],
'Windows signing',
);

if (macNotarization && !macSigning) {
throw new Error('macOS notarization requires macOS signing configuration');
}
if (updateConfig.enabled) {
if (process.platform === 'darwin' && !macSigning) {
throw new Error('The macOS signed-update build must have a macOS signing identity');
}
if (process.platform === 'win32' && !windowsSigning) {
throw new Error('The Windows signed-update build must have a Windows signing certificate');
}
}
if (process.env.PROPR_DESKTOP_PRODUCTION_RELEASE === '1') {
requireProductionReleaseConfiguration({
platform: process.platform,
updateConfig,
macSigning,
macNotarization,
windowsSigning,
});
}

const windowsSign = windowsSigning ? {
certificateFile: windowsSigning.PROPR_DESKTOP_WINDOWS_CERTIFICATE_FILE,
certificatePassword: windowsSigning.PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD,
description: 'ProPR Desktop',
} : undefined;

const config: ForgeConfig = {
packagerConfig: {
asar: true,
name: 'propr-desktop',
executableName: 'propr-desktop',
appBundleId: 'dev.propr.desktop',
appCategoryType: 'public.app-category.developer-tools',
appVersion: releaseVersion,
buildVersion: releaseVersion,
name: DESKTOP_EXECUTABLE_NAME,
executableName: DESKTOP_EXECUTABLE_NAME,
protocols: [{ name: 'ProPR Desktop', schemes: ['propr'] }],
...(macSigning ? {
osxSign: {
continueOnError: false,
identity: macSigning.PROPR_DESKTOP_MAC_SIGNING_IDENTITY,
},
} : {}),
...(macNotarization ? {
osxNotarize: {
appleApiKey: macNotarization.PROPR_DESKTOP_APPLE_API_KEY_FILE,
appleApiKeyId: macNotarization.PROPR_DESKTOP_APPLE_API_KEY_ID,
appleApiIssuer: macNotarization.PROPR_DESKTOP_APPLE_API_ISSUER_ID,
},
} : {}),
...(windowsSign ? { windowsSign } : {}),
},
rebuildConfig: {},
hooks: {
readPackageJson: async (_forgeConfig, packageJson) => ({
...packageJson,
version: releaseVersion,
}),
packageAfterCopy: async (_forgeConfig, resourcesPath, _electronVersion, platform, arch) => {
const applePlatform = platform === 'darwin' || platform === 'mas';
const executableName = applePlatform ? 'Electron' : `electron${platform === 'win32' ? '.exe' : ''}`;
Expand All @@ -35,10 +119,33 @@ const config: ForgeConfig = {
},
},
makers: [
new MakerSquirrel({ name: 'propr_desktop' }),
new MakerSquirrel({
name: SQUIRREL_PACKAGE_NAME,
setupExe: `ProPR-Desktop-${releaseVersion}-Setup.exe`,
version: releaseVersion,
...(windowsSign ? { windowsSign } : {}),
}),
new MakerZIP({}, ['darwin', 'linux']),
...(process.env.PROPR_DESKTOP_ENABLE_DEB === '1' ? [new MakerDeb({})] : []),
...(process.env.PROPR_DESKTOP_ENABLE_RPM === '1' ? [new MakerRpm({})] : []),
...(process.env.PROPR_DESKTOP_ENABLE_DEB === '1'
? [new MakerDeb({
options: {
name: DESKTOP_EXECUTABLE_NAME,
productName: 'ProPR Desktop',
version: releaseVersion,
bin: DESKTOP_EXECUTABLE_NAME,
},
})]
: []),
...(process.env.PROPR_DESKTOP_ENABLE_RPM === '1'
? [new MakerRpm({
options: {
name: DESKTOP_EXECUTABLE_NAME,
productName: 'ProPR Desktop',
version: releaseVersion,
bin: DESKTOP_EXECUTABLE_NAME,
},
})]
: []),
],
plugins: [
new VitePlugin({
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,16 @@
"dev": "electron-forge start",
"pretypecheck": "npm run prepare:renderer",
"typecheck": "tsc --noEmit",
"test": "tsx --test src/**/*.test.ts",
"test": "tsx --test src/**/*.test.ts scripts/*.test.mjs",
"prepackage": "npm run prepare:renderer",
"package": "electron-forge package",
"smoke:package": "node scripts/smoke-packaged.mjs",
"smoke:inspect": "node scripts/smoke-packaged.mjs --inspect-only",
"premake": "npm run prepare:renderer",
"make": "electron-forge make",
"make:dmg": "node scripts/make-dmg.mjs",
"release:stage": "node scripts/release-artifacts.mjs stage",
"release:finalize": "node scripts/release-artifacts.mjs finalize",
"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",
Expand Down
39 changes: 39 additions & 0 deletions apps/desktop/scripts/make-dmg.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { execFile } from 'node:child_process';
import { access, cp, mkdir, mkdtemp, readFile, rm, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { promisify } from 'node:util';
import { basename, join, resolve } from 'node:path';

const execFileAsync = promisify(execFile);
if (process.platform !== 'darwin') throw new Error('DMG artifacts must be built on a native macOS host');

const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
const version = process.env.PROPR_DESKTOP_VERSION?.trim() || packageJson.version;
const archArgument = process.argv.find(argument => argument.startsWith('--arch='));
const arch = archArgument?.slice('--arch='.length) || process.arch;
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) {
throw new Error(`Invalid desktop release version: ${version}`);
}
if (arch !== 'x64' && arch !== 'arm64') throw new Error(`Unsupported macOS architecture: ${arch}`);

const appPath = resolve('out', `propr-desktop-darwin-${arch}`, 'propr-desktop.app');
const outputDirectory = resolve('out', 'make', 'dmg', arch);
const outputPath = resolve(outputDirectory, `ProPR-Desktop-${version}-macos-${arch}.dmg`);
await access(appPath);
await mkdir(outputDirectory, { recursive: true });
const stagingDirectory = await mkdtemp(join(tmpdir(), 'propr-dmg-layout-'));
try {
await cp(appPath, join(stagingDirectory, basename(appPath)), { recursive: true, verbatimSymlinks: true });
await symlink('/Applications', join(stagingDirectory, 'Applications'));
await execFileAsync('hdiutil', [
'create',
'-volname', 'ProPR Desktop',
'-srcfolder', stagingDirectory,
'-ov',
'-format', 'UDZO',
outputPath,
]);
} finally {
await rm(stagingDirectory, { recursive: true, force: true });
}
console.log(outputPath);
Loading
Loading