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
6 changes: 6 additions & 0 deletions apps/app/electrobun/src/__tests__/kitchen-sink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,9 @@ vi.mock("../native/desktop", async () => {
checkForUpdates: vi.fn(() =>
Promise.resolve({
currentVersion: "1.0.0",
appBundlePath: "/Applications/Milady.app",
canAutoUpdate: true,
autoUpdateDisabledReason: null,
updateAvailable: false,
updateReady: false,
latestVersion: null,
Expand All @@ -511,6 +514,9 @@ vi.mock("../native/desktop", async () => {
getUpdaterState: vi.fn(() =>
Promise.resolve({
currentVersion: "1.0.0",
appBundlePath: "/Applications/Milady.app",
canAutoUpdate: true,
autoUpdateDisabledReason: null,
updateAvailable: false,
updateReady: false,
latestVersion: null,
Expand Down
11 changes: 11 additions & 0 deletions apps/app/electrobun/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1002,6 +1002,17 @@ async function _startAgent(win: BrowserWindow): Promise<void> {
async function setupUpdater(): Promise<void> {
const runUpdateCheck = async (notifyOnNoUpdate = false): Promise<void> => {
try {
const updaterState = await getDesktopManager().getUpdaterState();
if (!updaterState.canAutoUpdate) {
if (updaterState.autoUpdateDisabledReason) {
console.info(
"[Updater] Skipping auto-update check:",
updaterState.autoUpdateDisabledReason,
);
}
return;
}

const updateResult = await Updater.checkForUpdate();
if (updateResult?.updateAvailable) {
Updater.downloadUpdate().catch((err: unknown) => {
Expand Down
71 changes: 71 additions & 0 deletions apps/app/electrobun/src/native/__tests__/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
* - getMiladyDistFallbackCandidates: path resolution fallback list
*/

import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";

import {
configureDesktopLocalApiAuth,
ensureDesktopApiToken,
getMiladyDistFallbackCandidates,
inspectExistingElizaInstall,
resolveConfigDir,
} from "../agent";

Expand Down Expand Up @@ -161,3 +163,72 @@ describe("configureDesktopLocalApiAuth", () => {
expect(env.ELIZA_PAIRING_DISABLED).toBe("1");
});
});

describe("inspectExistingElizaInstall", () => {
it("detects the default ~/.eliza config when it exists", async () => {
const homeDir = path.join(process.cwd(), "tmp-home-default");
const stateDir = path.join(homeDir, ".eliza");
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(
path.join(stateDir, "eliza.json"),
JSON.stringify({ agents: { list: [{ id: "main" }] } }),
);

const result = inspectExistingElizaInstall({
env: {},
homedir: homeDir,
});

expect(result.detected).toBe(true);
expect(result.stateDir).toBe(stateDir);
expect(result.configPath).toBe(path.join(stateDir, "eliza.json"));
expect(result.configExists).toBe(true);
expect(result.source).toBe("default-state-dir");

fs.rmSync(homeDir, { recursive: true, force: true });
});

it("detects an env-overridden state dir even without eliza.json when it has state entries", async () => {
const homeDir = path.join(process.cwd(), "tmp-home-env-state");
const stateDir = path.join(homeDir, "custom-state");
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(path.join(stateDir, "skills.json"), JSON.stringify({}));

const result = inspectExistingElizaInstall({
env: {
ELIZA_STATE_DIR: stateDir,
},
homedir: homeDir,
});

expect(result.detected).toBe(true);
expect(result.stateDir).toBe(stateDir);
expect(result.configPath).toBe(path.join(stateDir, "eliza.json"));
expect(result.configExists).toBe(false);
expect(result.hasStateEntries).toBe(true);
expect(result.source).toBe("state-dir-env");

fs.rmSync(homeDir, { recursive: true, force: true });
});

it("prefers an explicit config path when provided", async () => {
const homeDir = path.join(process.cwd(), "tmp-home-config-path");
const configPath = path.join(homeDir, "profiles", "legacy.json");
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({}));

const result = inspectExistingElizaInstall({
env: {
MILADY_CONFIG_PATH: configPath,
},
homedir: homeDir,
});

expect(result.detected).toBe(true);
expect(result.configPath).toBe(configPath);
expect(result.stateDir).toBe(path.dirname(configPath));
expect(result.source).toBe("config-path-env");

fs.rmSync(homeDir, { recursive: true, force: true });
});
});
44 changes: 44 additions & 0 deletions apps/app/electrobun/src/native/__tests__/desktop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ import * as electrobunBun from "electrobun/bun";
import { DesktopManager, resetDesktopManagerForTesting } from "../desktop";
import * as macEffects from "../mac-window-effects";

const ORIGINAL_EXEC_PATH = process.execPath;
const mockExistsSync = nodeFs.existsSync as ReturnType<typeof vi.fn>;
const mockWriteFileSync = nodeFs.writeFileSync as ReturnType<typeof vi.fn>;
const mockMkdirSync = nodeFs.mkdirSync as ReturnType<typeof vi.fn>;
Expand All @@ -218,6 +219,9 @@ const mockShowContextMenu = electrobunBun.ContextMenu
const mockBuildConfigGet = electrobunBun.BuildConfig.get as ReturnType<
typeof vi.fn
>;
const mockUpdaterApplyUpdate = electrobunBun.Updater.applyUpdate as ReturnType<
typeof vi.fn
>;
const mockSessionFromPartition = electrobunBun.Session
.fromPartition as ReturnType<typeof vi.fn>;
const mockBrowserView = electrobunBun.BrowserView as ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -253,6 +257,13 @@ function setPlatform(platform: string) {
});
}

function setExecPath(execPath: string) {
Object.defineProperty(process, "execPath", {
value: execPath,
configurable: true,
});
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -280,6 +291,7 @@ describe("DesktopManager", () => {
availableRenderers: ["native"],
bunVersion: "1.2.3",
});
mockUpdaterApplyUpdate.mockReset();
mockSessionFromPartition
.mockReset()
.mockImplementation((partition: string) => ({
Expand All @@ -301,6 +313,7 @@ describe("DesktopManager", () => {
resetDesktopManagerForTesting();
// Restore platform to darwin (test host)
setPlatform("darwin");
setExecPath(ORIGINAL_EXEC_PATH);
delete process.env.NODE_ENV;
delete process.env.ELECTROBUN_DEV;
vi.useRealTimers();
Expand Down Expand Up @@ -566,6 +579,37 @@ describe("DesktopManager", () => {

expect(mockSetDockIconVisible).toHaveBeenCalledWith(false);
});

it("reports auto-updates as unavailable outside Applications on macOS", async () => {
setPlatform("darwin");
setExecPath("/Volumes/Milady/Milady.app/Contents/MacOS/Milady");

const snapshot = await manager.getUpdaterState();

expect(snapshot.appBundlePath).toBe("/Volumes/Milady/Milady.app");
expect(snapshot.canAutoUpdate).toBe(false);
expect(snapshot.autoUpdateDisabledReason).toContain(
"Move Milady.app to /Applications",
);
});

it("blocks applying updates outside Applications on macOS", async () => {
setPlatform("darwin");
setExecPath("/Volumes/Milady/Milady.app/Contents/MacOS/Milady");

await expect(manager.applyUpdate()).rejects.toThrow(
"Move Milady.app to /Applications",
);
expect(mockUpdaterApplyUpdate).not.toHaveBeenCalled();
});

it("allows applying updates from Applications on macOS", async () => {
setPlatform("darwin");
setExecPath("/Applications/Milady.app/Contents/MacOS/Milady");

await expect(manager.applyUpdate()).resolves.toBeUndefined();
expect(mockUpdaterApplyUpdate).toHaveBeenCalledTimes(1);
});
});

// ── setAutoLaunch — macOS ─────────────────────────────────────────────────
Expand Down
134 changes: 134 additions & 0 deletions apps/app/electrobun/src/native/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ interface AgentStatus {
error: string | null;
}

type ExistingElizaInstallSource =
| "config-path-env"
| "state-dir-env"
| "default-state-dir";

export interface ExistingElizaInstallInfo {
detected: boolean;
stateDir: string;
configPath: string;
configExists: boolean;
stateDirExists: boolean;
hasStateEntries: boolean;
source: ExistingElizaInstallSource;
}

type SendToWebview = (message: string, payload?: unknown) => void;

// Subprocess type from Bun.spawn
Expand All @@ -49,6 +64,7 @@ type BunSubprocess = ReturnType<typeof Bun.spawn>;
const HEALTH_POLL_INTERVAL_MS = 500;
const SIGTERM_GRACE_MS = 5_000;
const WINDOWS_ABS_PATH_RE = /^[A-Za-z]:[\\/]/;
const ELIZA_CONFIG_FILENAME = "eliza.json";

export function getHealthPollTimeoutMs(
env: NodeJS.ProcessEnv = process.env,
Expand Down Expand Up @@ -96,6 +112,120 @@ function resolveRelativePortable(base: string, relativePath: string): string {
: path.resolve(base, relativePath);
}

function normalizeEnvPath(value: string | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? resolvePortablePath(trimmed) : null;
}

function listStateEntries(stateDir: string): string[] {
try {
return fs
.readdirSync(stateDir)
.filter(
(entry) => entry !== "." && entry !== ".." && entry !== ".DS_Store",
);
} catch {
return [];
}
}

function buildExistingElizaInstallCandidates(opts?: {
env?: NodeJS.ProcessEnv;
homedir?: string;
}): Array<{
source: ExistingElizaInstallSource;
stateDir: string;
configPath: string;
}> {
const env = opts?.env ?? process.env;
const homedir = opts?.homedir ?? os.homedir();
const configPathFromEnv =
normalizeEnvPath(env.MILADY_CONFIG_PATH) ??
normalizeEnvPath(env.ELIZA_CONFIG_PATH);
const stateDirFromEnv =
normalizeEnvPath(env.MILADY_STATE_DIR) ??
normalizeEnvPath(env.ELIZA_STATE_DIR);
const defaultStateDir = joinPortable(homedir, ".eliza");

const candidates = [
configPathFromEnv
? {
source: "config-path-env" as const,
stateDir: dirnamePortable(configPathFromEnv),
configPath: configPathFromEnv,
}
: null,
stateDirFromEnv
? {
source: "state-dir-env" as const,
stateDir: stateDirFromEnv,
configPath: joinPortable(stateDirFromEnv, ELIZA_CONFIG_FILENAME),
}
: null,
{
source: "default-state-dir" as const,
stateDir: defaultStateDir,
configPath: joinPortable(defaultStateDir, ELIZA_CONFIG_FILENAME),
},
].filter((candidate): candidate is NonNullable<typeof candidate> =>
Boolean(candidate),
);

return candidates.filter(
(candidate, index, all) =>
all.findIndex(
(other) =>
other.stateDir === candidate.stateDir &&
other.configPath === candidate.configPath,
) === index,
);
}

export function inspectExistingElizaInstall(opts?: {
env?: NodeJS.ProcessEnv;
homedir?: string;
}): ExistingElizaInstallInfo {
const candidates = buildExistingElizaInstallCandidates(opts);

for (const candidate of candidates) {
const configExists = fs.existsSync(candidate.configPath);
const stateDirExists = fs.existsSync(candidate.stateDir);
const hasStateEntries =
stateDirExists && listStateEntries(candidate.stateDir).length > 0;

if (configExists || hasStateEntries) {
return {
detected: true,
stateDir: candidate.stateDir,
configPath: candidate.configPath,
configExists,
stateDirExists,
hasStateEntries,
source: candidate.source,
};
}
}

const fallback = candidates[0] ?? {
source: "default-state-dir" as const,
stateDir: joinPortable(opts?.homedir ?? os.homedir(), ".eliza"),
configPath: joinPortable(
joinPortable(opts?.homedir ?? os.homedir(), ".eliza"),
ELIZA_CONFIG_FILENAME,
),
};

return {
detected: false,
stateDir: fallback.stateDir,
configPath: fallback.configPath,
configExists: false,
stateDirExists: fs.existsSync(fallback.stateDir),
hasStateEntries: false,
source: fallback.source,
};
}

// ---------------------------------------------------------------------------
// Diagnostic logging
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -912,6 +1042,10 @@ export class AgentManager {
return { ...this.status };
}

inspectExistingInstall(): ExistingElizaInstallInfo {
return inspectExistingElizaInstall();
}

getPort(): number | null {
return this.status.port;
}
Expand Down
Loading
Loading