Compare commits
11 Commits
292d8c27f2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cb92835d2f | |||
| 97c4e7ad96 | |||
| f641d42a8b | |||
| 77b6caab7b | |||
| 4473aa1b8b | |||
| 5c4770f051 | |||
| 8952b9d3ed | |||
| ca51ba7063 | |||
| 1476ff7bb4 | |||
| 54f4b46755 | |||
| 5843d3f8ca |
2
.gitignore
vendored
@@ -1 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
renderer/
|
||||
|
||||
48
README.md
@@ -1,18 +1,52 @@
|
||||
# Fabric.Desktop
|
||||
|
||||
Electron desktop shell for Fabric.Frontend.
|
||||
A **self-contained** Electron desktop app for Fabric. It bundles the
|
||||
`Fabric.Frontend` SPA inside the package and loads it locally over
|
||||
`file://` — no separate frontend web server is needed. It still talks to the
|
||||
Center/Guild **backends** over HTTP (the Center base is set on the login
|
||||
screen, so the desktop app points at whatever host runs the backends).
|
||||
|
||||
## Dev
|
||||
## How it works
|
||||
|
||||
- `npm run build:renderer` builds `Fabric.Frontend` with a relative asset
|
||||
base (`build:desktop`) and copies the bundle into `renderer/`.
|
||||
- `main.js` loads `renderer/index.html` in production (falls back to
|
||||
`offline.html` if the bundle is missing); dev mode loads
|
||||
`FABRIC_DESKTOP_URL` (default `http://localhost:5173`).
|
||||
- Security baseline: `contextIsolation` + `sandbox`, no arbitrary new
|
||||
windows/navigation; `preload` exposes a small IPC allowlist
|
||||
(`fabric:config:get|set`, `fabric:notify`).
|
||||
- System tray (show/hide window, quit) with the Fabric tray icon; app/window
|
||||
icons use the Fabric mark.
|
||||
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run start:dev
|
||||
npm run start:dev # expects Fabric.Frontend dev server at :5173
|
||||
# or, against the bundled renderer:
|
||||
npm run build:renderer && npm start
|
||||
```
|
||||
|
||||
`start:dev` expects Frontend dev server at `http://localhost:5173`.
|
||||
|
||||
## Standalone
|
||||
## Build / package
|
||||
|
||||
```bash
|
||||
npm start
|
||||
npm install
|
||||
npm run pack # unpacked dir (no installer)
|
||||
npm run dist:linux # AppImage, deb, tar.gz -> dist/
|
||||
npm run dist:mac # dmg, zip
|
||||
npm run dist:win # nsis, zip
|
||||
```
|
||||
|
||||
Each `dist:*`/`pack` runs `build:renderer` first (so `Fabric.Frontend` must
|
||||
be present beside this submodule, which it is in the `Fabric` checkout).
|
||||
Output goes to `dist/` (git-ignored). `productName` is **Fabric** so the app
|
||||
installs to `/opt/Fabric/` (a space there breaks Electron's sandbox); the
|
||||
launcher is still labelled "Fabric Desktop". Linux icons are generated from
|
||||
`build/icons/`.
|
||||
|
||||
## Notes
|
||||
|
||||
- The bundled SPA uses `HashRouter` under `file://` (no server for the
|
||||
History API). The Center/Guild backends are HTTP and CORS-allow the
|
||||
Electron `file://` origin (`null`).
|
||||
|
||||
BIN
assets/icon.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
assets/tray.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
assets/tray@2x.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
build/icon.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
build/icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
build/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
build/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 683 B |
BIN
build/icons/24x24.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
build/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
build/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
build/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
build/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
build/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
169
main.js
@@ -1,25 +1,182 @@
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const { app, BrowserWindow, Menu, Tray, ipcMain, Notification, nativeImage, shell } = require('electron')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const isDev = !!process.env.FABRIC_DESKTOP_URL
|
||||
const DEFAULT_DEV_URL = 'http://localhost:5173'
|
||||
// Self-contained: load the bundled frontend if present, else the placeholder.
|
||||
const BUNDLED_ENTRY = path.join(__dirname, 'renderer', 'index.html')
|
||||
const DEFAULT_PROD_ENTRY = fs.existsSync(BUNDLED_ENTRY)
|
||||
? BUNDLED_ENTRY
|
||||
: path.join(__dirname, 'offline.html')
|
||||
|
||||
let mainWindow = null
|
||||
let tray = null
|
||||
let isQuitting = false
|
||||
|
||||
function configPath() {
|
||||
return path.join(app.getPath('userData'), 'fabric-desktop.config.json')
|
||||
}
|
||||
|
||||
function readConfig() {
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath(), 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return {
|
||||
centerApiBase: 'http://localhost:7001/api',
|
||||
guildApiBase: 'http://localhost:7002/api',
|
||||
guildSocketBase: 'http://localhost:7002/realtime',
|
||||
apiKey: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeConfig(next) {
|
||||
fs.mkdirSync(path.dirname(configPath()), { recursive: true })
|
||||
fs.writeFileSync(configPath(), JSON.stringify(next, null, 2), 'utf8')
|
||||
return next
|
||||
}
|
||||
|
||||
function createMenu() {
|
||||
const template = [
|
||||
{
|
||||
label: 'Fabric',
|
||||
submenu: [
|
||||
{ role: 'reload', accelerator: 'CmdOrCtrl+R' },
|
||||
{ role: 'toggleDevTools', accelerator: 'CmdOrCtrl+Shift+I' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit', accelerator: 'CmdOrCtrl+Q' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [{ role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'copy' }, { role: 'paste' }],
|
||||
},
|
||||
]
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 840,
|
||||
minWidth: 1024,
|
||||
minHeight: 700,
|
||||
title: 'Fabric Desktop',
|
||||
icon: path.join(__dirname, 'assets/icon.png'),
|
||||
autoHideMenuBar: false,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
},
|
||||
})
|
||||
|
||||
const url = process.env.FABRIC_DESKTOP_URL || 'file://' + __dirname + '/offline.html'
|
||||
win.loadURL(url)
|
||||
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
win.webContents.on('will-navigate', (event, url) => {
|
||||
const allowedDev = isDev && url.startsWith((process.env.FABRIC_DESKTOP_URL || DEFAULT_DEV_URL))
|
||||
const allowedFile = url.startsWith('file://')
|
||||
if (!allowedDev && !allowedFile) {
|
||||
event.preventDefault()
|
||||
shell.openExternal(url)
|
||||
}
|
||||
})
|
||||
|
||||
const devUrl = process.env.FABRIC_DESKTOP_URL || DEFAULT_DEV_URL
|
||||
if (isDev) {
|
||||
win.loadURL(devUrl)
|
||||
} else {
|
||||
win.loadFile(DEFAULT_PROD_ENTRY)
|
||||
}
|
||||
|
||||
win.on('close', (event) => {
|
||||
if (!isQuitting) {
|
||||
event.preventDefault()
|
||||
win.hide()
|
||||
}
|
||||
})
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
function createTrayIcon() {
|
||||
if (tray) return tray
|
||||
|
||||
const icon = nativeImage.createFromPath(path.join(__dirname, 'assets/tray.png'))
|
||||
|
||||
tray = new Tray(icon)
|
||||
tray.setToolTip('Fabric Desktop')
|
||||
|
||||
const buildTrayMenu = () =>
|
||||
Menu.buildFromTemplate([
|
||||
{
|
||||
label: mainWindow?.isVisible() ? '隐藏窗口' : '显示窗口',
|
||||
click: () => {
|
||||
if (!mainWindow) {
|
||||
mainWindow = createWindow()
|
||||
return
|
||||
}
|
||||
if (mainWindow.isVisible()) mainWindow.hide()
|
||||
else {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
tray.setContextMenu(buildTrayMenu())
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '退出',
|
||||
click: () => {
|
||||
isQuitting = true
|
||||
app.quit()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
tray.setContextMenu(buildTrayMenu())
|
||||
|
||||
tray.on('double-click', () => {
|
||||
if (!mainWindow) {
|
||||
mainWindow = createWindow()
|
||||
return
|
||||
}
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
tray.setContextMenu(buildTrayMenu())
|
||||
})
|
||||
|
||||
return tray
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow()
|
||||
createMenu()
|
||||
mainWindow = createWindow()
|
||||
createTrayIcon()
|
||||
|
||||
ipcMain.handle('fabric:config:get', () => readConfig())
|
||||
ipcMain.handle('fabric:config:set', (_evt, next) => writeConfig(next || {}))
|
||||
ipcMain.handle('fabric:notify', (_evt, payload) => {
|
||||
const title = payload?.title || 'Fabric'
|
||||
const body = payload?.body || ''
|
||||
if (Notification.isSupported()) {
|
||||
new Notification({ title, body }).show()
|
||||
return { ok: true }
|
||||
}
|
||||
return { ok: false }
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
if (BrowserWindow.getAllWindows().length === 0) mainWindow = createWindow()
|
||||
else if (mainWindow && !mainWindow.isVisible()) mainWindow.show()
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
// 保持后台驻留,由托盘控制退出
|
||||
})
|
||||
|
||||
3239
package-lock.json
generated
63
package.json
@@ -2,12 +2,71 @@
|
||||
"name": "fabric-desktop",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Electron desktop shell for Fabric frontend.",
|
||||
"homepage": "https://github.com/hangman0414/Fabric",
|
||||
"author": {
|
||||
"name": "Hangman",
|
||||
"email": "noreply@hangman-lab.top"
|
||||
},
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"start:dev": "FABRIC_DESKTOP_URL=http://localhost:5173 electron ."
|
||||
"start:dev": "FABRIC_DESKTOP_URL=http://localhost:5173 electron .",
|
||||
"build:renderer": "node scripts/build-renderer.mjs",
|
||||
"pack": "npm run build:renderer && electron-builder --dir",
|
||||
"dist": "npm run build:renderer && electron-builder",
|
||||
"dist:linux": "npm run build:renderer && electron-builder --linux AppImage deb tar.gz",
|
||||
"dist:mac": "npm run build:renderer && electron-builder --mac dmg zip",
|
||||
"dist:win": "npm run build:renderer && electron-builder --win nsis zip"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^37.2.1"
|
||||
"electron": "^37.2.1",
|
||||
"electron-builder": "^24.13.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "ai.hangman.fabric.desktop",
|
||||
"productName": "Fabric",
|
||||
"artifactName": "Fabric-Desktop-${version}-${os}-${arch}.${ext}",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"offline.html",
|
||||
"package.json",
|
||||
"assets/**",
|
||||
"renderer/**"
|
||||
],
|
||||
"asar": true,
|
||||
"icon": "build/icon.png",
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb",
|
||||
"tar.gz"
|
||||
],
|
||||
"category": "Utility",
|
||||
"maintainer": "Hangman <noreply@hangman-lab.top>",
|
||||
"executableName": "fabric-desktop",
|
||||
"icon": "build/icons",
|
||||
"desktop": {
|
||||
"Name": "Fabric Desktop",
|
||||
"StartupWMClass": "Fabric Desktop"
|
||||
}
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"category": "public.app-category.productivity"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis",
|
||||
"zip"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7
preload.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('fabricDesktop', {
|
||||
getConfig: () => ipcRenderer.invoke('fabric:config:get'),
|
||||
setConfig: (next) => ipcRenderer.invoke('fabric:config:set', next),
|
||||
notify: (title, body) => ipcRenderer.invoke('fabric:notify', { title, body }),
|
||||
})
|
||||
36
scripts/build-renderer.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build the Fabric web frontend with a relative asset base and copy the
|
||||
* static bundle into Fabric.Desktop/renderer/ so the packaged Electron app
|
||||
* is self-contained (loaded via file://, no separate web server needed).
|
||||
*
|
||||
* Fabric.Frontend is the sibling submodule under the parent Fabric repo.
|
||||
*/
|
||||
import { execSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DESKTOP = path.resolve(__dirname, '..')
|
||||
const FRONTEND = path.resolve(DESKTOP, '..', 'Fabric.Frontend')
|
||||
const SRC = path.join(FRONTEND, 'dist-desktop')
|
||||
const DEST = path.join(DESKTOP, 'renderer')
|
||||
|
||||
if (!fs.existsSync(FRONTEND)) {
|
||||
console.error(`[build-renderer] Fabric.Frontend not found at ${FRONTEND}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('[build-renderer] building frontend (relative base)…')
|
||||
execSync('npm install --no-audit --no-fund', { cwd: FRONTEND, stdio: 'inherit' })
|
||||
execSync('npm run build:desktop', { cwd: FRONTEND, stdio: 'inherit' })
|
||||
|
||||
if (!fs.existsSync(path.join(SRC, 'index.html'))) {
|
||||
console.error(`[build-renderer] expected ${SRC}/index.html after build`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
fs.rmSync(DEST, { recursive: true, force: true })
|
||||
fs.cpSync(SRC, DEST, { recursive: true })
|
||||
console.log(`[build-renderer] copied ${SRC} -> ${DEST}`)
|
||||