diff --git a/App/shell/desktop/electron-builder.unsigned.yml b/App/shell/desktop/electron-builder.unsigned.yml index 5059d69d..e24ae64a 100644 --- a/App/shell/desktop/electron-builder.unsigned.yml +++ b/App/shell/desktop/electron-builder.unsigned.yml @@ -33,6 +33,12 @@ extraResources: to: memory-runtime filter: - "**/*" + # electron-builder excludes a matcher-root node_modules directory. Copy it + # through its own matcher so the standalone Memory runtime stays complete. + - from: dist/runtime/memory/node_modules + to: memory-runtime/node_modules + filter: + - "**/*" - from: dist/runtime/bin to: cli filter: diff --git a/App/shell/desktop/electron-builder.yml b/App/shell/desktop/electron-builder.yml index 8528a3cd..20ae7ee9 100644 --- a/App/shell/desktop/electron-builder.yml +++ b/App/shell/desktop/electron-builder.yml @@ -33,6 +33,12 @@ extraResources: to: memory-runtime filter: - "**/*" + # electron-builder excludes a matcher-root node_modules directory. Copy it + # through its own matcher so the standalone Memory runtime stays complete. + - from: dist/runtime/memory/node_modules + to: memory-runtime/node_modules + filter: + - "**/*" - from: dist/runtime/bin to: cli filter: diff --git a/App/shell/desktop/src/main/runtime-services.ts b/App/shell/desktop/src/main/runtime-services.ts index 8dd60651..308f35be 100644 --- a/App/shell/desktop/src/main/runtime-services.ts +++ b/App/shell/desktop/src/main/runtime-services.ts @@ -1316,7 +1316,9 @@ export function resolveRuntimeEntryPaths(options: StartManagedRuntimeServicesOpt return { ...options.runtimeEntries }; } return { - memoryEntry: join(options.appPath, "dist/runtime/memory/dist/src/server/index.js"), + memoryEntry: options.offlineMemoryRuntimeDirectory + ? join(options.offlineMemoryRuntimeDirectory, "dist/src/server/index.js") + : join(options.appPath, "dist/runtime/memory/dist/src/server/index.js"), agentEntry: join(options.appPath, "dist/runtime/memmy-agent/dist/main.js") }; } @@ -1547,6 +1549,7 @@ function isPackagedMemoryServiceProcess(pid: number, memoryEntry: string): boole const normalizedCommand = command.replaceAll("\\", "/"); const normalizedEntry = resolve(memoryEntry).replaceAll("\\", "/"); return normalizedCommand.includes(normalizedEntry) + || normalizedCommand.includes("/memory-runtime/dist/src/server/index.js") || normalizedCommand.includes("/dist/runtime/memory/src/server/index.js"); } catch { return false; diff --git a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts index e1beea7a..8eb307dd 100644 --- a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts +++ b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts @@ -368,6 +368,39 @@ describe("desktop packaged runtime boundaries", () => { } }); + it("ships the standalone Memory runtime with production dependencies on macOS", () => { + for (const configPath of [electronBuilderPath, unsignedElectronBuilderPath]) { + const config = parseYaml(readFileSync(configPath, "utf8")) as { + extraResources?: Array<{ from?: string; to?: string; filter?: string[] }>; + }; + expect(config.extraResources).toContainEqual({ + from: "dist/runtime/memory", + to: "memory-runtime", + filter: ["**/*"] + }); + expect(config.extraResources).toContainEqual({ + from: "dist/runtime/memory/node_modules", + to: "memory-runtime/node_modules", + filter: ["**/*"] + }); + } + + const macSource = readFileSync(packageMacDmgPath, "utf8"); + expect(macSource).toContain( + 'packaged_memory_runtime="$app_path/Contents/Resources/memory-runtime"' + ); + expect(macSource).toContain( + '$packaged_memory_runtime/node_modules/onnxruntime-node/bin/napi-v3/darwin/$target_cpu/libonnxruntime*.dylib' + ); + expect(macSource).not.toContain( + '$unpacked_runtime/memory/node_modules/onnxruntime-node/bin/napi-v3/darwin/$target_cpu/libonnxruntime*.dylib' + ); + const asarGuardSource = readFileSync(verifyPackagedAsarPath, "utf8"); + expect(asarGuardSource).toContain( + 'if (platform === "win32") {\n requiredFiles.push(\n "dist/runtime/memory/package.json"' + ); + }); + it("excludes dependency root tests and docs from every desktop app archive", () => { for (const configPath of [ electronBuilderPath, diff --git a/Memory/src/cli/load-env.ts b/Memory/src/cli/load-env.ts index fc7bd4de..c602e5c1 100644 --- a/Memory/src/cli/load-env.ts +++ b/Memory/src/cli/load-env.ts @@ -55,7 +55,7 @@ export function loadCloudServiceEnv(options: { const moduleDir = options.moduleDir ?? dirname(fileURLToPath(import.meta.url)); const packagedRuntime = isPackagedRuntimeModule(moduleDir); if (options.manifestPath !== undefined || packagedRuntime) { - const manifestPath = options.manifestPath ?? resolve(moduleDir, "../../../../main/desktop-edition.json"); + const manifestPath = options.manifestPath ?? packagedManifestPath(moduleDir); if (!existsSync(manifestPath)) { throw new Error("Packaged desktop runtime manifest is missing"); } @@ -77,7 +77,17 @@ export function loadCloudServiceEnv(options: { } function isPackagedRuntimeModule(moduleDir: string): boolean { - return resolve(moduleDir).replace(/\\/g, "/").endsWith("/dist/runtime/memory/src/cli"); + const normalized = resolve(moduleDir).replace(/\\/g, "/"); + return normalized.endsWith("/dist/runtime/memory/src/cli") + || normalized.endsWith("/memory-runtime/dist/src/cli"); +} + +function packagedManifestPath(moduleDir: string): string { + const normalized = resolve(moduleDir).replace(/\\/g, "/"); + if (normalized.endsWith("/memory-runtime/dist/src/cli")) { + return resolve(moduleDir, "../../../../app.asar/dist/main/desktop-edition.json"); + } + return resolve(moduleDir, "../../../../main/desktop-edition.json"); } loadCloudServiceEnv(); diff --git a/Memory/src/model/agent-region.ts b/Memory/src/model/agent-region.ts index a4f7590d..54cec45f 100644 --- a/Memory/src/model/agent-region.ts +++ b/Memory/src/model/agent-region.ts @@ -16,6 +16,10 @@ export interface ResolveMemoryAgentRegionOptions { export function packagedDesktopEditionManifestPath( modelDirectory = import.meta.dirname ): string { + const normalized = resolve(modelDirectory).replace(/\\/g, "/"); + if (normalized.includes("/memory-runtime/dist/")) { + return resolve(modelDirectory, "../../../../app.asar/dist/main/desktop-edition.json"); + } return resolve(modelDirectory, "../../../../main/desktop-edition.json"); } diff --git a/Memory/tests/llm-agent-region.test.ts b/Memory/tests/llm-agent-region.test.ts index b315c446..24d94ea6 100644 --- a/Memory/tests/llm-agent-region.test.ts +++ b/Memory/tests/llm-agent-region.test.ts @@ -29,6 +29,14 @@ describe("memory account model agent region", () => { ); }); + it("locates the desktop manifest beside the app ASAR for standalone Memory", () => { + const modelDirectory = resolve("app-root/Resources/memory-runtime/dist/src/model"); + + expect(packagedDesktopEditionManifestPath(modelDirectory)).toBe( + resolve("app-root/Resources/app.asar/dist/main/desktop-edition.json") + ); + }); + it.each([ [{ accountChannel: "email" }, "intl"], [{ accountChannel: "phone" }, "cn"], diff --git a/Memory/tests/load-env.test.ts b/Memory/tests/load-env.test.ts index d7f64d16..468d35cb 100644 --- a/Memory/tests/load-env.test.ts +++ b/Memory/tests/load-env.test.ts @@ -58,6 +58,19 @@ describe("packaged memmy-memory cloud-service loading", () => { expect(env.MEMMY_CLOUD_SERVICE).toBe("https://dev.example.test"); }); + it("loads the desktop manifest from the app ASAR for the standalone runtime", () => { + const root = fixtureRoot(); + const moduleDir = join(root, "Resources", "memory-runtime", "dist", "src", "cli"); + const manifestPath = join(root, "Resources", "app.asar", "dist", "main", "desktop-edition.json"); + mkdirSync(moduleDir, { recursive: true }); + mkdirSync(join(root, "Resources", "app.asar", "dist", "main"), { recursive: true }); + writeFileSync(manifestPath, JSON.stringify({ cloudService: "https://standalone.example.test" })); + + const env: NodeJS.ProcessEnv = {}; + expect(loadCloudServiceEnv({ cwd: root, moduleDir, env })).toBe(manifestPath); + expect(env.MEMMY_CLOUD_SERVICE).toBe("https://standalone.example.test"); + }); + it("fails closed for an invalid packaged manifest", () => { const root = fixtureRoot(); const manifestPath = join(root, "desktop-edition.json"); diff --git a/scripts/internal/mac/build-dmg.sh b/scripts/internal/mac/build-dmg.sh index abc0368e..bacedd07 100755 --- a/scripts/internal/mac/build-dmg.sh +++ b/scripts/internal/mac/build-dmg.sh @@ -126,7 +126,22 @@ resolve_microphone_usage_description() { create_cli_launcher() { local output_path="$1" - local asar_entry="$2" + local entry_path="$2" + local entry_kind="${3:-asar}" + local entry_expression + + case "$entry_kind" in + asar) + entry_expression='"$RESOURCES_DIR/app.asar/'"$entry_path"'"' + ;; + resource) + entry_expression='"$RESOURCES_DIR/memory-runtime/'"$entry_path"'"' + ;; + *) + echo "Unsupported CLI launcher entry kind: $entry_kind" >&2 + exit 1 + ;; + esac cat > "$output_path" < /(^|\/)\.env(?:$|\.)/u.test(entry))) { const requiredFiles = [ "dist/main/desktop-edition.json", "package.json", - "dist/runtime/memory/package.json", "dist/runtime/memmy-agent/package.json", "dist/runtime/memmy-agent/node_modules/@memmy/local-api-contracts/dist/index.js", "node_modules/@memmy/backend/dist/src/adapters/outbound/skill-writer/workspace-bridge/memmy-workspace-bridge.mjs", ]; if (platform === "win32") { requiredFiles.push( + "dist/runtime/memory/package.json", `dist/runtime/memory/node_modules/onnxruntime-node/bin/napi-v3/${platform}/${arch}/onnxruntime_binding.node`, `dist/runtime/memory/node_modules/onnxruntime-node/bin/napi-v3/${platform}/${arch}/onnxruntime.dll`, "dist/runtime/memmy-agent/dist/main.js.map", @@ -55,13 +55,18 @@ if (platform === "win32") { } } -for (const [file, lock] of [ +const versionedFiles = [ ["package.json", false], - ["dist/runtime/memory/package.json", false], - ["dist/runtime/memory/package-lock.json", true], ["dist/runtime/memmy-agent/package.json", false], ["dist/runtime/memmy-agent/package-lock.json", true], -]) { +]; +if (platform === "win32") { + versionedFiles.splice(1, 0, + ["dist/runtime/memory/package.json", false], + ["dist/runtime/memory/package-lock.json", true], + ); +} +for (const [file, lock] of versionedFiles) { // electron-builder excludes npm lockfiles by default. The staged-runtime // version guard validates them before packaging; re-check any that are kept. if (lock && !entrySet.has(file)) continue;