🎉 init: 小龙的工作空间
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# eduplatform — Desktop
|
||||
|
||||
> Electron desktop application wrapping the eduplatform fullstack web project.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Web Fullstack Project
|
||||
×
|
||||
Desktop Shell (Electron)
|
||||
=
|
||||
Desktop Application
|
||||
```
|
||||
|
||||
- **Main Process** — Window management, menu, tray, IPC, auto-update
|
||||
- **Preload** — Secure bridge between main and renderer
|
||||
- **Renderer** — Your existing web frontend (Next.js)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start dev mode (web + api + electron concurrently)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Dev mode:
|
||||
- Web frontend: `http://localhost:3000`
|
||||
- API backend: `http://localhost:3001`
|
||||
- Electron loads the web URL directly
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Build for current platform
|
||||
npm run build
|
||||
|
||||
# Platform-specific builds
|
||||
npm run build:electron:mac
|
||||
npm run build:electron:win
|
||||
npm run build:electron:linux
|
||||
```
|
||||
|
||||
Output: `release/` directory with platform installers.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
electron/
|
||||
├── main.ts — Main process (BrowserWindow, Menu, lifecycle)
|
||||
├── preload.ts — Secure IPC bridge (contextBridge)
|
||||
├── ipc.ts — IPC handlers (dialogs, store, notifications)
|
||||
├── tray.ts — System tray
|
||||
├── updater.ts — Auto-update (electron-updater)
|
||||
└── utils.ts — Shared utilities
|
||||
```
|
||||
|
||||
## IPC API (available in renderer)
|
||||
|
||||
```typescript
|
||||
window.electronAPI.getAppVersion()
|
||||
window.electronAPI.getPlatform()
|
||||
window.electronAPI.minimizeWindow()
|
||||
window.electronAPI.maximizeWindow()
|
||||
window.electronAPI.closeWindow()
|
||||
window.electronAPI.openFile(options?)
|
||||
window.electronAPI.saveFile(options?)
|
||||
window.electronAPI.showNotification(title, body)
|
||||
window.electronAPI.openExternal(url)
|
||||
window.electronAPI.storeGet(key)
|
||||
window.electronAPI.storeSet(key, value)
|
||||
window.electronAPI.checkForUpdates()
|
||||
window.electronAPI.downloadUpdate()
|
||||
window.electronAPI.quitAndInstall()
|
||||
```
|
||||
|
||||
## Auto Update
|
||||
|
||||
Configured via `electron-builder.yml`. Uses `electron-updater` with generic provider.
|
||||
Set your release URL in the `publish` section.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 66 B |
@@ -0,0 +1,100 @@
|
||||
# electron-builder.yml — Auto-generated by SF-06
|
||||
|
||||
appId: com.eduplatform.desktop
|
||||
productName: eduplatform
|
||||
copyright: "Copyright © 2026 eduplatform"
|
||||
|
||||
# ─── Directories ─────────────────────────────────
|
||||
directories:
|
||||
output: release
|
||||
buildResources: assets
|
||||
|
||||
# ─── Files ───────────────────────────────────────
|
||||
files:
|
||||
- dist/electron/**/*
|
||||
- "!node_modules/**/*"
|
||||
- "**/*.node"
|
||||
|
||||
# ─── Extra Resources ─────────────────────────────
|
||||
extraResources:
|
||||
- from: "../web/out"
|
||||
to: "web"
|
||||
filter:
|
||||
- "**/*"
|
||||
- from: "../api/dist"
|
||||
to: "api"
|
||||
filter:
|
||||
- "**/*"
|
||||
- from: "../api/data"
|
||||
to: "data"
|
||||
filter:
|
||||
- "**/*"
|
||||
|
||||
# ─── macOS ───────────────────────────────────────
|
||||
mac:
|
||||
category: public.app-category.productivity
|
||||
icon: assets/icon.png
|
||||
target:
|
||||
- target: dmg
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
- target: zip
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
darkModeSupport: true
|
||||
hardenedRuntime: true
|
||||
gatekeeperAssess: false
|
||||
entitlements: entitlements.mac.plist
|
||||
entitlementsInherit: entitlements.mac.plist
|
||||
|
||||
# ─── Windows ─────────────────────────────────────
|
||||
win:
|
||||
icon: assets/icon.png
|
||||
target:
|
||||
- target: nsis
|
||||
arch:
|
||||
- x64
|
||||
- target: portable
|
||||
arch:
|
||||
- x64
|
||||
publisherName: eduplatform
|
||||
|
||||
nsis:
|
||||
oneClick: false
|
||||
perMachine: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
installerIcon: assets/icon.ico
|
||||
uninstallerIcon: assets/icon.ico
|
||||
installerHeaderIcon: assets/icon.ico
|
||||
createDesktopShortcut: true
|
||||
createStartMenuShortcut: true
|
||||
shortcutName: eduplatform
|
||||
|
||||
# ─── Linux ───────────────────────────────────────
|
||||
linux:
|
||||
icon: assets/icon.png
|
||||
category: Utility
|
||||
target:
|
||||
- target: AppImage
|
||||
arch:
|
||||
- x64
|
||||
- target: deb
|
||||
arch:
|
||||
- x64
|
||||
- target: rpm
|
||||
arch:
|
||||
- x64
|
||||
desktop:
|
||||
Name: eduplatform
|
||||
Comment: eduplatform Desktop Application
|
||||
Terminal: false
|
||||
Type: Application
|
||||
Categories: Utility;
|
||||
|
||||
# ─── Auto Update ─────────────────────────────────
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://releases.example.com/eduplatform/
|
||||
channel: latest
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ipcMain, app, dialog, shell, BrowserWindow, Notification } from "electron";
|
||||
import Store from "electron-store";
|
||||
|
||||
const store = new Store() as any;
|
||||
|
||||
export function setupIPC(mainWindow: BrowserWindow): void {
|
||||
// ── App Info ──
|
||||
ipcMain.handle("app:version", () => app.getVersion());
|
||||
ipcMain.handle("app:platform", () => process.platform);
|
||||
ipcMain.handle("app:isDev", () => !app.isPackaged);
|
||||
|
||||
// ── Window Controls ──
|
||||
ipcMain.on("window:minimize", () => {
|
||||
mainWindow?.minimize();
|
||||
});
|
||||
|
||||
ipcMain.on("window:maximize", () => {
|
||||
if (mainWindow?.isMaximized()) {
|
||||
mainWindow.unmaximize();
|
||||
} else {
|
||||
mainWindow?.maximize();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("window:close", () => {
|
||||
mainWindow?.close();
|
||||
});
|
||||
|
||||
ipcMain.handle("window:isMaximized", () => {
|
||||
return mainWindow?.isMaximized() ?? false;
|
||||
});
|
||||
|
||||
// ── File Dialogs ──
|
||||
ipcMain.handle("dialog:openFile", async (_event, options?) => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ["openFile"],
|
||||
...options,
|
||||
});
|
||||
return result.canceled ? undefined : result.filePaths;
|
||||
});
|
||||
|
||||
ipcMain.handle("dialog:saveFile", async (_event, options?) => {
|
||||
const result = await dialog.showSaveDialog(mainWindow, {
|
||||
...options,
|
||||
});
|
||||
return result.canceled ? undefined : result.filePath;
|
||||
});
|
||||
|
||||
// ── Notifications ──
|
||||
ipcMain.on("notification:show", (_event, { title, body }) => {
|
||||
if (Notification.isSupported()) {
|
||||
new Notification({ title, body }).show();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Shell ──
|
||||
ipcMain.on("shell:openExternal", (_event, url: string) => {
|
||||
shell.openExternal(url);
|
||||
});
|
||||
|
||||
ipcMain.on("shell:openPath", (_event, path: string) => {
|
||||
shell.openPath(path);
|
||||
});
|
||||
|
||||
// ── Persistent Store ──
|
||||
ipcMain.handle("store:get", (_event, key: string) => {
|
||||
return store.get(key);
|
||||
});
|
||||
|
||||
ipcMain.handle("store:set", (_event, key: string, value: unknown) => {
|
||||
store.set(key, value);
|
||||
});
|
||||
|
||||
ipcMain.handle("store:delete", (_event, key: string) => {
|
||||
store.delete(key);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { app, BrowserWindow, Menu, nativeImage, ipcMain } from "electron";
|
||||
import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { setupTray } from "./tray";
|
||||
import { setupIPC } from "./ipc";
|
||||
import { setupUpdater } from "./updater";
|
||||
import { checkSingleInstance, getWebUrl, getApiPath, isDev } from "./utils";
|
||||
|
||||
// ─── Constants ───────────────────────────────────
|
||||
const WEB_PORT = 3000;
|
||||
const API_PORT = 3001;
|
||||
const APP_NAME = "eduplatform";
|
||||
|
||||
// ─── Single Instance Lock ────────────────────────
|
||||
if (!checkSingleInstance()) {
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ─── State ───────────────────────────────────────
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let apiProcess: ReturnType<typeof import("child_process").spawn> | null = null;
|
||||
|
||||
// ─── Create Window ───────────────────────────────
|
||||
function createWindow(): BrowserWindow {
|
||||
const preloadPath = join(__dirname, "preload.js");
|
||||
|
||||
const win = new BrowserWindow({
|
||||
title: APP_NAME,
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
icon: getIconPath(),
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
|
||||
trafficLightPosition: { x: 16, y: 16 },
|
||||
});
|
||||
|
||||
// ── Load content ──
|
||||
const url = getWebUrl(isDev(), WEB_PORT);
|
||||
if (url.startsWith("http")) {
|
||||
win.loadURL(url);
|
||||
} else {
|
||||
win.loadFile(url);
|
||||
}
|
||||
|
||||
// ── Show when ready ──
|
||||
win.once("ready-to-show", () => {
|
||||
win.show();
|
||||
if (isDev()) {
|
||||
win.webContents.openDevTools({ mode: "detach" });
|
||||
}
|
||||
});
|
||||
|
||||
// ── External links → default browser ──
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith("http")) {
|
||||
require("electron").shell.openExternal(url);
|
||||
}
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// ── Prevent navigation away ──
|
||||
win.webContents.on("will-navigate", (event, url) => {
|
||||
const allowed = isDev()
|
||||
? url.startsWith("http://localhost:3000")
|
||||
: url.startsWith("file://");
|
||||
if (!allowed) event.preventDefault();
|
||||
});
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
// ─── Icon Path ───────────────────────────────────
|
||||
function getIconPath(): string {
|
||||
const candidates = [
|
||||
join(__dirname, "..", "assets", "icon.png"),
|
||||
join(__dirname, "..", "assets", "icon.icns"),
|
||||
join(__dirname, "..", "assets", "icon.ico"),
|
||||
];
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// ─── API Server (Production) ─────────────────────
|
||||
function startApiServer(): void {
|
||||
if (isDev()) return;
|
||||
|
||||
const apiPath = getApiPath();
|
||||
if (!apiPath || !existsSync(apiPath)) {
|
||||
console.warn("[main] API server not found at", apiPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const { spawn } = require("child_process");
|
||||
apiProcess = spawn("node", [apiPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
PORT: String(API_PORT),
|
||||
HOST: "127.0.0.1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
apiProcess!.stdout?.on("data", (data: Buffer) => {
|
||||
console.log("[api]", data.toString().trim());
|
||||
});
|
||||
apiProcess!.stderr?.on("data", (data: Buffer) => {
|
||||
console.error("[api]", data.toString().trim());
|
||||
});
|
||||
apiProcess!.on("exit", (code: number) => {
|
||||
console.warn("[main] API server exited with code", code);
|
||||
apiProcess = null;
|
||||
});
|
||||
}
|
||||
|
||||
function stopApiServer(): void {
|
||||
if (apiProcess) {
|
||||
apiProcess.kill("SIGTERM");
|
||||
apiProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Menu ────────────────────────────────────────
|
||||
function buildMenu(): void {
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
...(isMac
|
||||
? [{
|
||||
label: APP_NAME,
|
||||
submenu: [
|
||||
{ role: "about" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "services" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "hide" as const },
|
||||
{ role: "hideOthers" as const },
|
||||
{ role: "unhide" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "quit" as const },
|
||||
],
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
{ type: "separator" },
|
||||
{ role: "cut" },
|
||||
{ role: "copy" },
|
||||
{ role: "paste" },
|
||||
{ role: "selectAll" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{ role: "reload" },
|
||||
{ role: "forceReload" },
|
||||
{ role: "toggleDevTools" },
|
||||
{ type: "separator" },
|
||||
{ role: "resetZoom" },
|
||||
{ role: "zoomIn" },
|
||||
{ role: "zoomOut" },
|
||||
{ type: "separator" },
|
||||
{ role: "togglefullscreen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Window",
|
||||
submenu: [
|
||||
{ role: "minimize" },
|
||||
{ role: "zoom" },
|
||||
...(isMac
|
||||
? [
|
||||
{ type: "separator" as const },
|
||||
{ role: "front" as const },
|
||||
]
|
||||
: [{ role: "close" as const }]),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
}
|
||||
|
||||
// ─── App Lifecycle ───────────────────────────────
|
||||
app.whenReady().then(() => {
|
||||
startApiServer();
|
||||
mainWindow = createWindow();
|
||||
buildMenu();
|
||||
if (mainWindow) {
|
||||
setupTray(mainWindow, APP_NAME);
|
||||
setupIPC(mainWindow);
|
||||
}
|
||||
setupUpdater(APP_NAME);
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
mainWindow = createWindow();
|
||||
if (mainWindow) {
|
||||
setupTray(mainWindow, APP_NAME);
|
||||
setupIPC(mainWindow);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
stopApiServer();
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
stopApiServer();
|
||||
});
|
||||
|
||||
app.on("gpu-process-crashed" as any, () => {
|
||||
console.warn("[main] GPU process crashed — continuing");
|
||||
});
|
||||
|
||||
process.on("exit", () => {
|
||||
stopApiServer();
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
// ─── Expose safe API to renderer ─────────────────
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
// ── App Info ──
|
||||
getAppVersion: () => ipcRenderer.invoke("app:version"),
|
||||
getPlatform: () => ipcRenderer.invoke("app:platform"),
|
||||
getIsDev: () => ipcRenderer.invoke("app:isDev"),
|
||||
|
||||
// ── Window Controls ──
|
||||
minimizeWindow: () => ipcRenderer.send("window:minimize"),
|
||||
maximizeWindow: () => ipcRenderer.send("window:maximize"),
|
||||
closeWindow: () => ipcRenderer.send("window:close"),
|
||||
isMaximized: () => ipcRenderer.invoke("window:isMaximized"),
|
||||
|
||||
// ── File Dialogs ──
|
||||
openFile: (options?: Electron.OpenDialogOptions) =>
|
||||
ipcRenderer.invoke("dialog:openFile", options),
|
||||
saveFile: (options?: Electron.SaveDialogOptions) =>
|
||||
ipcRenderer.invoke("dialog:saveFile", options),
|
||||
|
||||
// ── Notifications ──
|
||||
showNotification: (title: string, body: string) =>
|
||||
ipcRenderer.send("notification:show", { title, body }),
|
||||
|
||||
// ── Shell ──
|
||||
openExternal: (url: string) => ipcRenderer.send("shell:openExternal", url),
|
||||
openPath: (path: string) => ipcRenderer.send("shell:openPath", path),
|
||||
|
||||
// ── Store ──
|
||||
storeGet: (key: string) => ipcRenderer.invoke("store:get", key),
|
||||
storeSet: (key: string, value: unknown) =>
|
||||
ipcRenderer.invoke("store:set", key, value),
|
||||
storeDelete: (key: string) => ipcRenderer.invoke("store:delete", key),
|
||||
|
||||
// ── Updates ──
|
||||
checkForUpdates: () => ipcRenderer.invoke("updater:check"),
|
||||
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
|
||||
quitAndInstall: () => ipcRenderer.send("updater:quitAndInstall"),
|
||||
|
||||
// ── Update Events ──
|
||||
onUpdateAvailable: (callback: (info: unknown) => void) => {
|
||||
ipcRenderer.on("updater:available", (_event, info) => callback(info));
|
||||
},
|
||||
onUpdateDownloaded: (callback: (info: unknown) => void) => {
|
||||
ipcRenderer.on("updater:downloaded", (_event, info) => callback(info));
|
||||
},
|
||||
onUpdateProgress: (callback: (progress: unknown) => void) => {
|
||||
ipcRenderer.on("updater:progress", (_event, progress) => callback(progress));
|
||||
},
|
||||
onUpdateError: (callback: (error: string) => void) => {
|
||||
ipcRenderer.on("updater:error", (_event, error) => callback(error));
|
||||
},
|
||||
|
||||
// ── Remove listener ──
|
||||
removeAllListeners: (channel: string) => {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Type declarations for renderer ──────────────
|
||||
export interface ElectronAPI {
|
||||
getAppVersion: () => Promise<string>;
|
||||
getPlatform: () => Promise<string>;
|
||||
getIsDev: () => Promise<boolean>;
|
||||
minimizeWindow: () => void;
|
||||
maximizeWindow: () => void;
|
||||
closeWindow: () => void;
|
||||
isMaximized: () => Promise<boolean>;
|
||||
openFile: (options?: Electron.OpenDialogOptions) => Promise<string[] | undefined>;
|
||||
saveFile: (options?: Electron.SaveDialogOptions) => Promise<string | undefined>;
|
||||
showNotification: (title: string, body: string) => void;
|
||||
openExternal: (url: string) => void;
|
||||
openPath: (path: string) => void;
|
||||
storeGet: (key: string) => Promise<unknown>;
|
||||
storeSet: (key: string, value: unknown) => Promise<void>;
|
||||
storeDelete: (key: string) => Promise<void>;
|
||||
checkForUpdates: () => Promise<void>;
|
||||
downloadUpdate: () => Promise<void>;
|
||||
quitAndInstall: () => void;
|
||||
onUpdateAvailable: (callback: (info: unknown) => void) => void;
|
||||
onUpdateDownloaded: (callback: (info: unknown) => void) => void;
|
||||
onUpdateProgress: (callback: (progress: unknown) => void) => void;
|
||||
onUpdateError: (callback: (error: string) => void) => void;
|
||||
removeAllListeners: (channel: string) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Tray, Menu, nativeImage, BrowserWindow, app } from "electron";
|
||||
import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
let tray: Tray | null = null;
|
||||
|
||||
export function setupTray(mainWindow: BrowserWindow, appName: string): void {
|
||||
const iconPath = getTrayIcon();
|
||||
if (!iconPath) {
|
||||
console.warn("[tray] No icon found, skipping tray setup");
|
||||
return;
|
||||
}
|
||||
|
||||
const icon = nativeImage.createFromPath(iconPath);
|
||||
tray = new Tray(icon.resize({ width: 16, height: 16 }));
|
||||
tray.setToolTip(appName);
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: "Show " + appName,
|
||||
click: () => {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
tray.on("click", () => {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.focus();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
}
|
||||
});
|
||||
|
||||
tray.on("double-click", () => {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function getTrayIcon(): string | null {
|
||||
const candidates = [
|
||||
join(__dirname, "..", "assets", "tray-icon.png"),
|
||||
join(__dirname, "..", "assets", "icon.png"),
|
||||
join(__dirname, "..", "assets", "icon.ico"),
|
||||
];
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { ipcMain, BrowserWindow } from "electron";
|
||||
|
||||
let updateAvailable = false;
|
||||
let updateDownloaded = false;
|
||||
|
||||
export function setupUpdater(appName: string): void {
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
autoUpdater.logger = {
|
||||
info: (msg) => console.log("[updater]", msg),
|
||||
warn: (msg) => console.warn("[updater]", msg),
|
||||
error: (msg) => console.error("[updater]", msg),
|
||||
debug: (msg) => console.debug("[updater]", msg),
|
||||
};
|
||||
|
||||
autoUpdater.on("checking-for-update", () => {
|
||||
console.log("[updater] Checking for updates...");
|
||||
});
|
||||
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
updateAvailable = true;
|
||||
console.log("[updater] Update available:", info.version);
|
||||
broadcast("updater:available", info);
|
||||
});
|
||||
|
||||
autoUpdater.on("update-not-available", (info) => {
|
||||
console.log("[updater] Up to date:", info.version);
|
||||
});
|
||||
|
||||
autoUpdater.on("download-progress", (progress) => {
|
||||
broadcast("updater:progress", {
|
||||
percent: progress.percent,
|
||||
bytesPerSecond: progress.bytesPerSecond,
|
||||
transferred: progress.transferred,
|
||||
total: progress.total,
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", (info) => {
|
||||
updateDownloaded = true;
|
||||
console.log("[updater] Update downloaded:", info.version);
|
||||
broadcast("updater:downloaded", info);
|
||||
});
|
||||
|
||||
autoUpdater.on("error", (err) => {
|
||||
console.error("[updater] Error:", err.message);
|
||||
broadcast("updater:error", err.message);
|
||||
});
|
||||
|
||||
ipcMain.handle("updater:check", async () => {
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
return result?.updateInfo ?? null;
|
||||
} catch (err) {
|
||||
console.error("[updater] Check failed:", err);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("updater:download", async () => {
|
||||
if (!updateAvailable) return;
|
||||
try {
|
||||
await autoUpdater.downloadUpdate();
|
||||
} catch (err) {
|
||||
console.error("[updater] Download failed:", err);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("updater:quitAndInstall", () => {
|
||||
if (updateDownloaded) {
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
autoUpdater.checkForUpdates().catch(() => {});
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
function broadcast(channel: string, data: unknown): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send(channel, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { app } from "electron";
|
||||
import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
// ─── Single Instance Lock ────────────────────────
|
||||
export function checkSingleInstance(): boolean {
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
return false;
|
||||
}
|
||||
|
||||
app.on("second-instance", (_event, _argv, _cwd) => {
|
||||
const windows = require("electron").BrowserWindow.getAllWindows();
|
||||
if (windows.length > 0) {
|
||||
const win = windows[0];
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Dev mode detection ──────────────────────────
|
||||
export function isDev(): boolean {
|
||||
return !app.isPackaged;
|
||||
}
|
||||
|
||||
// ─── Web URL ─────────────────────────────────────
|
||||
export function getWebUrl(dev: boolean, port: number): string {
|
||||
if (dev) {
|
||||
return "http://localhost:" + port;
|
||||
}
|
||||
|
||||
const appPath = app.isPackaged
|
||||
? join(process.resourcesPath, "web")
|
||||
: join(__dirname, "..", "..", "web", "out");
|
||||
|
||||
const staticIndex = join(appPath, "index.html");
|
||||
if (existsSync(staticIndex)) {
|
||||
return staticIndex;
|
||||
}
|
||||
|
||||
const standalone = join(appPath, ".next", "standalone", "server.js");
|
||||
if (existsSync(standalone)) {
|
||||
return "http://localhost:3000";
|
||||
}
|
||||
|
||||
const fallback = join(appPath, "index.html");
|
||||
if (existsSync(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
console.warn("[utils] No web build found at", appPath);
|
||||
return "http://localhost:3000";
|
||||
}
|
||||
|
||||
// ─── API Path ────────────────────────────────────
|
||||
export function getApiPath(): string | null {
|
||||
if (app.isPackaged) {
|
||||
const packed = join(process.resourcesPath, "api", "dist", "index.js");
|
||||
if (existsSync(packed)) return packed;
|
||||
const packedSrc = join(process.resourcesPath, "api", "src", "index.js");
|
||||
if (existsSync(packedSrc)) return packedSrc;
|
||||
}
|
||||
|
||||
const devPath = join(__dirname, "..", "..", "api", "dist", "index.js");
|
||||
if (existsSync(devPath)) return devPath;
|
||||
|
||||
const devSrc = join(__dirname, "..", "..", "api", "src", "index.js");
|
||||
if (existsSync(devSrc)) return devSrc;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Get API port from env ───────────────────────
|
||||
export function getApiPort(): number {
|
||||
return 3001;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "eduplatform-desktop",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "eduplatform — Desktop Application powered by Electron",
|
||||
"main": "dist/electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:web\" \"npm run dev:api\" \"npm run dev:electron\"",
|
||||
"dev:electron": "tsc && electron .",
|
||||
"dev:web": "cd ../web && npm run dev",
|
||||
"dev:api": "cd ../api && npm run dev",
|
||||
"build": "npm run build:web && npm run build:api && npm run build:electron",
|
||||
"build:web": "cd ../web && npm run build",
|
||||
"build:api": "cd ../api && npm run build",
|
||||
"build:electron": "tsc && electron-builder --config electron-builder.yml",
|
||||
"build:electron:win": "tsc && electron-builder --win --config electron-builder.yml",
|
||||
"build:electron:mac": "tsc && electron-builder --mac --config electron-builder.yml",
|
||||
"build:electron:linux": "tsc && electron-builder --linux --config electron-builder.yml",
|
||||
"pack": "tsc && electron-builder --dir --config electron-builder.yml",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint electron/"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.3.9",
|
||||
"electron-store": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^35.0.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"concurrently": "^9.1.0",
|
||||
"typescript": "^5.7.0",
|
||||
"@types/node": "^22.10.0"
|
||||
},
|
||||
"build": {
|
||||
"productName": "eduplatform",
|
||||
"appId": "com.eduplatform.desktop",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"outDir": "dist",
|
||||
"rootDir": "electron",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"electron/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"release"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user