Star-Office-UI/electron-shell/main.js
2026-03-06 17:11:35 +08:00

543 lines
16 KiB
JavaScript

const { app, BrowserWindow, Tray, Menu, ipcMain, shell, nativeImage } = require("electron");
const { spawn } = require("child_process");
const fs = require("fs");
const path = require("path");
const net = require("net");
const APP_NAME = "Star Office UI";
const BACKEND_HOST = process.env.STAR_BACKEND_HOST || "127.0.0.1";
const rawBackendPort = Number(process.env.STAR_BACKEND_PORT || 19000);
const BACKEND_PORT = Number.isFinite(rawBackendPort) && rawBackendPort > 0 ? rawBackendPort : 19000;
const BACKEND_BASE_URL = `http://${BACKEND_HOST}:${BACKEND_PORT}`;
let mainWindow = null;
let miniWindow = null;
let assetWindow = null;
let tray = null;
let backendChild = null;
let isQuitting = false;
let currentUiLang = "en";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function tcpReachable(host, port, timeoutMs = 500) {
return new Promise((resolve) => {
const socket = new net.Socket();
let settled = false;
const done = (ok) => {
if (settled) return;
settled = true;
socket.destroy();
resolve(ok);
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => done(true));
socket.once("timeout", () => done(false));
socket.once("error", () => done(false));
socket.connect(port, host);
});
}
async function waitBackendReady(timeoutMs = 20000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await tcpReachable(BACKEND_HOST, BACKEND_PORT, 400)) return true;
await sleep(200);
}
return false;
}
function findProjectRoot() {
if (process.env.STAR_PROJECT_ROOT) {
const custom = path.isAbsolute(process.env.STAR_PROJECT_ROOT)
? process.env.STAR_PROJECT_ROOT
: path.resolve(process.cwd(), process.env.STAR_PROJECT_ROOT);
if (fs.existsSync(path.join(custom, "backend", "app.py"))) return custom;
}
const fromDir = __dirname;
let cursor = fromDir;
for (let i = 0; i < 8; i += 1) {
if (fs.existsSync(path.join(cursor, "backend", "app.py"))) return cursor;
const parent = path.dirname(cursor);
if (parent === cursor) break;
cursor = parent;
}
const home = process.env.HOME || "";
const candidates = [
path.join(home, "Documents", "GitHub", "Star-Office-UI"),
path.join(home, "GitHub", "Star-Office-UI"),
path.join(home, "Documents", "Star-Office-UI"),
path.join(home, "Star-Office-UI"),
];
for (const c of candidates) {
if (fs.existsSync(path.join(c, "backend", "app.py"))) return c;
}
return process.cwd();
}
function resolveAppIconPath(projectRoot) {
const candidates = [
path.join(projectRoot, "desktop-pet", "src-tauri", "icons", "icon.png"),
path.join(projectRoot, "desktop-pet", "src-tauri", "icons", "[email protected]"),
path.join(projectRoot, "desktop-pet", "src-tauri", "icons", "128x128.png"),
path.join(projectRoot, "desktop-pet", "src-tauri", "icons", "32x32.png"),
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
return null;
}
function applyAppIcon(projectRoot) {
const iconPath = resolveAppIconPath(projectRoot);
if (!iconPath) return null;
const iconImg = nativeImage.createFromPath(iconPath);
if (iconImg.isEmpty()) return null;
if (process.platform === "darwin" && app.dock && app.dock.setIcon) {
app.dock.setIcon(iconImg);
}
return iconPath;
}
function readStateFile(statePath) {
const raw = fs.readFileSync(statePath, "utf-8");
return JSON.parse(raw);
}
function readStateViaBackend() {
return new Promise((resolve, reject) => {
const req = `GET /status HTTP/1.1\r\nHost: ${BACKEND_HOST}\r\nConnection: close\r\n\r\n`;
const socket = net.createConnection({ host: BACKEND_HOST, port: BACKEND_PORT });
let buf = "";
socket.setTimeout(1200);
socket.on("connect", () => socket.write(req));
socket.on("data", (chunk) => {
buf += chunk.toString("utf-8");
});
socket.on("timeout", () => {
socket.destroy();
reject(new Error("backend timeout"));
});
socket.on("error", reject);
socket.on("end", () => {
const sep = "\r\n\r\n";
const idx = buf.indexOf(sep);
if (idx === -1) {
reject(new Error("invalid backend response"));
return;
}
try {
resolve(JSON.parse(buf.slice(idx + sep.length)));
} catch (e) {
reject(e);
}
});
});
}
async function readStateWithFallback(projectRoot) {
const statePath = path.join(projectRoot, "state.json");
try {
return readStateFile(statePath);
} catch (_) {
return readStateViaBackend();
}
}
function spawnBackend(projectRoot) {
const script = path.join(projectRoot, "backend", "app.py");
if (!fs.existsSync(script)) {
console.warn(`backend/app.py not found: ${script}`);
return null;
}
const candidates = [];
if (process.env.STAR_BACKEND_PYTHON) candidates.push(process.env.STAR_BACKEND_PYTHON);
candidates.push(path.join(projectRoot, ".venv", "bin", "python"));
candidates.push("python3");
candidates.push("python");
for (const bin of candidates) {
try {
const child = spawn(bin, [script], {
cwd: projectRoot,
stdio: "inherit",
});
console.log(`backend started with ${bin}`);
return child;
} catch (e) {
console.warn(`failed to spawn ${bin}: ${e.message}`);
}
}
return null;
}
function ensureElectronStandaloneSnapshot(projectRoot) {
const src = path.join(projectRoot, "frontend", "index.html");
const dst = path.join(projectRoot, "frontend", "electron-standalone.html");
if (!fs.existsSync(src)) return;
if (fs.existsSync(dst)) return;
try {
fs.copyFileSync(src, dst);
console.log(`created standalone snapshot: ${dst}`);
} catch (e) {
console.warn(`failed to create standalone snapshot: ${e.message}`);
}
}
function emitMini(event, payload) {
if (!miniWindow || miniWindow.isDestroyed()) return;
miniWindow.webContents.send("tauri:event", { event, payload });
}
function emitMain(event, payload) {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send("tauri:event", { event, payload });
}
async function enterMiniMode(projectRoot) {
const snapshot = await readStateWithFallback(projectRoot).catch(() => null);
if (snapshot) emitMini("mini-sync-state", { ...snapshot, ui_lang: currentUiLang });
if (mainWindow && !mainWindow.isDestroyed()) {
const bounds = mainWindow.getBounds();
if (miniWindow && !miniWindow.isDestroyed()) {
miniWindow.setBounds({ ...miniWindow.getBounds(), x: bounds.x, y: bounds.y });
}
mainWindow.hide();
}
if (miniWindow && !miniWindow.isDestroyed()) {
miniWindow.show();
miniWindow.focus();
}
}
async function openFrontendAndQuit() {
await shell.openExternal(`${BACKEND_BASE_URL}/`);
app.quit();
}
function createAssetWindow(projectRoot) {
if (assetWindow && !assetWindow.isDestroyed()) {
assetWindow.show();
assetWindow.focus();
assetWindow.moveTop();
return assetWindow;
}
const preloadPath = path.join(__dirname, "preload.js");
const appIconPath = resolveAppIconPath(projectRoot);
const mainBounds = mainWindow && !mainWindow.isDestroyed() ? mainWindow.getBounds() : null;
const x = mainBounds ? mainBounds.x + 32 : 160;
const y = mainBounds ? mainBounds.y + 32 : 120;
const assetUrl = `${BACKEND_BASE_URL}/electron-standalone?desktop=1&assetWindow=1`;
assetWindow = new BrowserWindow({
width: 300,
height: 580,
minWidth: 300,
maxWidth: 300,
minHeight: 580,
x,
y,
title: "Star Decorate Room",
frame: false,
transparent: true,
hasShadow: false,
alwaysOnTop: true,
resizable: true,
maximizable: true,
fullscreenable: false,
backgroundColor: "#00000000",
icon: appIconPath || undefined,
show: false,
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
},
});
assetWindow.once("ready-to-show", () => {
if (!assetWindow || assetWindow.isDestroyed()) return;
assetWindow.setAlwaysOnTop(true, "floating");
assetWindow.moveTop();
assetWindow.show();
assetWindow.focus();
});
assetWindow.on("closed", () => {
assetWindow = null;
});
assetWindow.loadURL(assetUrl);
return assetWindow;
}
function createWindows(projectRoot) {
const preloadPath = path.join(__dirname, "preload.js");
const appIconPath = resolveAppIconPath(projectRoot);
ensureElectronStandaloneSnapshot(projectRoot);
mainWindow = new BrowserWindow({
width: 700,
height: 460,
x: 80,
y: 60,
transparent: true,
frame: false,
alwaysOnTop: true,
resizable: false,
maximizable: false,
fullscreenable: false,
hasShadow: false,
icon: appIconPath || undefined,
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
},
});
mainWindow.setTitle(APP_NAME);
miniWindow = new BrowserWindow({
width: 220,
height: 240,
minWidth: 180,
minHeight: 200,
transparent: true,
frame: false,
alwaysOnTop: true,
resizable: false,
hasShadow: false,
show: false,
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
},
});
miniWindow.setTitle("Star Office UI Mini");
const v = Date.now();
const mainUrl = `${BACKEND_BASE_URL}/electron-standalone?desktop=1&v=${v}`;
mainWindow.loadURL(mainUrl);
miniWindow.loadFile(path.join(projectRoot, "desktop-pet", "src", "minimized.html"));
}
function createTray(projectRoot) {
const tray32 = path.join(projectRoot, "desktop-pet", "src-tauri", "icons", "32x32.png");
const iconPath = fs.existsSync(tray32) ? tray32 : resolveAppIconPath(projectRoot);
if (!iconPath) return;
const trayImage = nativeImage.createFromPath(iconPath);
tray = new Tray(trayImage);
tray.setToolTip(APP_NAME);
const menu = Menu.buildFromTemplate([
{
label: "显示主窗口",
click: () => {
if (miniWindow && !miniWindow.isDestroyed()) miniWindow.hide();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.show();
mainWindow.focus();
}
},
},
{
label: "显示 Mini 窗口",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.hide();
if (miniWindow && !miniWindow.isDestroyed()) {
miniWindow.show();
miniWindow.focus();
}
},
},
{ type: "separator" },
{
label: "退出",
click: () => app.quit(),
},
]);
tray.setContextMenu(menu);
tray.on("click", () => {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (mainWindow.isVisible()) mainWindow.hide();
else {
if (miniWindow && !miniWindow.isDestroyed()) miniWindow.hide();
mainWindow.show();
mainWindow.focus();
}
});
}
function registerIpc(projectRoot) {
const applyMainWindowMode = (expanded) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
const bounds = mainWindow.getBounds();
const targetHeight = expanded ? 620 : 460;
const targetWidth = bounds.width || 700;
mainWindow.setSize(targetWidth, targetHeight, true);
mainWindow.setContentSize(targetWidth, targetHeight, true);
};
ipcMain.handle("tauri:invoke", async (_event, payload) => {
const cmd = payload && payload.command;
const args = (payload && payload.args) || {};
if (cmd === "read_state") {
const state = await readStateWithFallback(projectRoot);
return { ...state, ui_lang: currentUiLang };
}
if (cmd === "set_ui_lang") {
const lang = String(args && args.lang ? args.lang : "").toLowerCase();
if (lang === "zh" || lang === "en" || lang === "ja") {
currentUiLang = lang;
}
return { ok: true, lang: currentUiLang };
}
if (cmd === "enter_minimize_mode") {
await enterMiniMode(projectRoot);
return null;
}
if (cmd === "restore_main_window") {
if (miniWindow && !miniWindow.isDestroyed()) miniWindow.hide();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.show();
mainWindow.focus();
}
return null;
}
if (cmd === "close_app") {
app.quit();
return null;
}
if (cmd === "open_external_url") {
if (args && args.url) {
await shell.openExternal(args.url);
}
return null;
}
if (cmd === "set_main_window_mode") {
const senderWin = BrowserWindow.fromWebContents(_event.sender);
// Only main window is allowed to control main window height.
if (!senderWin || !mainWindow || senderWin.id !== mainWindow.id) {
return null;
}
const expanded = !!(args && args.expanded);
applyMainWindowMode(expanded);
return null;
}
if (cmd === "open_asset_window") {
createAssetWindow(projectRoot);
return null;
}
if (cmd === "close_asset_window") {
if (assetWindow && !assetWindow.isDestroyed()) {
assetWindow.close();
}
return null;
}
if (cmd === "notify_main_window_asset_refresh") {
const payloadData = {
...(args && typeof args === "object" ? args : {}),
};
const kind = String(payloadData.kind ? payloadData.kind : "asset");
const path = String(payloadData.path ? payloadData.path : "");
emitMain("main-window-asset-refresh", {
...payloadData,
kind,
path,
at: Date.now(),
});
return null;
}
throw new Error(`Unsupported invoke command: ${cmd}`);
});
ipcMain.handle("window:set-size", (event, payload) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return null;
const width = Number(payload && payload.width);
const height = Number(payload && payload.height);
if (Number.isFinite(width) && Number.isFinite(height)) {
const w = Math.round(width);
const h = Math.round(height);
// Dual strategy: outer-size and content-size together for transparent frameless windows.
win.setSize(w, h, true);
win.setContentSize(w, h, true);
}
return null;
});
ipcMain.handle("window:get-position", (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return { x: 0, y: 0 };
const [x, y] = win.getPosition();
return { x, y };
});
ipcMain.handle("window:set-position", (event, payload) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return null;
const x = Number(payload && payload.x);
const y = Number(payload && payload.y);
if (Number.isFinite(x) && Number.isFinite(y)) {
win.setPosition(Math.round(x), Math.round(y), false);
}
return null;
});
}
async function bootstrap() {
const projectRoot = findProjectRoot();
console.log(`project root: ${projectRoot}`);
console.log(`state path: ${path.join(projectRoot, "state.json")}`);
const iconPath = applyAppIcon(projectRoot);
if (iconPath) console.log(`app icon: ${iconPath}`);
if (!(await tcpReachable(BACKEND_HOST, BACKEND_PORT, 400))) {
backendChild = spawnBackend(projectRoot);
const ready = await waitBackendReady(20000);
if (!ready) console.warn("backend not ready within 20s");
} else {
console.log(`backend already running on ${BACKEND_HOST}:${BACKEND_PORT}`);
}
registerIpc(projectRoot);
createWindows(projectRoot);
createTray(projectRoot);
}
app.on("window-all-closed", (e) => {
// Keep tray app resident by default (unless quitting).
if (!isQuitting) e.preventDefault();
});
app.on("before-quit", () => {
isQuitting = true;
if (backendChild) {
try {
backendChild.kill();
} catch (_) {}
}
});
if (app.setName) app.setName(APP_NAME);
app.whenReady().then(bootstrap);