> For the complete documentation index, see [llms.txt](https://408550179s-organization.gitbook.io/blog/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://408550179s-organization.gitbook.io/blog/electron-hou-tai-jing-mo-yun-xing-ben-di-zi-ti-http-fu-wu.md).

# Electron后台静默运行本地字体HTTP服务

设计工具、画布编辑器、在线海报工具经常会遇到一个问题：浏览器页面不能直接读取用户本机字体文件。

如果只是列出字体名，可以依赖浏览器已有字体渲染。但如果要做字体预览、字体文件加载、服务端生成图片，或者设计器里读取本机字体，就需要一个本地辅助服务。

我这里的方案是用 Electron 启动一个后台 HTTP 服务：

* 应用启动后不显示窗口。
* 默认监听 `127.0.0.1:3838`。
* 提供字体列表接口。
* 下载字体文件时做路径校验。
* 支持开机自启。
* macOS 下自动尝试清除下载隔离属性。

## 字体目录扫描

不同系统的字体目录不一样，可以先按平台区分：

```js
function getSystemFonts({ forceRefresh = false } = {}) {
  const fonts = [];
  const seen = new Set();
  const platform = process.platform;

  if (platform === "darwin") {
    const systemDirs = ["/System/Library/Fonts", "/Library/Fonts"];
    const userDirs = [path.join(os.homedir(), "Library/Fonts")];
    systemDirs.forEach((dir) => fonts.push(...scanFontsDir(dir, true, seen)));
    userDirs.forEach((dir) => fonts.push(...scanFontsDir(dir, false, seen)));
  } else if (platform === "win32") {
    const systemDir = path.join(process.env.WINDIR || "C:\\Windows", "Fonts");
    const userDir = path.join(os.homedir(), "AppData", "Local", "Microsoft", "Windows", "Fonts");
    fonts.push(...scanFontsDir(systemDir, true, seen));
    fonts.push(...scanFontsDir(userDir, false, seen));
  } else {
    const systemDirs = ["/usr/share/fonts", "/usr/local/share/fonts"];
    const userDirs = [path.join(os.homedir(), ".fonts")];
    systemDirs.forEach((dir) => fonts.push(...scanFontsDir(dir, true, seen)));
    userDirs.forEach((dir) => fonts.push(...scanFontsDir(dir, false, seen)));
  }

  return fonts;
}
```

递归扫描时只收集字体文件：

```js
const FONT_EXT = /\.(ttf|otf|ttc|woff|woff2)$/i;

function scanFontsDir(dir, system = true, seen = new Set()) {
  const fonts = [];
  if (!fs.existsSync(dir)) return fonts;

  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const fullPath = path.join(dir, entry.name);

    if (entry.isDirectory()) {
      fonts.push(...scanFontsDir(fullPath, system, seen));
    } else if (entry.isFile() && FONT_EXT.test(entry.name)) {
      if (!seen.has(fullPath)) {
        fonts.push({
          name: path.parse(entry.name).name,
          path: fullPath,
          system,
          ext: path.extname(entry.name).slice(1).toLowerCase(),
        });
        seen.add(fullPath);
      }
    }
  }

  return fonts;
}
```

## 加一层缓存

字体目录扫描可能比较慢，尤其 Windows 字体目录文件多时。可以加一个短缓存：

```js
let fontCache = null;
let fontCacheAt = 0;
const FONT_CACHE_TTL_MS = 60_000;

function getFontsWithCache({ forceRefresh = false } = {}) {
  const now = Date.now();
  if (!forceRefresh && fontCache && now - fontCacheAt < FONT_CACHE_TTL_MS) {
    return fontCache;
  }

  fontCache = getSystemFonts({ forceRefresh });
  fontCacheAt = now;
  return fontCache;
}
```

接口里可以通过 `?refresh=1` 强制刷新：

```js
app.get("/fonts", (req, res) => {
  const forceRefresh = req.query.refresh === "1" || req.query.refresh === "true";
  const fonts = getFontsWithCache({ forceRefresh });

  res.json({
    status: 200,
    count: fonts.length,
    cached: !forceRefresh,
    fonts,
  });
});
```

## 下载字体文件必须做白名单校验

不要因为本地服务就直接 `sendFile(req.query.path)`，否则会变成任意文件读取。

正确做法是：传入路径后，先确认它存在于扫描出的字体列表中。

```js
function resolveFontPath(encodedPath) {
  const fontPath = decodeURIComponent(encodedPath);
  if (!path.isAbsolute(fontPath)) return null;

  const fonts = getFontsWithCache();
  const match = fonts.find((font) => font.path === fontPath);
  return match ? match.path : null;
}

app.get("/font", (req, res) => {
  const encodedPath = req.query.path;
  if (!encodedPath || typeof encodedPath !== "string") {
    res.status(400).json({ error: "Missing path query parameter" });
    return;
  }

  const fontPath = resolveFontPath(encodedPath);
  if (!fontPath || !fs.existsSync(fontPath)) {
    res.status(404).json({ error: "Font not found" });
    return;
  }

  res.sendFile(fontPath);
});
```

## Electron 静默启动

Electron 主进程里不创建窗口，只启动服务：

```js
const { app } = require("electron");
const server = require("./server");

const gotLock = app.requestSingleInstanceLock();

if (!gotLock) {
  app.quit();
} else {
  app.whenReady().then(() => {
    if (process.platform === "darwin") {
      app.dock.hide();
    }

    server.start(app, Number(process.env.FONT_SERVER_PORT) || 3838);

    app.setLoginItemSettings({
      openAtLogin: true,
      path: app.getPath("exe"),
    });
  });

  app.on("window-all-closed", (event) => {
    event.preventDefault();
  });
}
```

## macOS 隔离属性处理

没有签名的 Electron App，从浏览器下载后 macOS 可能提示“已损坏”。如果是 DMG 拖拽安装，没有安装脚本可以执行，所以可以在应用启动时尝试清除自身隔离属性：

```js
function clearMacQuarantine() {
  if (process.platform !== "darwin" || !app.isPackaged) return;

  try {
    const bundlePath = path.resolve(process.execPath, "../../..");
    if (!bundlePath.endsWith(".app")) return;
    execFileSync("/usr/bin/xattr", ["-cr", bundlePath], { stdio: "ignore" });
  } catch {
    // 没权限或者已经清除时忽略
  }
}
```

## 总结

本地字体服务看起来是一个小工具，但里面有几个工程点很值得注意：

* 只监听 `127.0.0.1`，避免暴露到局域网。
* 下载字体文件前必须做白名单校验。
* 字体扫描要做缓存。
* Electron 要做单实例运行。
* macOS/Windows 打包安装体验要单独处理。

这类本地辅助服务很适合配合网页设计器、低代码编辑器、海报工具使用。
