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
24 changes: 19 additions & 5 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ 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
`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 All @@ -37,8 +37,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 @@ -47,5 +47,19 @@ 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.
7 changes: 7 additions & 0 deletions apps/desktop/forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@ 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';
import { fileURLToPath } from 'node:url';

const cliAsset = (path: string): string => fileURLToPath(new URL(`../../packages/cli/dist/${path}`, import.meta.url));

const config: ForgeConfig = {
packagerConfig: {
asar: true,
name: 'propr-desktop',
executableName: 'propr-desktop',
extraResource: [
cliAsset('orchestrator'),
cliAsset('assets'),
],
},
rebuildConfig: {},
hooks: {
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@
"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",
"prepackage": "npm run prepare:renderer",
"package": "electron-forge package",
Expand All @@ -26,6 +27,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
107 changes: 107 additions & 0 deletions apps/desktop/src/desktop-host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { ConfigManager } from '@propr/cli/dist/config/index.js';
import { loginWithGithubCli } from '@propr/cli/dist/auth/githubLogin.js';
import { configureStackTemplatePath } from '@propr/cli/dist/commands/initStack.js';
import { createDefaultActions } from '@propr/cli/dist/commands/setup/hostActions.js';
import { configureOrchestratorAssetPath, getHostConfig } from '@propr/cli/dist/orchestrator/index.js';
import { localhostServiceUrl } from '@propr/cli/dist/utils/dockerPort.js';
import { dirname, join, resolve } from 'node:path';
import type { SetupActions } from '@propr/local-setup';
import type { LocalLifecycleHost } from './lifecycle';
import { bindRootOperations, RootDirectoryAuthority } from './setup-capabilities';

export interface DesktopLocalHost {
actions: SetupActions;
config: ConfigManager;
lifecycle: LocalLifecycleHost;
resolveApiBaseUrl(rootDir: string, signal?: AbortSignal): Promise<string>;
}

/** Bind the portable setup engine to the same launcher used by the CLI. */
export async function createDesktopLocalHost(resourcesPath?: string, defaultRootDir?: string, appDataDir = defaultRootDir ? dirname(defaultRootDir) : undefined): Promise<DesktopLocalHost> {
if (resourcesPath) {
configureOrchestratorAssetPath(join(resourcesPath, 'orchestrator', 'orchestrator.mjs'));
configureStackTemplatePath(join(resourcesPath, 'assets', 'env.example.txt'));
}
const config = new ConfigManager();
await config.init();
const defaultActions = createDefaultActions(config);
const actions: SetupActions = {
...defaultActions,
async loginWithGithub({ onLog, signal } = {}) {
// A packaged GUI has no controlling terminal. Reuse an existing gh
// session, but leave an actionable recovery step instead of launching an
// invisible interactive process when the user is not signed in.
const result = await loginWithGithubCli(config, { interactive: false, onLog, signal });
if (!result.ok) onLog?.(result.message);
return result.ok;
},
async startStack(params) {
params.signal?.throwIfAborted();
params.assertRootAuthority?.();
const { orch, cfg } = await getHostConfig({ configManager: config, root: params.rootDir, readRoot: params.rootOperationsDir });
params.assertRootAuthority?.();
const recovered = await orch.recoverStackAsync(cfg, {
ui: params.ui ?? config.getUiEnabled() ?? true,
docs: params.docs ?? cfg.docsEnabled,
signal: params.signal,
onLog: params.onLog,
assertRootAuthority: params.assertRootAuthority,
});
params.assertRootAuthority?.();
if (!recovered.recovered) await defaultActions.startStack(params);
},
};

const root = (): string => {
if (!defaultRootDir) throw new Error('No fixed local ProPR runtime root is configured');
return resolve(defaultRootDir);
};

const withFixedRoot = async <T>(operation: (authority: RootDirectoryAuthority, displayRoot: string) => Promise<T>): Promise<T> => {
const displayRoot = root();
const authority = RootDirectoryAuthority.open(displayRoot, true, appDataDir);
try { return await operation(authority, displayRoot); } finally { authority.close(); }
};

return {
actions,
config,
async resolveApiBaseUrl(rootDir, signal) {
return withFixedRoot(async (authority, displayRoot) => {
if (resolve(rootDir) !== displayRoot) throw new Error('The local profile root is not the fixed desktop runtime root');
signal?.throwIfAborted();
authority.validate();
const { cfg } = await getHostConfig({ configManager: config, root: displayRoot, readRoot: authority.operationPath() });
authority.validate();
signal?.throwIfAborted();
return localhostServiceUrl(cfg.apiPort);
});
},
lifecycle: {
async running(signal) {
return withFixedRoot(async (authority, displayRoot) => {
signal?.throwIfAborted();
authority.validate();
const { orch, cfg } = await getHostConfig({ configManager: config, root: displayRoot, readRoot: authority.operationPath() });
authority.validate();
return orch.isLifecycleStackRunningAsync(cfg, { signal, assertRootAuthority: () => authority.validate() });
});
},
async start(signal) {
await withFixedRoot((authority, displayRoot) => bindRootOperations(actions, displayRoot, authority).startStack({ rootDir: displayRoot, signal }));
},
async stop(signal) {
await withFixedRoot(async (authority, displayRoot) => {
signal?.throwIfAborted();
authority.validate();
const { orch, cfg } = await getHostConfig({ configManager: config, root: displayRoot, readRoot: authority.operationPath() });
authority.validate();
const { failed } = await orch.stopLifecycleStackAsync(cfg, { signal, assertRootAuthority: () => authority.validate() });
authority.validate();
signal?.throwIfAborted();
if (failed.length) throw new Error(`Could not stop ${failed.join(', ')}`);
});
},
},
};
}
42 changes: 34 additions & 8 deletions apps/desktop/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import type { App, IpcMain, IpcMainInvokeEvent, Session } from 'electron';
import { shell } from 'electron';
import { logoutDesktopSession } from './desktop-session';
import type { DesktopLogger } from './logger';
import type { DesktopOperationCoordinator } from './operation-coordinator';
import type { LocalLifecycleController } from './lifecycle';
import type { ProfileStore } from './profile-store';
import type { DesktopSetupController } from './setup-controller';
import { isSafeExternalUrl, isTrustedRendererUrl } from './security';
import { IPC_CHANNELS } from './shared/contract';

Expand All @@ -12,10 +14,12 @@ interface RegisterIpcOptions {
ipcMain: IpcMain;
profiles: ProfileStore;
lifecycle: LocalLifecycleController;
setup: DesktopSetupController;
logger: DesktopLogger;
desktopSession: Session;
devServerUrl: string | undefined;
packagedRendererUrl: string;
coordinator: DesktopOperationCoordinator;
}

type Handler = (event: IpcMainInvokeEvent, ...args: any[]) => unknown;
Expand All @@ -35,7 +39,7 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): void => {
return await handler(event, ...args);
} catch (error) {
options.logger.log('error', 'desktop.ipc.failed', { channel, error });
throw error;
throw new Error('Desktop operation failed. Review the protected desktop log for details.');
}
});
};
Expand All @@ -57,11 +61,33 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): void => {
handle(IPC_CHANNELS.profilesSave, (_event, input) => options.profiles.save(input));
handle(IPC_CHANNELS.profilesRemove, (_event, profileId) => options.profiles.remove(profileId));
handle(IPC_CHANNELS.profilesSetActive, (_event, profileId) => options.profiles.setActive(profileId));
handle(IPC_CHANNELS.credentialsRead, (_event, profileId) => options.profiles.readCredential(profileId));
handle(IPC_CHANNELS.credentialsWrite, (_event, profileId, value) => options.profiles.writeCredential(profileId, value));
handle(IPC_CHANNELS.credentialsRemove, (_event, profileId) => options.profiles.removeCredential(profileId));
handle(IPC_CHANNELS.lifecycleStatus, () => options.lifecycle.status());
handle(IPC_CHANNELS.lifecycleStart, () => options.lifecycle.start());
handle(IPC_CHANNELS.lifecycleStop, () => options.lifecycle.stop());
handle(IPC_CHANNELS.lifecycleRestart, () => options.lifecycle.restart());
handle(IPC_CHANNELS.lifecycleStatus, () => options.coordinator.run('status', signal => options.lifecycle.status(signal)));
handle(IPC_CHANNELS.lifecycleStart, () => options.coordinator.run('start', signal => options.lifecycle.start(signal)));
handle(IPC_CHANNELS.lifecycleStop, () => options.coordinator.run('stop', signal => options.lifecycle.stop(signal)));
handle(IPC_CHANNELS.lifecycleRestart, () => options.coordinator.run('restart', signal => options.lifecycle.restart(signal)));
handle(IPC_CHANNELS.discovery, () => []);
handle(IPC_CHANNELS.setupStatus, (_event, ...args) => {
if (args.length) throw new Error('Invalid local setup status request');
return options.setup.status();
});
handle(IPC_CHANNELS.setupStart, (_event, ...args) => {
if (args.length !== 1) throw new Error('Invalid local setup start request');
return options.coordinator.run('setup', signal => options.setup.start(args[0], signal));
});
handle(IPC_CHANNELS.setupRetry, (_event, ...args) => {
if (args.length > 1) throw new Error('Invalid local setup retry request');
return options.coordinator.run('setup', signal => options.setup.retry(args[0], signal));
});
handle(IPC_CHANNELS.setupCancel, (_event, ...args) => {
if (args.length) throw new Error('Invalid local setup cancellation request');
return options.coordinator.cancel(() => options.setup.cancel());
});
handle(IPC_CHANNELS.setupSelectPrivateKey, (_event, ...args) => {
if (args.length) throw new Error('Invalid private-key selection request');
return options.coordinator.run('setup', signal => options.setup.selectPrivateKey(signal));
});
handle(IPC_CHANNELS.setupAcquireWebhookSecret, (_event, ...args) => {
if (args.length) throw new Error('Invalid webhook-secret acquisition request');
return options.coordinator.run('setup', signal => options.setup.acquireWebhookSecret(signal));
});
};
26 changes: 26 additions & 0 deletions apps/desktop/src/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { LocalLifecycleController } from './lifecycle';

describe('desktop local lifecycle presentation boundary', () => {
it('keeps raw host diagnostics in main and returns only a fixed bounded status', async () => {
const diagnostics: unknown[] = [];
const controller = new LocalLifecycleController({
async running() { throw new Error('docker /home/alice/stack/.env TOKEN=sentinel'); },
async start() { throw new Error('HostConfig.Binds=/home/alice/stack'); },
async stop() {},
}, (_event, fields) => diagnostics.push(fields));
const status = await controller.status();
assert.equal(status.state, 'error');
assert.ok((status.detail?.length ?? 0) < 160);
assert.doesNotMatch(status.detail ?? '', /alice|HostConfig|TOKEN|sentinel/);
await assert.rejects(controller.start(), error => {
assert.ok(error instanceof Error);
assert.doesNotMatch(error.message, /alice|HostConfig|TOKEN|sentinel/);
return true;
});
assert.equal(diagnostics.length, 2);
assert.match(((diagnostics[0] as { error: Error }).error).message, /alice/);
assert.match(((diagnostics[1] as { error: Error }).error).message, /HostConfig/);
});
});
70 changes: 59 additions & 11 deletions apps/desktop/src/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,56 @@
import type { LocalLifecycleOperationResult, LocalLifecycleStatus } from './shared/contract';

/**
* Stable renderer-facing lifecycle boundary. Runtime installation and process
* control are deliberately absent until the user-approved setup work lands.
*/
export interface LocalLifecycleHost {
running(signal?: AbortSignal): Promise<boolean>;
start(signal?: AbortSignal): Promise<void>;
stop(signal?: AbortSignal): Promise<void>;
}

const lifecycleFailure = 'Local runtime operation failed. Review the protected desktop log for details.';

export class LocalLifecycleController {
#status: LocalLifecycleStatus = { state: 'disconnected' };
readonly #host?: LocalLifecycleHost;
readonly #diagnose?: (event: string, fields: Record<string, unknown>) => void;

status(): LocalLifecycleStatus {
constructor(host?: LocalLifecycleHost, diagnose?: (event: string, fields: Record<string, unknown>) => void) {
this.#host = host;
this.#diagnose = diagnose;
}

async status(signal?: AbortSignal): Promise<LocalLifecycleStatus> {
if (!this.#host) return { ...this.#status };
try {
this.#status = { state: await this.#host.running(signal) ? 'connected' : 'disconnected' };
} catch (error) {
this.#diagnose?.('desktop.lifecycle.status_failed', { error });
this.#status = { state: 'error', detail: lifecycleFailure };
}
return { ...this.#status };
}

start(): LocalLifecycleOperationResult {
return this.#unsupported();
async start(signal?: AbortSignal): Promise<LocalLifecycleOperationResult> {
return this.#operate('starting', 'connected', () => this.#host?.start(signal));
}

stop(): LocalLifecycleOperationResult {
return this.#unsupported();
async stop(signal?: AbortSignal): Promise<LocalLifecycleOperationResult> {
return this.#operate('stopping', 'disconnected', () => this.#host?.stop(signal));
}

restart(): LocalLifecycleOperationResult {
return this.#unsupported();
async restart(signal?: AbortSignal): Promise<LocalLifecycleOperationResult> {
if (!this.#host) return this.#unsupported();
this.#status = { state: 'stopping' };
try {
await this.#host.stop(signal);
this.#status = { state: 'starting' };
await this.#host.start(signal);
this.#status = { state: 'connected' };
return { ok: true, status: { ...this.#status } };
} catch (error) {
this.#diagnose?.('desktop.lifecycle.restart_failed', { error });
this.#status = { state: 'error', detail: lifecycleFailure };
throw new Error(lifecycleFailure);
}
}

async shutdown(): Promise<void> {
Expand All @@ -37,4 +67,22 @@ export class LocalLifecycleController {
},
};
}

async #operate(
transitional: 'starting' | 'stopping',
completed: 'connected' | 'disconnected',
operation: () => Promise<void> | undefined,
): Promise<LocalLifecycleOperationResult> {
if (!this.#host) return this.#unsupported();
this.#status = { state: transitional };
try {
await operation();
this.#status = { state: completed };
return { ok: true, status: { ...this.#status } };
} catch (error) {
this.#diagnose?.(`desktop.lifecycle.${transitional}_failed`, { error });
this.#status = { state: 'error', detail: lifecycleFailure };
throw new Error(lifecycleFailure);
}
}
}
Loading