🎉 init: 小龙的工作空间

This commit is contained in:
大海
2026-06-06 10:40:48 +08:00
commit a188ee1426
3201 changed files with 231817 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
# noteapp — Desktop
> Electron desktop application wrapping the noteapp 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.noteapp.desktop
productName: noteapp
copyright: "Copyright © 2026 noteapp"
# ─── 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: noteapp
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
installerIcon: assets/icon.ico
uninstallerIcon: assets/icon.ico
installerHeaderIcon: assets/icon.ico
createDesktopShortcut: true
createStartMenuShortcut: true
shortcutName: noteapp
# ─── Linux ───────────────────────────────────────
linux:
icon: assets/icon.png
category: Utility
target:
- target: AppImage
arch:
- x64
- target: deb
arch:
- x64
- target: rpm
arch:
- x64
desktop:
Name: noteapp
Comment: noteapp Desktop Application
Terminal: false
Type: Application
Categories: Utility;
# ─── Auto Update ─────────────────────────────────
publish:
provider: generic
url: https://releases.example.com/noteapp/
channel: latest
+77
View File
@@ -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);
});
}
+238
View File
@@ -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 = "noteapp";
// ─── 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;
}
}
+61
View File
@@ -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>
+41
View File
@@ -0,0 +1,41 @@
{
"name": "noteapp-desktop",
"version": "1.0.0",
"private": true,
"description": "noteapp — 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": "noteapp",
"appId": "com.noteapp.desktop",
"directories": {
"output": "release"
}
}
}
+32
View File
@@ -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"
]
}