Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 20 additions & 1 deletion .github/workflows/desktop-release-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,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 desktop-file-utils fakeroot libglib2.0-bin libsecret-1-0 rpm unzip xdg-utils xvfb zip

- name: Package desktop app from clean checkout
shell: bash
Expand Down Expand Up @@ -264,6 +264,25 @@ jobs:
--make-directory apps/desktop/out/make \
--output "desktop-release-${{ matrix.platform }}-${{ matrix.arch }}"

- name: Exercise staged native install, deep-link, relaunch, and removal lifecycle
if: matrix.platform == 'linux' || matrix.platform == 'darwin'
shell: bash
run: |
if [ "${{ matrix.platform }}" = linux ]; then
dbus-run-session -- xvfb-run --auto-servernum \
node apps/desktop/scripts/test-native-artifact-lifecycle.mjs \
--version "$PROPR_DESKTOP_VERSION" \
--platform linux \
--arch "${{ matrix.arch }}" \
--artifact-directory "desktop-release-${{ matrix.platform }}-${{ matrix.arch }}"
else
node apps/desktop/scripts/test-native-artifact-lifecycle.mjs \
--version "$PROPR_DESKTOP_VERSION" \
--platform darwin \
--arch "${{ matrix.arch }}" \
--artifact-directory "desktop-release-${{ matrix.platform }}-${{ matrix.arch }}"
fi

- name: Upload unsigned validation target
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
Expand Down
82 changes: 82 additions & 0 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,88 @@ PROPR_DESKTOP_ENABLE_RPM=1 \
npm run make -w @propr/desktop -- --arch="$(node -p process.arch)"
```

### Unsigned internal-RC install and removal (macOS/Linux)

Use the artifact whose final `x64` or `arm64` suffix matches the machine. These commands are for internal release
candidates only. They do not assert signing or notarization, do not change Gatekeeper policy, and do not use `xattr`,
`spctl --add`, or another quarantine bypass.

On Debian/Ubuntu, exercise the DEB and remove it with the native package manager:

```sh
ARCH=x64 # use arm64 on an ARM64 Linux machine
VERSION=0.8.15
sudo apt install "./ProPR-Desktop-${VERSION}-linux-${ARCH}.deb"
propr-desktop
xdg-open 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000'
xdg-open 'propr://connect?api=https%3A%2F%2Ft-your-tunnel.propr.dev'
sudo apt remove propr-desktop
```

On Fedora/RHEL-family systems, use the RPM. The ZIP is the non-package-manager alternative on either family:

```sh
ARCH=x64 # use arm64 on an ARM64 Linux machine
VERSION=0.8.15
sudo rpm --install "ProPR-Desktop-${VERSION}-linux-${ARCH}.rpm"
propr-desktop
sudo rpm --erase propr-desktop

install_root="$(mktemp -d)"
unzip "ProPR-Desktop-${VERSION}-linux-${ARCH}.zip" -d "$install_root"
"$install_root/propr-desktop-linux-${ARCH}/propr-desktop"
rm -r "$install_root"
```

On either Intel (`x64`) or Apple Silicon (`arm64`) macOS, the DMG flow mounts and copies the app; the ZIP flow uses
the system archive tool. Quit ProPR Desktop before removal:

```sh
ARCH=arm64 # use x64 on an Intel Mac
VERSION=0.8.15
mount_point="$(mktemp -d)"
hdiutil attach -readonly -nobrowse -mountpoint "$mount_point" \
"ProPR-Desktop-${VERSION}-macos-${ARCH}.dmg"
ditto "$mount_point/propr-desktop.app" '/Applications/propr-desktop.app'
hdiutil detach "$mount_point"
rmdir "$mount_point"
open '/Applications/propr-desktop.app'
open 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000'
open 'propr://connect?api=https%3A%2F%2Ft-your-tunnel.propr.dev'
osascript -e 'tell application id "dev.propr.desktop" to quit'
rm -r '/Applications/propr-desktop.app'

install_root="$(mktemp -d)"
ditto -x -k "ProPR-Desktop-${VERSION}-macos-${ARCH}.zip" "$install_root"
open "$install_root/propr-desktop.app"
osascript -e 'tell application id "dev.propr.desktop" to quit'
rm -r "$install_root"
```

The pull-request native gate runs on `ubuntu-24.04`/`ubuntu-24.04-arm` and
`macos-15-intel`/`macos-15`. It consumes the canonical staged bytes: DEB/RPM are extracted with `dpkg-deb`/`rpm2cpio`
and ZIP with the platform archive tool; DMG is mounted read-only and copied with `ditto`. DMG authority begins as soon
as attach succeeds, and detach plus an absent-mount postcondition is mandatory before its mount root is removed. Every
format gets a first launch, clean shutdown, preserved-state relaunch, and owned-root removal. The gate rechecks the executable architecture,
identity/version, launcher or bundle, safe paths/symlinks, 0700/0600 profile authority, unchanged artifact bytes, and
absence of default-profile leakage. A fixed non-secret custody probe must either round-trip through OS encryption or be
refused with no `basic_text`/plaintext fallback; macOS additionally requires the OS-protected Keychain backend.
Evidence files contain fixed event names only—never endpoints, paths, credentials,
or process output. Deep-link events are written only after the already-loaded renderer acknowledges the exact consumed
confirmation or queued navigation state; each dispatch waits for that bounded acknowledgement before the next begins.

Linux DEB/RPM protocol evidence uses an isolated XDG MIME database and a CI-relocated copy of the package's real
desktop launcher, then dispatches with `gio`; ZIP has no registered launcher, so its single-instance dispatch evidence
is direct and is reported as that limitation. macOS registers the copied bundle with LaunchServices and dispatches with
`open -b`; unregister failure or a stale exact copied-bundle record fails cleanup. LaunchServices' database writes are
OS-managed state, distinct from app-owned profile writes, and are not reported as cleaned while that copied record remains.
Cold starts for every format are direct executable argv launches, not OS protocol launches; OS protocol dispatch evidence
is warm-only. The Linux job does not forward its outer session-bus address or provision a scoped Secret Service, so its
secure-storage result is explicitly fallback-only: `basic_text`/plaintext storage must be refused, and installed
`libsecret` is not claimed as exercised custody. These checks do
not claim end-user Gatekeeper approval for unsigned builds, signing, notarization, or behavior on a desktop session that
the hosted runner cannot provide.

### CI preflight, signing, and notarization configuration

Repository-ruleset inspection uses a dedicated GitHub App installed only on this repository. Configure the App with
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ const config: ForgeConfig = {
productName: 'ProPR Desktop',
version: releaseVersion,
bin: DESKTOP_EXECUTABLE_NAME,
mimeType: ['x-scheme-handler/propr'],
},
})]
: []),
Expand All @@ -172,6 +173,7 @@ const config: ForgeConfig = {
productName: 'ProPR Desktop',
version: releaseVersion,
bin: DESKTOP_EXECUTABLE_NAME,
mimeType: ['x-scheme-handler/propr'],
},
})]
: []),
Expand Down
16 changes: 15 additions & 1 deletion apps/desktop/scripts/packaged-smoke-support.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ export const createSmokeChildEnvironment = async ({
profileApiUrl,
parentEnvironment = process.env,
inspectPath = lstat,
preserveMacosKeychainContext = false,
}) => {
if (!profile || !createdProfiles.has(profile)) {
throw new Error('Packaged smoke child environment rejected an unknown profile');
Expand Down Expand Up @@ -330,8 +331,21 @@ export const createSmokeChildEnvironment = async ({
});
}
if (platform === 'darwin') {
let home = profile.home;
if (preserveMacosKeychainContext) {
const runnerHome = parentEnvironment.HOME;
if (typeof runnerHome !== 'string' || runnerHome.length > 4096
|| !isAbsolute(runnerHome) || resolve(runnerHome) !== runnerHome) {
throw new Error('Packaged smoke macOS Keychain home is invalid');
}
const homeStats = await inspectPath(runnerHome);
if (!homeStats.isDirectory() || homeStats.isSymbolicLink()) {
throw new Error('Packaged smoke macOS Keychain home is invalid');
}
home = runnerHome;
}
return Object.freeze({
HOME: profile.home,
HOME: home,
...triggers,
TEMP: profile.temporary,
TMP: profile.temporary,
Expand Down
37 changes: 36 additions & 1 deletion apps/desktop/scripts/packaged-smoke-support.test.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import { chmod, readFile, writeFile } from 'node:fs/promises';
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, join, relative } from 'node:path';
import { describe, test } from 'node:test';
Expand Down Expand Up @@ -103,6 +103,41 @@ describe('packaged smoke native window layout', () => {
});

describe('packaged smoke child environment', () => {
test('preserves a validated runner HOME only for an explicit native macOS Keychain context', async () => {
const profile = await createPrivateSmokeProfile(tmpdir());
const runnerHome = await mkdtemp(join(tmpdir(), 'propr-runner-home-'));
try {
const isolated = await createSmokeChildEnvironment({
platform: 'darwin',
profile,
profileApiUrl: 'http://127.0.0.1:43123',
parentEnvironment: { HOME: runnerHome },
});
assert.equal(isolated.HOME, profile.home);

const keychainEnabled = await createSmokeChildEnvironment({
platform: 'darwin',
profile,
profileApiUrl: 'http://127.0.0.1:43123',
parentEnvironment: { HOME: runnerHome },
preserveMacosKeychainContext: true,
});
assert.equal(keychainEnabled.HOME, runnerHome);
assert.equal(keychainEnabled.TMPDIR, profile.temporary);

await assert.rejects(createSmokeChildEnvironment({
platform: 'darwin',
profile,
profileApiUrl: 'http://127.0.0.1:43123',
parentEnvironment: { HOME: 'relative-home' },
preserveMacosKeychainContext: true,
}), /Keychain home is invalid/);
} finally {
await removePrivateSmokeProfile(profile);
await rm(runnerHome, { recursive: true, force: true });
}
});

test('passes only platform launch inputs and private profile paths from a hostile parent', async () => {
const parent = await createPrivateSmokeProfile(tmpdir());
const xAuthority = join(parent.root, 'Xauthority');
Expand Down
Loading
Loading