Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ apps/mobile/android/app/src/main/assets/public/
apps/mobile/android/capacitor-cordova-android-plugins/
apps/mobile/ios/App/App/public/
apps/mobile/ios/DerivedData/
apps/desktop/release/
apps/desktop/native/bin/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Read your sources, ask with citations, write and learn — in one workspace you
[![CI](https://img.shields.io/github/actions/workflow/status/Mar7thLover/QuireNote/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/Mar7thLover/QuireNote/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-C06240?style=flat-square)](LICENSE)
[![Node](https://img.shields.io/badge/node-%E2%89%A522.12-5A5A55?style=flat-square)](https://nodejs.org)
[![Platforms](https://img.shields.io/badge/platforms-web%20%C2%B7%20iOS%20%C2%B7%20Android-5A5A55?style=flat-square)](docs/mobile.md)
[![Platforms](https://img.shields.io/badge/platforms-web%20%C2%B7%20macOS%20%C2%B7%20iOS%20%C2%B7%20Android-5A5A55?style=flat-square)](docs/mobile.md)
[![Docker](https://img.shields.io/badge/deploy-Docker-5A5A55?style=flat-square)](#docker)

**English** · [简体中文](README.zh-CN.md)
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
[![CI](https://img.shields.io/github/actions/workflow/status/Mar7thLover/QuireNote/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/Mar7thLover/QuireNote/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-C06240?style=flat-square)](LICENSE)
[![Node](https://img.shields.io/badge/node-%E2%89%A522.12-5A5A55?style=flat-square)](https://nodejs.org)
[![Platforms](https://img.shields.io/badge/platforms-web%20%C2%B7%20iOS%20%C2%B7%20Android-5A5A55?style=flat-square)](docs/mobile.md)
[![Platforms](https://img.shields.io/badge/platforms-web%20%C2%B7%20macOS%20%C2%B7%20iOS%20%C2%B7%20Android-5A5A55?style=flat-square)](docs/mobile.md)
[![Docker](https://img.shields.io/badge/deploy-Docker-5A5A55?style=flat-square)](#docker-一键部署)

[English](README.md) · **简体中文**
Expand Down
Binary file added apps/desktop/build/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions apps/desktop/electron-builder.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
appId: app.quirenote.notebook
productName: QuireNote
asar: false
directories:
output: release
files:
- src/main.mjs
- package.json
- from: dist
to: server
mac:
category: public.app-category.productivity
icon: build/icon.png
identity: null
target:
- dir
- zip
npmRebuild: false
113 changes: 113 additions & 0 deletions apps/desktop/native/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions apps/desktop/native/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "quirenote-desktop-natives",
"private": true,
"dependencies": {
"better-sqlite3": "13.0.3",
"sqlite-vec": "0.1.9",
"sqlite-vec-darwin-arm64": "0.1.9"
}
}
21 changes: 21 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@quirenote/desktop",
"version": "0.0.0",
"private": true,
"description": "QuireNote desktop app (macOS): Electron shell running the embedded server and web build",
"main": "src/main.mjs",
"scripts": {
"bundle": "node scripts/bundle.mjs",
"natives": "node scripts/natives.mjs",
"setup": "pnpm natives && pnpm bundle",
"dev": "pnpm setup && electron .",
"pack": "pnpm setup && electron-builder --dir",
"dist": "pnpm setup && electron-builder --mac zip"
},
"devDependencies": {
"@electron/rebuild": "^4.0.1",
"electron": "^44.9.0",
"electron-builder": "^26.15.3",
"esbuild": "^0.25.10"
}
}
53 changes: 53 additions & 0 deletions apps/desktop/scripts/bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* 打包内嵌资源:esbuild 把服务端(workspace TS 源码)打成单文件,
* 复制 Web 构建产物与原生依赖到 dist/。结构:
* dist/server.mjs 服务端单文件(better-sqlite3 的 JS 也打进 bundle;
* .node / .dylib 由外壳通过 QUIREENOTE_* env 指定路径)
* dist/bin/* Electron ABI 的原生二进制(由 scripts/natives.mjs 生成)
* dist/web/* apps/web 构建产物
*/
import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { build } from "esbuild";

const desktopDir = resolve(dirname(new URL(import.meta.url).pathname), "..");
const repoRoot = resolve(desktopDir, "../..");
const outDir = join(desktopDir, "dist");
const nativesBin = join(desktopDir, "native/bin");
const webDist = join(repoRoot, "apps/web/dist");

if (!existsSync(join(webDist, "index.html"))) {
console.error("[bundle] 缺少 apps/web/dist,先运行 pnpm --filter @quirenote/web build");
process.exit(1);
}
if (!existsSync(join(nativesBin, "better_sqlite3.node"))) {
console.error("[bundle] 缺少 native/bin,先运行 pnpm --filter @quirenote/desktop natives");
process.exit(1);
}

rmSync(outDir, { recursive: true, force: true });
mkdirSync(outDir, { recursive: true });

await build({
entryPoints: [join(desktopDir, "src/server-entry.mjs")],
bundle: true,
format: "esm",
platform: "node",
target: "node22",
outfile: join(outDir, "server.mjs"),
// better-sqlite3 / sqlite-vec 的 JS 一并打包;.node / .dylib 二进制不进 bundle,
// 运行时由 QUIREENOTE_SQLITE3_BINDING / QUIREENOTE_SQLITE_VEC_EXT 指定。
external: ["*.node", "*.dylib"],
// 服务端的依赖(hono、better-sqlite3 等)在 apps/server/node_modules 下解析
nodePaths: [join(repoRoot, "apps/server/node_modules"), join(repoRoot, "node_modules")],
banner: {
// CJS 依赖里的 require()(含 node builtin)在 ESM 产物中需要真实 require;
// 用别名避开 esbuild 在打包产物里自动注入的同名 import。
js: 'import { createRequire as __cr } from "node:module";\nconst require = __cr(import.meta.url);',
},
});

cpSync(webDist, join(outDir, "web"), { recursive: true });
cpSync(nativesBin, join(outDir, "bin"), { recursive: true });

console.log(`[bundle] server + web + natives → ${outDir}`);
45 changes: 45 additions & 0 deletions apps/desktop/scripts/natives.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* 准备 Electron ABI 的原生模块:在 apps/desktop/native/ 下独立安装
* better-sqlite3 / sqlite-vec,并用 @electron/rebuild 针对 Electron 重编译。
* 独立目录是为了不污染 workspace 里按系统 Node ABI 构建的同名包。
*/
import { execSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { rebuild } from "@electron/rebuild";

const desktopDir = resolve(dirname(new URL(import.meta.url).pathname), "..");
const nativeDir = join(desktopDir, "native");
const require = createRequire(import.meta.url);

const electronVersion = JSON.parse(
readFileSync(require.resolve("electron/package.json", { paths: [desktopDir] }), "utf8"),
).version;

if (!existsSync(join(nativeDir, "node_modules/better-sqlite3"))) {
console.log(`[natives] 安装原生依赖(better-sqlite3 / sqlite-vec)…`);
execSync("npm install --omit=dev --no-audit --no-fund", { cwd: nativeDir, stdio: "inherit" });
}

console.log(`[natives] 针对 Electron ${electronVersion} 重编译 better-sqlite3…`);
await rebuild({
buildPath: nativeDir,
electronVersion,
arch: process.arch,
force: true,
onlyModules: ["better-sqlite3"],
});

// 只导出运行时真正需要的两个二进制,打包时随 dist/bin 进 .app(不进 node_modules)
const binDir = join(nativeDir, "bin");
mkdirSync(binDir, { recursive: true });
copyFileSync(
join(nativeDir, `node_modules/better-sqlite3/prebuilds/${process.platform}-${process.arch}.node`),
join(binDir, "better_sqlite3.node"),
);
copyFileSync(
join(nativeDir, `node_modules/sqlite-vec-${process.platform}-${process.arch}/vec0.dylib`),
join(binDir, "vec0.dylib"),
);
console.log(`[natives] 完成,二进制在 ${binDir}`);
102 changes: 102 additions & 0 deletions apps/desktop/src/main.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* QuireNote 桌面端(macOS)主进程:内嵌服务端 + 内置 Web 界面。
* 数据保存在 userData/data,与自托管部署同构但开箱即用。
*/
import { createServer } from "node:net";
import { dirname, join } from "node:path";
import { pathToFileURL } from "node:url";
import { app, BrowserWindow, dialog, shell } from "electron";

app.setName("QuireNote");

const here = dirname(new URL(import.meta.url).pathname);
// 打包后资源在 resources/app/server/(files 配置),开发态在 apps/desktop/dist/
const resourceBase = app.isPackaged ? join(process.resourcesPath, "app", "server") : join(here, "..", "dist");
const serverBundle = join(resourceBase, "server.mjs");
const webDist = join(resourceBase, "web");
const binDir = join(resourceBase, "bin");

let closing = false;
let closeServer = async () => {};

function freePort() {
return new Promise((resolve, reject) => {
const s = createServer();
s.once("error", reject);
s.listen(0, "127.0.0.1", () => {
const { port } = s.address();
s.close(() => resolve(port));
});
});
}

async function startServer() {
const port = await freePort();
process.env.PORT = String(port);
process.env.QUIRENOTE_DATA_DIR = join(app.getPath("userData"), "data");
process.env.QUIRENOTE_WEB_DIST = webDist;
process.env.QUIRENOTE_SQLITE3_BINDING = join(binDir, "better_sqlite3.node");
process.env.QUIRENOTE_SQLITE_VEC_EXT = join(binDir, "vec0.dylib");
// 桌面端默认关闭种子数据可通过 QUIREENOTE_SEED=0;保持默认与 Docker 一致。
const mod = await import(pathToFileURL(serverBundle).href);
closeServer = mod.close;
return port;
}

async function waitForHealth(port, timeoutMs = 30000) {
const url = `http://127.0.0.1:${port}/api/health`;
const deadline = Date.now() + timeoutMs;
let lastErr;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (res.ok) return;
lastErr = new Error(`health ${res.status}`);
} catch (e) {
lastErr = e;
}
await new Promise((r) => setTimeout(r, 200));
}
throw lastErr ?? new Error("server did not become healthy");
}

async function createMainWindow(port) {
const win = new BrowserWindow({
width: 1440,
height: 960,
minWidth: 960,
minHeight: 600,
title: "QuireNote",
backgroundColor: "#141311",
webPreferences: { contextIsolation: true, nodeIntegration: false },
});
// 外链交给系统浏览器,应用内只导航本机地址
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: "deny" };
});
await win.loadURL(`http://127.0.0.1:${port}`);
}

app.whenReady().then(async () => {
try {
const port = await startServer();
await waitForHealth(port);
await createMainWindow(port);
} catch (err) {
console.error(err);
dialog.showErrorBox("QuireNote 启动失败", String(err?.stack ?? err));
app.exit(1);
}
});

app.on("window-all-closed", () => app.quit());

app.on("before-quit", (e) => {
if (closing) return;
e.preventDefault();
closing = true;
closeServer()
.catch(() => {})
.finally(() => app.quit());
});
Loading
Loading