Compare commits

...

11 Commits

Author SHA1 Message Date
cb92835d2f docs: rewrite README to match current architecture
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:53:24 +01:00
97c4e7ad96 feat(desktop): self-contained — bundle the frontend
- build:renderer builds Fabric.Frontend with relative base and copies
  the static bundle into renderer/; dist/pack scripts run it first.
- main.js: production loads renderer/index.html when present (falls
  back to offline.html); packaged app no longer needs a separate
  frontend server (still talks to Center/Guild backends, which the
  login screen points at — file:// origin is CORS-allowed).
- package files += renderer/**; gitignore renderer/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:11:43 +01:00
f641d42a8b fix(desktop): crash on launch + wrong icon (deb)
- productName 'Fabric Desktop' put the app in /opt/Fabric Desktop/;
  the space broke Electron's zygote/chrome-sandbox execvp
  (failed to execvp: /opt/Fabric -> FATAL zygote_host, instant
  crash). productName -> 'Fabric' (/opt/Fabric, no space);
  linux.executableName=fabric-desktop, linux.desktop.Name keeps the
  'Fabric Desktop' label.
- Icon landed in hicolor/0x0/apps (theme ignores it -> generic gear).
  Ship a real build/icons/<NxN>.png set and point linux.icon there;
  deb now installs 16..1024 hicolor sizes.

Verified in the rebuilt .deb: /opt/Fabric/fabric-desktop, hicolor
16x16..1024x1024, no zygote core dump on run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:47:38 +01:00
77b6caab7b fix(brand): bolder strokes for app/tray icons
Match the frontend: thicker (dilated) strokes, deeper green
#1DB800, regenerated window/app + tray PNGs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:21:39 +01:00
4473aa1b8b feat(brand): deeper green app/tray icons (#1DB800)
Regenerate window/app (assets/icon.png, build/icon.png) and tray
(tray.png/@2x) with the deeper, fully-saturated green + emboldened
strokes to match the frontend favicon. (Electron uses raster icons;
no SVG.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:16:58 +01:00
5c4770f051 fix(brand): transparent bg + tight crop for app/tray icons
Same treatment as the favicon: transparent background and tight
square crop so the window, app and tray icons fill their frames
instead of a tiny mark on a black box.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:10:58 +01:00
8952b9d3ed feat(brand): use no-text mark for window/app icon
Switch BrowserWindow + electron-builder icon to the no-text mark
(tray was already the no-text mark). Wordmark dropped from app-icon
contexts where it doesn't read small.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:07:34 +01:00
ca51ba7063 feat(brand): apply Fabric app + tray icons
- assets/icon.png (512) -> BrowserWindow icon; build/icon.png (1024)
  for electron-builder (linux uses it directly, mac/win generated from
  it); package.json build.files += assets/**, build.icon set.
- Tray: replace the placeholder base64 data-URL with the designed
  no-text tray icon (assets/tray.png 22 + tray@2x.png 44) via
  nativeImage.createFromPath.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:04:55 +01:00
nav
1476ff7bb4 chore(desktop): set maintainer email for deb packaging 2026-05-13 07:17:04 +00:00
nav
54f4b46755 feat(desktop): add tray behavior and packaging config 2026-05-13 06:57:27 +00:00
nav
5843d3f8ca feat(desktop): add secure electron shell with preload ipc and menu 2026-05-12 16:13:05 +00:00
20 changed files with 3545 additions and 19 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
node_modules/
dist/
renderer/

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
assets/tray.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
assets/tray@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
build/icons/1024x1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
build/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
build/icons/16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 B

BIN
build/icons/24x24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
build/icons/256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
build/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
build/icons/48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
build/icons/512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
build/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

169
main.js
View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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 }),
})

View 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}`)