mirror of
https://github.com/ringhyacinth/Star-Office-UI
synced 2026-04-21 13:27:19 +00:00
543 lines
16 KiB
JavaScript
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);
|