Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/desktop/scripts/build-main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ await build({
"src/main/main.ts",
"src/main/preload.ts",
"src/main/agent-startup.ts",
"src/main/daemon-lifecycle.ts",
"src/main/deep-link.ts",
"src/main/editor-url.ts",
"src/main/electron-update-backend.ts",
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src/main/daemon-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export function connectCliEnvironment(
packaged: boolean,
environment: NodeJS.ProcessEnv = process.env
): NodeJS.ProcessEnv {
if (!packaged) return environment;
const sanitized = { ...environment };
delete sanitized.MDBASE_CONNECT_HOME;
delete sanitized.MDBASE_CONNECT_SOCKET;
return sanitized;
}

export function daemonCliArguments(
packaged: boolean,
stateDirectory: string,
endpoint: string,
command: string[],
json = false
): string[] {
return [
...(packaged ? [] : ["--state-dir", stateDirectory, "--endpoint", endpoint]),
...(json ? ["--json"] : []),
"connect",
"daemon",
...command
];
}
58 changes: 23 additions & 35 deletions apps/desktop/src/main/electron-update-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from "./update-policy";
import type { UpdateTransaction } from "./update-state";
import { artifactMatches, downloadArtifact, downloadBytes } from "./update-download";
import { connectCliEnvironment, daemonCliArguments } from "./daemon-lifecycle";

const execFile = promisify(execFileCallback);
const AUTO_UPDATER_TIMEOUT_MS = 180_000;
Expand Down Expand Up @@ -61,11 +62,7 @@ export class ElectronUpdateBackend implements UpdateBackend {
const status = await this.daemonStatus();
const needsReconciliation = runtimeNeedsReconciliation(status, this.currentVersion);
if (!needsReconciliation) return null;
await this.activateRuntime(
this.options.binaryPath(),
this.currentVersion,
status.installed
);
await this.activateRuntime(this.options.binaryPath(), this.currentVersion);
return `Connector runtime ${this.currentVersion} was reconciled with this application.`;
}

Expand Down Expand Up @@ -144,7 +141,7 @@ export class ElectronUpdateBackend implements UpdateBackend {
async stopDaemon(): Promise<void> {
const status = await this.daemonStatus();
if (!status.running) return;
await this.runCli(this.options.binaryPath(), ["connect", "daemon", "stop"], 35_000);
await this.runCli(this.options.binaryPath(), ["stop"], 35_000);
}

installAutomatic(): void {
Expand Down Expand Up @@ -175,8 +172,7 @@ export class ElectronUpdateBackend implements UpdateBackend {
try {
await this.activateRuntime(
this.options.binaryPath(),
transaction.target_version,
transaction.service_installed
transaction.target_version
);
return {
healthy: true,
Expand All @@ -187,8 +183,7 @@ export class ElectronUpdateBackend implements UpdateBackend {
if (!transaction.previous_runtime) throw error;
await this.activateRuntime(
transaction.previous_runtime,
transaction.previous_version,
transaction.service_installed
transaction.previous_version
);
return {
healthy: true,
Expand All @@ -202,8 +197,7 @@ export class ElectronUpdateBackend implements UpdateBackend {
if (runningPrevious) {
await this.activateRuntime(
this.options.binaryPath(),
transaction.previous_version,
transaction.service_installed
transaction.previous_version
);
return {
healthy: true,
Expand All @@ -219,8 +213,7 @@ export class ElectronUpdateBackend implements UpdateBackend {
}
await this.activateRuntime(
transaction.previous_runtime,
transaction.previous_version,
transaction.service_installed
transaction.previous_version
);
return {
healthy: true,
Expand All @@ -231,18 +224,13 @@ export class ElectronUpdateBackend implements UpdateBackend {

private async activateRuntime(
binary: string,
expectedVersion: string,
serviceInstalled: boolean
expectedVersion: string
): Promise<void> {
const current = await this.daemonStatus().catch(() => ({ installed: serviceInstalled, running: false }));
const current = await this.daemonStatus().catch(() => ({ running: false }));
if (current.running) {
await this.runCli(binary, ["connect", "daemon", "stop"], 35_000).catch(() => undefined);
await this.runCli(binary, ["stop"], 35_000).catch(() => undefined);
}
await this.runCli(
binary,
["connect", "daemon", serviceInstalled ? "install" : "start"],
35_000
);
await this.runCli(binary, ["install"], 35_000);
const deadline = Date.now() + 30_000;
let lastVersion: string | undefined;
while (Date.now() < deadline) {
Expand All @@ -263,7 +251,7 @@ export class ElectronUpdateBackend implements UpdateBackend {
running: boolean;
binaryVersion?: string;
}> {
const value = await this.runCli(binary, ["connect", "daemon", "status"], 10_000);
const value = await this.runCli(binary, ["status"], 10_000);
return {
installed: value.installed === true,
running: value.running === true,
Expand All @@ -284,15 +272,18 @@ export class ElectronUpdateBackend implements UpdateBackend {
): Promise<Record<string, unknown>> {
const { stdout } = await execFile(
binary,
[
"--state-dir",
daemonCliArguments(
this.packaged,
this.options.stateDirectory(),
"--endpoint",
this.options.endpoint(),
"--json",
...command
],
{ env: process.env, timeout, windowsHide: true }
command,
true
),
{
env: connectCliEnvironment(this.packaged),
timeout,
windowsHide: true
}
);
const parsed = JSON.parse(stdout) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
Expand All @@ -306,10 +297,7 @@ export function runtimeNeedsReconciliation(
status: { installed: boolean; running: boolean; binaryVersion?: string },
currentVersion: string
): boolean {
return (
(status.running && status.binaryVersion !== currentVersion) ||
(!status.running && status.installed)
);
return !status.installed || !status.running || status.binaryVersion !== currentVersion;
}

async function stageMacUpdate(
Expand Down
16 changes: 7 additions & 9 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { hostname } from "node:os";
import { promisify } from "node:util";
import { ensureAgentReady, type AgentPing } from "./agent-startup";
import { AgentControlError, requestAgent } from "./control-client";
import { connectCliEnvironment, daemonCliArguments } from "./daemon-lifecycle";
import { routeForDeepLink, shouldRegisterDeepLinks } from "./deep-link";
import { buildEditorUrl } from "./editor-url";
import { ElectronUpdateBackend } from "./electron-update-backend";
Expand Down Expand Up @@ -72,7 +73,7 @@ async function resolveDaemonPaths(): Promise<void> {
const binary = connectBinary();
if (!existsSync(binary)) throw new Error(`Connector runtime is missing: ${binary}`);
const { stdout } = await execFile(binary, ["--json", "connect", "paths"], {
env: process.env,
env: connectCliEnvironment(app.isPackaged),
timeout: 10_000,
windowsHide: true
});
Expand Down Expand Up @@ -124,17 +125,14 @@ async function startAgent(): Promise<void> {
await mkdir(stateDirectory(), { recursive: true });
await execFile(
binary,
[
"--state-dir",
daemonCliArguments(
app.isPackaged,
stateDirectory(),
"--endpoint",
controlEndpoint(),
"connect",
"daemon",
"start"
],
["start"]
),
{
env: process.env,
env: connectCliEnvironment(app.isPackaged),
timeout: 30_000,
windowsHide: true
}
Expand Down
61 changes: 61 additions & 0 deletions apps/desktop/test/daemon-lifecycle.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import test from "node:test";

const require = createRequire(import.meta.url);
const {
connectCliEnvironment,
daemonCliArguments
} = require("../dist/main/daemon-lifecycle.js");

test("packaged daemon commands target the installed service", () => {
assert.deepEqual(
daemonCliArguments(
true,
"/tmp/isolated-state",
"/tmp/isolated.sock",
["start"]
),
["connect", "daemon", "start"]
);
assert.deepEqual(
daemonCliArguments(
true,
"/tmp/isolated-state",
"/tmp/isolated.sock",
["status"],
true
),
["--json", "connect", "daemon", "status"]
);
});

test("development daemon commands retain their isolated profile", () => {
assert.deepEqual(
daemonCliArguments(
false,
"/tmp/isolated-state",
"/tmp/isolated.sock",
["start"]
),
[
"--state-dir",
"/tmp/isolated-state",
"--endpoint",
"/tmp/isolated.sock",
"connect",
"daemon",
"start"
]
);
});

test("packaged CLI calls cannot inherit isolated-profile selectors", () => {
const environment = {
PATH: "/usr/bin",
MDBASE_CONNECT_HOME: "/tmp/isolated-state",
MDBASE_CONNECT_SOCKET: "/tmp/isolated.sock"
};
assert.deepEqual(connectCliEnvironment(true, environment), { PATH: "/usr/bin" });
assert.equal(connectCliEnvironment(false, environment), environment);
});
16 changes: 15 additions & 1 deletion apps/desktop/test/mac-update-feed.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,21 @@ test("loopback feed rejects invalid methods and ranges", async (context) => {
assert.throws(() => parseRange("bytes=2-1", 10), /Invalid update range/);
});

test("runtime reconciliation covers stale and stopped services without restarting a match", () => {
test("runtime reconciliation installs missing services and repairs stale or stopped services", () => {
assert.equal(
runtimeNeedsReconciliation(
{ installed: false, running: false },
"0.1.0-beta.9"
),
true
);
assert.equal(
runtimeNeedsReconciliation(
{ installed: false, running: true, binaryVersion: "0.1.0-beta.9" },
"0.1.0-beta.9"
),
true
);
assert.equal(
runtimeNeedsReconciliation(
{ installed: true, running: true, binaryVersion: "0.1.0-beta.8" },
Expand Down
8 changes: 4 additions & 4 deletions config/architecture-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"apps/editor/src/TypeBrowser.tsx": 1998
},
"productionFileBudgetsByPackage": {
"apps/desktop": 39,
"apps/desktop": 40,
"apps/editor": 110,
"apps/portal": 16,
"crates/connect-agent": 34,
Expand Down Expand Up @@ -43,11 +43,11 @@
"semanticProjectionFormatVersion": 6
},
"reviewBudgets": {
"productionFiles": 676,
"relativeImports": 1433,
"productionFiles": 677,
"relativeImports": 1435,
"workspacePackages": 24,
"rustPublicDeclarations": 3163,
"typeScriptExportDeclarations": 2399,
"typeScriptExportDeclarations": 2400,
"mdbaseCollectionReferences": 16,
"typedCollectionReferences": 1
}
Expand Down
8 changes: 8 additions & 0 deletions docs/code-quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ references, 2,292 TypeScript export references, 16 `mdbase::Collection`
references, and one `TypedCollection` reference. These checks are architectural
alarms rather than substitutes for review.

The installed-desktop daemon fix adds one 26-line `daemon-lifecycle.ts` module
shared by startup and update recovery. Its two internal exports and two imports
replace duplicated CLI profile selection, keeping packaged default-service
selection and development isolation at one boundary. The reviewed limits are
40 desktop files, 677 production files, 1,435 relative imports and 2,400
TypeScript exports. No package, file-size or cycle limit changes, new service,
public protocol, or alternate daemon lifecycle is introduced.

Composition roots and package facades should approach these end-state shapes:

- server `app.ts`: registration and lifecycle wiring only;
Expand Down