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
41 changes: 40 additions & 1 deletion desktop/installer/cat-cafe.iss
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,26 @@ Filename: "powershell.exe"; \
StatusMsg: "Generating desktop configuration..."; \
Flags: runhidden waituntilterminated

; Offer to launch after install
; Offer to launch after interactive install
Filename: "{app}\desktop-dist\{#MyAppExeName}"; \
Description: "Launch {#MyAppName}"; Flags: postinstall nowait skipifsilent
; F258: Auto-restart after silent upgrade (in-app updater uses /SILENT).
; runasoriginaluser is critical — without it, the app + Redis + API all run
; as elevated admin, which breaks user-data paths and is a security concern.
Filename: "{app}\desktop-dist\{#MyAppExeName}"; \
Flags: nowait runasoriginaluser; Check: WizardSilent

; ── F258: Clean previous version's tar-extracted dirs before upgrade ───
; These are NOT tracked by Inno's file registry (created by tar.exe in [Run]).
; Without this, old module files / deleted packages persist across upgrades —
; an extremely hard-to-debug class of staleness bugs.
; The junction must be removed first (rmdir only deletes the link, not target).
; Recovery: if install fails after delete, rerunning the installer restores everything.
[InstallDelete]
Type: filesandordirs; Name: "{app}\scripts\node_modules"
Type: filesandordirs; Name: "{app}\packages"
Type: filesandordirs; Name: "{app}\desktop-dist"
Type: filesandordirs; Name: "{app}\node"

[UninstallRun]
Filename: "powershell.exe"; \
Expand All @@ -213,3 +230,25 @@ Type: filesandordirs; Name: "{app}\node"
Type: filesandordirs; Name: "{app}\bundled"
; scripts/node_modules junction created by mklink /J in [Run]
Type: filesandordirs; Name: "{app}\scripts\node_modules"

; ── F258: Defensive process cleanup before upgrade ────────────────────
; The in-app updater calls quitApp() → stopAll() before spawning the installer,
; but if stopAll() times out or the user manually reruns Setup.exe, child
; processes (node, redis-server) may still hold file locks in {app}\.
; PrepareToInstall kills ONLY processes whose executable path is under {app} —
; no risk of killing the user's own node/redis instances running elsewhere.
[Code]
function PrepareToInstall(var NeedsRestart: Boolean): String;
var
ExitCode: Integer;
Cmd: String;
begin
Result := '';
NeedsRestart := False;
Cmd := 'Get-Process | Where-Object { $_.Path -and $_.Path.StartsWith(''' +
ExpandConstant('{app}') +
''') } | Stop-Process -Force -ErrorAction SilentlyContinue';
Exec('powershell.exe',
'-NoProfile -ExecutionPolicy Bypass -Command "' + Cmd + '"',
'', SW_HIDE, ewWaitUntilTerminated, ExitCode);
end;
84 changes: 79 additions & 5 deletions desktop/main.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// Clowder AI Desktop — Electron main process
// Launches backend services (Redis, API, Web) then shows the web UI.

const { app, BrowserWindow, Menu, Tray, dialog } = require('electron');
const { app, BrowserWindow, Menu, Tray, dialog, net, shell, Notification } = require('electron');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const { resolveProjectRootFromDir } = require('./project-root');
const ServiceManager = require('./service-manager');
const UpdateManager = require('./update-manager');

// macOS install-location guard.
//
Expand Down Expand Up @@ -68,10 +69,9 @@ const FRONTEND_PORT = 3003;
const API_PORT = 3004;
const APP_URL = `http://localhost:${FRONTEND_PORT}`;
// Main process log in the user data directory alongside API + desktop logs.
const IS_MAC_MAIN = process.platform === 'darwin';
const userDataRoot = IS_MAC_MAIN
? path.join(process.env.HOME || os.homedir(), 'Library', 'Application Support', 'Clowder AI')
: path.join(process.env.LOCALAPPDATA || path.join(process.env.USERPROFILE || '', 'AppData', 'Local'), 'Clowder AI');
// Single source of truth: service-manager.js resolveUserDataDir() reads
// electron-builder productName and handles legacy data directory migration.
const userDataRoot = ServiceManager.USER_DATA_DIR;
const mainLogDir = path.join(userDataRoot, 'data', 'logs');
try {
fs.mkdirSync(mainLogDir, { recursive: true });
Expand All @@ -92,6 +92,7 @@ let mainWindow = null;
let splashWindow = null;
let tray = null;
let services = null;
let updater = null;
let isQuitting = false;

function createSplashWindow() {
Expand Down Expand Up @@ -152,6 +153,32 @@ function createMainWindow() {
});
}

function showAboutDialog() {
const version = app.getVersion();
dialog
.showMessageBox({
type: 'info',
buttons: ['Check for Updates', 'OK'],
defaultId: 1,
cancelId: 1,
title: 'About Clowder AI',
message: `Clowder AI v${version}`,
detail: [
'Multi-Agent Collaboration Platform',
'',
`Version: ${version}`,
`Electron: ${process.versions.electron}`,
`Node: ${process.versions.node}`,
'',
'License: AGPL-3.0',
'https://github.com/zts212653/clowder-ai',
].join('\n'),
})
.then((result) => {
if (result.response === 0) updater?.checkForUpdates();
});
}

function createTray() {
const iconPath = path.join(__dirname, 'assets', 'icon.ico');
try {
Expand All @@ -162,6 +189,9 @@ function createTray() {
const contextMenu = Menu.buildFromTemplate([
{ label: 'Show Clowder AI', click: () => mainWindow?.show() },
{ type: 'separator' },
{ label: 'About', click: () => showAboutDialog() },
{ label: 'Check for Updates', click: () => updater?.checkForUpdates() },
{ type: 'separator' },
{ label: 'Quit', click: () => quitApp() },
]);
tray.setToolTip('Clowder AI');
Expand Down Expand Up @@ -215,17 +245,61 @@ app.on('ready', async () => {
createSplashWindow();
createTray();

// macOS: set application menu with About entry (standard mac UX)
if (process.platform === 'darwin') {
const appMenu = Menu.buildFromTemplate([
{
label: app.name,
submenu: [
{ label: 'About Clowder AI', click: () => showAboutDialog() },
{ label: 'Check for Updates…', click: () => updater?.checkForUpdates() },
{ type: 'separator' },
{ role: 'quit' },
],
},
]);
Menu.setApplicationMenu(appMenu);
}

services = new ServiceManager(PROJECT_ROOT, {
frontendPort: FRONTEND_PORT,
apiPort: API_PORT,
onStatus: sendSplashStatus,
});

// F258: Initialize updater — check pending upgrade result BEFORE services
// (spec §3.2: "main.js 早期、服务启动前检测")
updater = new UpdateManager({
app,
net,
showDialog: (opts) => dialog.showMessageBox(opts).then((r) => r.response),
showNotification: (title, body) => {
try {
new Notification({ title, body }).show();
} catch {}
},
setProgressBar: (p) => {
try {
mainWindow?.setProgressBar(p);
} catch {}
},
openExternal: (url) => shell.openExternal(url),
openPath: (p) => shell.openPath(p),
quitApp,
dbg,
userDataRoot,
platform: process.platform,
arch: process.arch,
});
await updater.checkPendingUpgrade();

try {
dbg('startAll() called');
await services.startAll();
dbg('startAll() done — creating main window');
createMainWindow();
// F258: Start update check after services are up
updater.startSchedule();
} catch (err) {
dbg(`startAll() FAILED: ${err.message}`);
dialog.showErrorBox(
Expand Down
4 changes: 4 additions & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@
"preload.js",
"project-root.js",
"service-manager.js",
"update-checker.js",
"update-downloader.js",
"update-installer.js",
"update-manager.js",
"splash.html",
"assets/**/*"
],
Expand Down
5 changes: 5 additions & 0 deletions desktop/scripts/generate-desktop-config.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ $config = @{
installedAt = (Get-Date -Format "o")
}

# Only write installType if explicitly provided (fail-safe: missing = no auto-install)
if ($InstallType -ne "") {
$config.installType = $InstallType
}

$configPath = Join-Path $AppDir ".cat-cafe\desktop-config.json"
$configDir = Split-Path -Parent $configPath
if (-not (Test-Path $configDir)) {
Expand Down
18 changes: 13 additions & 5 deletions desktop/service-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,19 @@ const ARCH_SEG = process.arch === 'arm64' ? 'arm64' : 'x64';

// Resolve the per-user writable data root. Extracted to module level so
// LOG_FILE can be set before ServiceManager is instantiated.
// Reads brand name from electron-builder config — single source of truth
// shared with main.js (which imports USER_DATA_DIR from this module).
function resolveUserDataDir() {
if (IS_MAC) {
const home = process.env.HOME || os.homedir();
return path.join(home, 'Library', 'Application Support', 'Clowder AI');
let brandName;
try {
brandName = require('./package.json').build?.productName || 'Clowder AI';
} catch {
brandName = 'Clowder AI';
}
const localAppData = process.env.LOCALAPPDATA || path.join(process.env.USERPROFILE || '', 'AppData', 'Local');
return path.join(localAppData, 'Clowder AI');
const base = IS_MAC
? path.join(process.env.HOME || os.homedir(), 'Library', 'Application Support')
: process.env.LOCALAPPDATA || path.join(process.env.USERPROFILE || '', 'AppData', 'Local');
return path.join(base, brandName);
}

// Desktop log alongside API logs in the user data directory.
Expand Down Expand Up @@ -804,4 +810,6 @@ class ServiceManager {
}
}

// Expose data root for main.js — single source of truth for userData path.
ServiceManager.USER_DATA_DIR = USER_DATA_DIR;
module.exports = ServiceManager;
Loading
Loading