fluxplugin 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -9
- package/dist/cli.js +148 -206
- package/dist/node/cli-api.js +148 -206
- package/dist/types/cli/host-files.generated.d.ts +1 -0
- package/dist/types/cli/host.d.ts +2 -1
- package/docs/screenshots/electron-app.png +0 -0
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -38,13 +38,25 @@ export default definePlugin({ import { usePluginIPC } from
|
|
|
38
38
|
```
|
|
39
39
|
More: [ARCHITECTURE](docs/ARCHITECTURE.md) · [SECURITY](docs/SECURITY.md) · [PLUGIN-AUTHORS](docs/PLUGIN-AUTHORS.md) · [RELEASE](docs/RELEASE.md)
|
|
40
40
|
|
|
41
|
+
## What `fluxplugin init` generates
|
|
42
|
+
All application code lives in `src/app`, in layers (`shared` → `main` / `preload` / `renderer`):
|
|
43
|
+
```
|
|
44
|
+
src/app/
|
|
45
|
+
shared/ constants.ts · types.ts (depends on nothing)
|
|
46
|
+
main/ index.ts (Electron) · web.ts (browser) · framework.ts · operations.ts · window.ts · paths.ts
|
|
47
|
+
preload/ index.ts
|
|
48
|
+
renderer/ index.tsx · bootstrap.ts · extension-points.ts · routes.ts · App.tsx
|
|
49
|
+
providers/ · shell/ · pages/ · hooks/ · services/ · styles/
|
|
50
|
+
plugins/ loaded at startup
|
|
51
|
+
```
|
|
52
|
+
|
|
41
53
|
## Status — read this
|
|
42
|
-
* **Verified:** 121
|
|
43
|
-
(`init` → `npm install` → typecheck → `build`
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
* **Not verified:**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
* **Security:** plugin `main` code is not sandboxed (
|
|
50
|
-
* **Not done:** per-plugin process isolation, hosted marketplace (only
|
|
54
|
+
* **Verified:** 121 tests; strict typecheck; the packed tarball installed into a freshly generated app
|
|
55
|
+
(`init` → `npm install` → typecheck → `build`); the generated app run **in real Electron 33** (under Xvfb, Linux) through
|
|
56
|
+
`fluxplugin dev`: window loads, preload bridge present, **no Node globals leak into the page**, plugin menus, hook-wrapped
|
|
57
|
+
host operation, live plugin disable — and in browser mode with a Playwright E2E (3 plugins).
|
|
58
|
+
* **Not verified:** Windows and macOS (only Linux was available), packaging an installer (electron-builder/forge is not set up).
|
|
59
|
+
* **Linux note:** if Electron's sandbox helper is not setuid-root (or you run as root) `fluxplugin dev` starts it with
|
|
60
|
+
`--no-sandbox` and tells you how to fix it. Extra flags: `FLUXPLUGIN_ELECTRON_ARGS="--remote-debugging-port=9222"`.
|
|
61
|
+
* **Not published** on npm — see `docs/RELEASE.md`. **Security:** plugin `main` code is not sandboxed (see `docs/SECURITY.md`).
|
|
62
|
+
* **Not done:** per-plugin process isolation, hosted marketplace (only `PluginRegistry` + local/HTTP), Tailwind wiring.
|
package/dist/cli.js
CHANGED
|
@@ -892,7 +892,7 @@ async function buildPlugin(root, watch = false, log2 = console.log) {
|
|
|
892
892
|
|
|
893
893
|
// src/cli/host.ts
|
|
894
894
|
import { spawn } from "node:child_process";
|
|
895
|
-
import { promises as fs7 } from "node:fs";
|
|
895
|
+
import { existsSync, promises as fs7, statSync } from "node:fs";
|
|
896
896
|
import { createRequire } from "node:module";
|
|
897
897
|
import path8 from "node:path";
|
|
898
898
|
import { build as build2, context as context2 } from "esbuild";
|
|
@@ -915,17 +915,25 @@ async function loadConfig(flags, cwd = process.cwd()) {
|
|
|
915
915
|
// src/cli/host.ts
|
|
916
916
|
var exists2 = (f) => fs7.access(f).then(() => true, () => false);
|
|
917
917
|
async function isHostProject(cwd) {
|
|
918
|
-
|
|
918
|
+
if (await exists2(path8.join(cwd, "fluxplugin.config.json"))) return true;
|
|
919
|
+
return await exists2(path8.join(cwd, "src/app/renderer/index.tsx")) || await exists2(path8.join(cwd, "src/renderer.tsx"));
|
|
919
920
|
}
|
|
920
921
|
var nodeBanner = { js: "import { createRequire as __r } from 'node:module'; const require = __r(import.meta.url);" };
|
|
922
|
+
var pick = (cwd, ...rels) => {
|
|
923
|
+
for (const r of rels) if (existsSync(path8.join(cwd, r))) return path8.join(cwd, r);
|
|
924
|
+
return path8.join(cwd, rels[0]);
|
|
925
|
+
};
|
|
921
926
|
function hostBuilds(cwd, outDir, dev) {
|
|
922
927
|
const common = { absWorkingDir: cwd, bundle: true, sourcemap: dev, logLevel: "warning", jsx: "automatic", define: { "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production") } };
|
|
923
|
-
const
|
|
928
|
+
const renderer = pick(cwd, "src/app/renderer/index.tsx", "src/renderer.tsx");
|
|
929
|
+
const main = pick(cwd, "src/app/main/index.ts", "src/main.ts");
|
|
930
|
+
const preload = pick(cwd, "src/app/preload/index.ts", "src/preload.ts");
|
|
931
|
+
const web = pick(cwd, "src/app/main/web.ts", "src/web.ts");
|
|
924
932
|
return [
|
|
925
|
-
{ ...common, entryPoints: [
|
|
926
|
-
{ ...common, entryPoints: [
|
|
927
|
-
{ ...common, entryPoints: [
|
|
928
|
-
{ ...common, entryPoints: [
|
|
933
|
+
{ ...common, entryPoints: [renderer], outfile: path8.join(outDir, "renderer.js"), platform: "browser", format: "esm", target: "chrome120" },
|
|
934
|
+
{ ...common, entryPoints: [main], outfile: path8.join(outDir, "main.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
|
|
935
|
+
{ ...common, entryPoints: [preload], outfile: path8.join(outDir, "preload.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
|
|
936
|
+
{ ...common, entryPoints: [web], outfile: path8.join(outDir, "web.mjs"), platform: "node", format: "esm", target: "node20", external: ["electron"], banner: nodeBanner }
|
|
929
937
|
];
|
|
930
938
|
}
|
|
931
939
|
async function pluginDirs(cwd, pluginsDir) {
|
|
@@ -941,11 +949,12 @@ async function buildHost(cwd, opts = {}) {
|
|
|
941
949
|
const cfg = await loadConfig({}, cwd);
|
|
942
950
|
const outDir = path8.resolve(cwd, cfgFile.outDir ?? "dist");
|
|
943
951
|
await fs7.mkdir(outDir, { recursive: true });
|
|
944
|
-
await fs7.copyFile(
|
|
952
|
+
await fs7.copyFile(pick(cwd, "src/app/renderer/index.html", "src/index.html"), path8.join(outDir, "index.html"));
|
|
945
953
|
const disposers = [];
|
|
946
954
|
const opts2 = hostBuilds(cwd, outDir, !!opts.watch);
|
|
947
955
|
if (opts.watch) {
|
|
948
956
|
const ctxs = await Promise.all(opts2.map((o) => context2(o)));
|
|
957
|
+
await Promise.all(ctxs.map((c) => c.rebuild()));
|
|
949
958
|
await Promise.all(ctxs.map((c) => c.watch()));
|
|
950
959
|
disposers.push(async () => void await Promise.all(ctxs.map((c) => c.dispose())));
|
|
951
960
|
} else for (const o of opts2) await build2(o);
|
|
@@ -962,17 +971,115 @@ async function buildHost(cwd, opts = {}) {
|
|
|
962
971
|
function electronBinary(cwd) {
|
|
963
972
|
try {
|
|
964
973
|
const p = createRequire(path8.join(cwd, "package.json"))("electron");
|
|
965
|
-
|
|
974
|
+
if (typeof p === "string" && existsSync(p)) return { bin: p };
|
|
975
|
+
return { error: "the Electron binary was not found" };
|
|
976
|
+
} catch (e) {
|
|
977
|
+
return { error: e.message.split("\n")[0] };
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
function needsNoSandbox(bin) {
|
|
981
|
+
if (process.platform !== "linux") return false;
|
|
982
|
+
if (process.getuid?.() === 0) return true;
|
|
983
|
+
try {
|
|
984
|
+
const st = statSync(path8.join(path8.dirname(bin), "chrome-sandbox"));
|
|
985
|
+
return !(st.uid === 0 && (st.mode & 2048) !== 0);
|
|
966
986
|
} catch {
|
|
967
|
-
return
|
|
987
|
+
return false;
|
|
968
988
|
}
|
|
969
989
|
}
|
|
970
990
|
function startApp(cwd, opts = {}) {
|
|
971
|
-
const
|
|
991
|
+
const log2 = opts.log ?? console.log;
|
|
972
992
|
const env = { ...process.env, FLUXPLUGIN_PROJECT: cwd, ...opts.dev ? { FLUXPLUGIN_DEV: "1" } : {}, ...opts.port ? { PORT: String(opts.port) } : {} };
|
|
973
|
-
if (
|
|
974
|
-
|
|
993
|
+
if (opts.web) return { child: spawn(process.execPath, [path8.join(cwd, "dist/web.mjs")], { cwd, env, stdio: "inherit" }), mode: "web" };
|
|
994
|
+
const { bin, error } = electronBinary(cwd);
|
|
995
|
+
if (!bin) throw new Error(`cannot start Electron: ${error}.
|
|
996
|
+
Install it with: npm install -D electron (then re-run)
|
|
997
|
+
or run in a browser: fluxplugin ${opts.dev ? "dev" : "start"} --web`);
|
|
998
|
+
const args = [...process.env["FLUXPLUGIN_ELECTRON_ARGS"]?.split(" ").filter(Boolean) ?? [], path8.join(cwd, "dist/main.cjs")];
|
|
999
|
+
if (needsNoSandbox(bin)) {
|
|
1000
|
+
args.unshift("--no-sandbox");
|
|
1001
|
+
log2("! Linux: Electron's sandbox helper is not configured (or you run as root) \u2192 starting with --no-sandbox.\n To fix permanently: sudo chown root node_modules/electron/dist/chrome-sandbox && sudo chmod 4755 node_modules/electron/dist/chrome-sandbox");
|
|
1002
|
+
}
|
|
1003
|
+
const started = Date.now();
|
|
1004
|
+
const child = spawn(bin, args, { cwd, env, stdio: "inherit" });
|
|
1005
|
+
child.on("exit", (code) => {
|
|
1006
|
+
if (code && Date.now() - started < 5e3) log2(`Electron exited with code ${code} right after starting. Scroll up for its error, or try: fluxplugin dev --web`);
|
|
1007
|
+
});
|
|
1008
|
+
return { child, mode: "electron" };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// src/cli/host-files.generated.ts
|
|
1012
|
+
var HOST_APP_FILES = {
|
|
1013
|
+
"src/app/main/framework.ts": `import { MainFramework } from "fluxplugin/main";
|
|
1014
|
+
import { CHANNELS } from "../shared/constants";
|
|
1015
|
+
import { registerOperations } from "./operations";
|
|
1016
|
+
|
|
1017
|
+
export interface FrameworkOptions {
|
|
1018
|
+
pluginDirs: string[];
|
|
1019
|
+
dataDir: string;
|
|
1020
|
+
dev?: boolean;
|
|
1021
|
+
assetUrl?: (id: string, rel: string) => string;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/** Creates the plugin framework. Shared by the Electron entry (index.ts) and the browser entry (web.ts). */
|
|
1025
|
+
export function createFramework(opts: FrameworkOptions): MainFramework {
|
|
1026
|
+
const fw = new MainFramework({
|
|
1027
|
+
pluginDirs: opts.pluginDirs,
|
|
1028
|
+
dataDir: opts.dataDir,
|
|
1029
|
+
hotReload: opts.dev,
|
|
1030
|
+
assetUrl: opts.assetUrl,
|
|
1031
|
+
requireApproval: false, // true \u2192 users must approve each plugin's permissions
|
|
1032
|
+
allowedRendererChannels: [CHANNELS.createItem],
|
|
1033
|
+
});
|
|
1034
|
+
registerOperations(fw);
|
|
1035
|
+
return fw;
|
|
1036
|
+
}
|
|
1037
|
+
`,
|
|
1038
|
+
"src/app/main/index.ts": '// Electron entry (main process). Keep it thin: wiring only, logic lives in the other files of this folder.\nimport { app, ipcMain, protocol, webContents } from "electron";\nimport path from "node:path";\nimport { PLUGIN_SCHEME, attachElectron, registerPluginProtocol } from "fluxplugin/main";\nimport { createFramework } from "./framework";\nimport { pluginDirs, projectRoot } from "./paths";\nimport { createMainWindow } from "./window";\n\nprotocol.registerSchemesAsPrivileged([PLUGIN_SCHEME]); // must run before the app is ready\n\napp.whenReady().then(async () => {\n const fw = createFramework({\n pluginDirs: pluginDirs(projectRoot(__dirname), app.getPath("userData")),\n dataDir: path.join(app.getPath("userData"), "plugins-data"),\n dev: !!process.env["FLUXPLUGIN_DEV"],\n });\n registerPluginProtocol(fw, protocol);\n attachElectron(fw, { ipcMain: ipcMain as never, webContents: webContents as never });\n await fw.start();\n createMainWindow(__dirname);\n app.on("before-quit", () => void fw.stop());\n});\n\napp.on("window-all-closed", () => app.quit());\n',
|
|
1039
|
+
"src/app/main/operations.ts": 'import { s } from "fluxplugin";\nimport type { MainFramework } from "fluxplugin/main";\nimport { CHANNELS, OPERATIONS } from "../shared/constants";\nimport type { Item } from "../shared/types";\n\n/**\n * Host operations. Each one runs through the hook pipeline, so plugins can validate, normalize or veto it\n * (before.item.create, transform.item.create, ...) without touching this file.\n */\nexport function registerOperations(fw: MainFramework): void {\n fw.manager.ipc.handle(\n CHANNELS.createItem,\n (input: { name: string }) =>\n fw.manager.hooks.run(OPERATIONS.createItem, input, (i) => {\n const item: Item = { id: Math.random().toString(36).slice(2, 8), name: i.name };\n fw.manager.events.emit("item.created", item, "host");\n return item as unknown as Record<string, unknown>;\n }),\n "host",\n { input: s.object({ name: s.string() }) },\n );\n}\n',
|
|
1040
|
+
"src/app/main/paths.ts": 'import path from "node:path";\n\n/** Project root: set by `fluxplugin dev|start`; falls back to the folder above dist/ (packaged app). */\nexport function projectRoot(distDir: string): string {\n return process.env["FLUXPLUGIN_PROJECT"] ?? path.resolve(distDir, "..");\n}\n\n/** Plugins bundled with the app (read-only) and plugins installed by the user (writable). */\nexport function pluginDirs(root: string, userData: string): string[] {\n return [path.join(root, "plugins"), path.join(userData, "plugins")];\n}\n',
|
|
1041
|
+
"src/app/main/web.ts": '// Browser entry: `fluxplugin dev --web` (no Electron needed).\nimport path from "node:path";\nimport { serveWeb } from "fluxplugin/main";\nimport { APP_NAME } from "../shared/constants";\nimport { createFramework } from "./framework";\n\nconst root = process.env["FLUXPLUGIN_PROJECT"] ?? process.cwd();\nconst fw = createFramework({\n pluginDirs: [path.join(root, "plugins")],\n dataDir: path.join(root, ".flux-data"),\n dev: true,\n assetUrl: (id, rel) => "/@plugins/" + id + "/" + rel.replace("./", ""),\n});\nawait fw.start();\nconst srv = await serveWeb(fw, { distDir: path.join(root, "dist"), port: Number(process.env["PORT"] ?? 5173) });\nconsole.log(APP_NAME + " -> http://127.0.0.1:" + srv.port);\n',
|
|
1042
|
+
"src/app/main/window.ts": 'import { BrowserWindow } from "electron";\nimport path from "node:path";\n\nexport function createMainWindow(distDir: string): BrowserWindow {\n const win = new BrowserWindow({\n width: 1100,\n height: 760,\n show: false,\n webPreferences: {\n preload: path.join(distDir, "preload.cjs"),\n contextIsolation: true,\n sandbox: true,\n nodeIntegration: false,\n },\n });\n win.once("ready-to-show", () => win.show());\n // The app never navigates away and never opens new windows.\n win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));\n win.webContents.on("will-navigate", (e) => e.preventDefault());\n void win.loadFile(path.join(distDir, "index.html"));\n return win;\n}\n',
|
|
1043
|
+
"src/app/preload/index.ts": 'import { contextBridge, ipcRenderer } from "electron";\nimport { exposeFluxBridge } from "fluxplugin/preload";\n\n// The only thing renderer code receives from the preload: window.__fluxplugin\nexposeFluxBridge(contextBridge, ipcRenderer);\n',
|
|
1044
|
+
"src/app/renderer/App.tsx": 'import type { FluxBridge } from "fluxplugin";\nimport type { RendererFramework } from "fluxplugin/react";\nimport { AppProviders } from "./providers/AppProviders";\nimport { AppShell } from "./shell/AppShell";\n\nexport function App({ framework, bridge }: { framework: RendererFramework; bridge: FluxBridge }) {\n return (\n <AppProviders framework={framework} bridge={bridge}>\n <AppShell />\n </AppProviders>\n );\n}\n',
|
|
1045
|
+
"src/app/renderer/bootstrap.ts": 'import React from "react";\nimport * as jsxRuntime from "react/jsx-runtime";\nimport * as ReactDOMClient from "react-dom/client";\nimport * as flux from "fluxplugin";\nimport * as fluxReact from "fluxplugin/react";\nimport type { FluxBridge } from "fluxplugin";\nimport { RendererFramework, createHttpBridge, installSharedModules } from "fluxplugin/react";\nimport { declareExtensionPoints } from "./extension-points";\nimport { registerHostRoutes } from "./routes";\n\n/**\n * Infrastructure layer: connects to the main process, shares singletons with plugin bundles,\n * declares what plugins may extend, registers the host\'s own routes, then starts the plugins.\n */\nexport async function bootstrap(): Promise<{ framework: RendererFramework; bridge: FluxBridge }> {\n // One copy of React and of the runtime: plugin bundles import these from globalThis.\n installSharedModules({\n react: React,\n "react/jsx-runtime": jsxRuntime,\n "react/jsx-dev-runtime": jsxRuntime,\n "react-dom/client": ReactDOMClient,\n fluxplugin: flux,\n "fluxplugin/react": fluxReact,\n });\n\n // Electron: the preload bridge. Browser mode: HTTP bridge to `fluxplugin dev --web`.\n const bridge: FluxBridge = (window as unknown as { __fluxplugin?: FluxBridge }).__fluxplugin ?? createHttpBridge();\n const framework = new RendererFramework({ bridge });\n\n declareExtensionPoints(framework); // BEFORE plugins start\n registerHostRoutes(framework);\n await framework.start();\n return { framework, bridge };\n}\n',
|
|
1046
|
+
"src/app/renderer/extension-points.ts": 'import { s } from "fluxplugin";\nimport type { RendererFramework } from "fluxplugin/react";\nimport { EXTENSION_POINTS } from "../shared/constants";\n\n/** Declare what plugins may contribute. Contributions are validated against these schemas. */\nexport function declareExtensionPoints(framework: RendererFramework): void {\n framework.manager.extensionPoints.declare({\n name: EXTENSION_POINTS.dashboard,\n contributions: {\n toolbar: s.object({ id: s.string(), label: s.string(), command: s.string().optional() }),\n },\n });\n}\n',
|
|
1047
|
+
"src/app/renderer/hooks/useCreateItem.ts": 'import { useState } from "react";\nimport { useBridge } from "../providers/AppProviders";\nimport { itemsApi } from "../services/items";\n\n/** Calls the host operation (which plugins may rewrite or veto through hooks) and exposes the outcome. */\nexport function useCreateItem() {\n const bridge = useBridge();\n const [result, setResult] = useState("");\n const create = async (name: string) => {\n try {\n setResult(JSON.stringify(await itemsApi.create(bridge, name)));\n } catch (e) {\n setResult("blocked: " + (e as Error).message);\n }\n };\n return { create, result };\n}\n',
|
|
1048
|
+
"src/app/renderer/index.html": `<!doctype html>
|
|
1049
|
+
<html>
|
|
1050
|
+
<head>
|
|
1051
|
+
<meta charset="utf-8" />
|
|
1052
|
+
<title>__APP_NAME__</title>
|
|
1053
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self' fluxplugin:; script-src 'self' fluxplugin:; style-src 'self' 'unsafe-inline' fluxplugin:; connect-src 'self'; img-src 'self' data:" />
|
|
1054
|
+
<link rel="stylesheet" href="./renderer.css" />
|
|
1055
|
+
</head>
|
|
1056
|
+
<body>
|
|
1057
|
+
<div id="root"></div>
|
|
1058
|
+
<script type="module" src="./renderer.js"></script>
|
|
1059
|
+
</body>
|
|
1060
|
+
</html>
|
|
1061
|
+
`,
|
|
1062
|
+
"src/app/renderer/index.tsx": '// Renderer entry. Order matters: styles \u2192 bootstrap (infrastructure) \u2192 mount (UI).\nimport "./styles/app.css";\nimport { createRoot } from "react-dom/client";\nimport { bootstrap } from "./bootstrap";\nimport { App } from "./App";\n\nconst { framework, bridge } = await bootstrap();\ncreateRoot(document.getElementById("root")!).render(<App framework={framework} bridge={bridge} />);\n',
|
|
1063
|
+
"src/app/renderer/pages/DashboardPage.tsx": 'import { PluginSlot, useExtensionItems, usePluginFramework } from "fluxplugin/react";\nimport { EXTENSION_POINTS, SLOTS } from "../../shared/constants";\nimport type { ToolbarAction } from "../../shared/types";\nimport { useCreateItem } from "../hooks/useCreateItem";\n\nexport function DashboardPage() {\n const framework = usePluginFramework();\n const actions = useExtensionItems<ToolbarAction>(EXTENSION_POINTS.dashboard, "toolbar");\n const { create, result } = useCreateItem();\n return (\n <div>\n <h2>Dashboard</h2>\n <PluginSlot name={SLOTS.dashboardBefore} />\n <p className="hint">These buttons are contributed by plugins through a host-declared extension point.</p>\n <div className="row" data-testid="toolbar">\n {actions.map((a) => (\n <button key={a.id} className="btn" onClick={() => a.command && void framework.manager.commands.execute(a.command)}>\n {a.label}\n </button>\n ))}\n </div>\n <div className="row">\n <button className="btn" onClick={() => void create(" Ada ")}>Create item \u201C Ada \u201D (plugin hooks may rewrite it)</button>\n <button className="btn" onClick={() => void create(" ")}>Create empty item (a plugin may veto)</button>\n </div>\n <pre data-testid="hook-result" className="hint">{result}</pre>\n <PluginSlot name={SLOTS.dashboardAfter} />\n </div>\n );\n}\n',
|
|
1064
|
+
"src/app/renderer/pages/PluginsPage.tsx": 'import { PluginManagerUI } from "fluxplugin/manager-ui";\n\n/** Ready-made plugin manager (enable/disable, permissions, settings, logs, install). Replace with your own UI if you like. */\nexport function PluginsPage() {\n return <PluginManagerUI />;\n}\n',
|
|
1065
|
+
"src/app/renderer/providers/AppProviders.tsx": 'import { createContext, useContext, type ReactNode } from "react";\nimport type { FluxBridge } from "fluxplugin";\nimport { PluginProvider, StyleHost, baseCss, type RendererFramework } from "fluxplugin/react";\n\nconst BridgeContext = createContext<FluxBridge | null>(null);\n\n/** Gives pages access to the main-process bridge without importing globals. */\nexport function useBridge(): FluxBridge {\n const b = useContext(BridgeContext);\n if (!b) throw new Error("useBridge must be used inside <AppProviders>");\n return b;\n}\n\nexport function AppProviders({ framework, bridge, children }: { framework: RendererFramework; bridge: FluxBridge; children: ReactNode }) {\n return (\n <PluginProvider framework={framework}>\n <BridgeContext.Provider value={bridge}>\n <style>{baseCss}</style>\n <StyleHost />\n {children}\n </BridgeContext.Provider>\n </PluginProvider>\n );\n}\n',
|
|
1066
|
+
"src/app/renderer/routes.ts": `import type { RendererFramework } from "fluxplugin/react";
|
|
1067
|
+
import { DashboardPage } from "./pages/DashboardPage";
|
|
1068
|
+
import { PluginsPage } from "./pages/PluginsPage";
|
|
1069
|
+
|
|
1070
|
+
/** The host's own pages (owner "host"). Plugins add theirs at runtime through the same registry. */
|
|
1071
|
+
export function registerHostRoutes(framework: RendererFramework): void {
|
|
1072
|
+
framework.manager.routes.register({ path: "/", component: DashboardPage, meta: { title: "Dashboard" } }, "host");
|
|
1073
|
+
framework.manager.routes.register({ path: "/plugins", component: PluginsPage, meta: { title: "Plugins", breadcrumb: "Plugins" } }, "host");
|
|
975
1074
|
}
|
|
1075
|
+
`,
|
|
1076
|
+
"src/app/renderer/services/items.ts": 'import type { FluxBridge } from "fluxplugin";\nimport { CHANNELS } from "../../shared/constants";\nimport type { Item } from "../../shared/types";\n\n/** Service layer: the only place that knows the IPC channel names. UI code calls these functions. */\nexport const itemsApi = {\n create: (bridge: FluxBridge, name: string) => bridge.invoke(CHANNELS.createItem, { name }) as Promise<Item>,\n};\n',
|
|
1077
|
+
"src/app/renderer/shell/AppShell.tsx": 'import { Breadcrumbs, CommandPalette, PluginRoutes, useKeybindings } from "fluxplugin/react";\nimport { Sidebar } from "./Sidebar";\n\n/** Layout of the whole window: sidebar + routed content + command palette. */\nexport function AppShell() {\n useKeybindings(); // runs commands bound to keyboard shortcuts (Mod+Shift+R, ...)\n return (\n <div className="app">\n <Sidebar />\n <main className="main">\n <Breadcrumbs />\n <PluginRoutes />\n </main>\n <CommandPalette />\n </div>\n );\n}\n',
|
|
1078
|
+
"src/app/renderer/shell/Sidebar.tsx": 'import { Link, PluginSlot, useMenu } from "fluxplugin/react";\nimport { APP_NAME, SLOTS } from "../../shared/constants";\n\nexport function Sidebar() {\n const { items, run } = useMenu("sidebar"); // entries added by plugins (routes with meta.menu, or menus.register)\n return (\n <aside className="side">\n <h1>\n {APP_NAME} <PluginSlot name={SLOTS.sidebarHeader} />\n </h1>\n <Link to="/">Dashboard</Link>\n {items.map((i) => (\n <a key={i.id} href={"#" + i.path} onClick={(e) => (e.preventDefault(), run(i))}>\n {i.label}\n </a>\n ))}\n <Link to="/plugins">Plugins</Link>\n <p className="hint">Ctrl/Cmd+K: commands</p>\n </aside>\n );\n}\n',
|
|
1079
|
+
"src/app/renderer/styles/app.css": "body { margin: 0; font: 14px system-ui; background: var(--flux-bg); color: var(--flux-fg); }\n.app { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }\n.side { border-right: 1px solid var(--flux-border); padding: 16px; background: var(--flux-surface); }\n.side h1 { font-size: 16px; margin: 0 0 14px; }\n.side a { display: block; padding: 7px 10px; border-radius: 8px; color: inherit; text-decoration: none; margin: 2px 0; }\n.side a[data-active] { background: var(--flux-accent); color: #fff; }\n.main { padding: 24px; max-width: 860px; }\n.row { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; }\n.btn { border: 1px solid var(--flux-border); background: var(--flux-bg); color: inherit; border-radius: 8px; padding: 6px 12px; cursor: pointer; }\n.hint { color: var(--flux-muted); font-size: 12px; }\n",
|
|
1080
|
+
"src/app/shared/constants.ts": '/** Names shared by the main process and the renderer. No imports here: this layer depends on nothing. */\nexport const APP_NAME = "__APP_NAME__";\n\n/** IPC channels the host exposes to the renderer (also allow-listed in main/framework.ts). */\nexport const CHANNELS = {\n createItem: "host.item.create",\n} as const;\n\n/** Declarative extension points the host offers to plugins (see renderer/extension-points.ts). */\nexport const EXTENSION_POINTS = {\n dashboard: "dashboard",\n} as const;\n\n/** UI slots where plugins can inject components (see <PluginSlot name=... />). */\nexport const SLOTS = {\n sidebarHeader: "header.badges",\n dashboardBefore: "dashboard.before",\n dashboardAfter: "dashboard.after",\n} as const;\n\n/** Hook pipeline operations plugins can intercept: before./after./transform./validate.<operation>. */\nexport const OPERATIONS = {\n createItem: "item.create",\n} as const;\n',
|
|
1081
|
+
"src/app/shared/types.ts": 'export interface Item {\n id: string;\n name: string;\n}\n\n/** Shape of a contribution to the "dashboard" extension point (validated in renderer/extension-points.ts). */\nexport interface ToolbarAction {\n id: string;\n label: string;\n command?: string;\n}\n'
|
|
1082
|
+
};
|
|
976
1083
|
|
|
977
1084
|
// src/cli/templates.ts
|
|
978
1085
|
var pkg = (v) => JSON.stringify(
|
|
@@ -1201,113 +1308,9 @@ function hostFiles(v, withSample = true) {
|
|
|
1201
1308
|
2
|
|
1202
1309
|
),
|
|
1203
1310
|
".gitignore": "node_modules\ndist\n.flux-data\n*.tgz\n",
|
|
1204
|
-
"
|
|
1205
|
-
<html><head><meta charset="utf-8"><title>${v.name}</title>
|
|
1206
|
-
<meta http-equiv="Content-Security-Policy" content="default-src 'self' fluxplugin:; script-src 'self' fluxplugin:; style-src 'self' 'unsafe-inline' fluxplugin:; connect-src 'self'; img-src 'self' data:">
|
|
1207
|
-
<style>body{margin:0;font:14px system-ui}</style></head>
|
|
1208
|
-
<body><div id="root"></div><script type="module" src="./renderer.js"></script></body></html>
|
|
1209
|
-
`,
|
|
1210
|
-
"src/framework.ts": `import { MainFramework } from "fluxplugin/main";
|
|
1211
|
-
import { s } from "fluxplugin";
|
|
1212
|
-
|
|
1213
|
-
/** Shared by Electron (main.ts) and browser mode (web.ts). Put host-side setup here. */
|
|
1214
|
-
export function createFramework(opts: { pluginDirs: string[]; dataDir: string; dev?: boolean; assetUrl?: (id: string, rel: string) => string }) {
|
|
1215
|
-
const fw = new MainFramework({
|
|
1216
|
-
pluginDirs: opts.pluginDirs,
|
|
1217
|
-
dataDir: opts.dataDir,
|
|
1218
|
-
hotReload: opts.dev,
|
|
1219
|
-
assetUrl: opts.assetUrl,
|
|
1220
|
-
requireApproval: false, // set true to make users approve each plugin's permissions
|
|
1221
|
-
allowedRendererChannels: ["host.item.create"],
|
|
1222
|
-
});
|
|
1223
|
-
|
|
1224
|
-
// A host operation wrapped in the hook pipeline: plugins can validate, normalize or veto it
|
|
1225
|
-
// (before./transform./after.item.create) without changing this file.
|
|
1226
|
-
fw.manager.ipc.handle(
|
|
1227
|
-
"host.item.create",
|
|
1228
|
-
(input: { name: string }) =>
|
|
1229
|
-
fw.manager.hooks.run("item.create", input, (i) => {
|
|
1230
|
-
const item = { id: Math.random().toString(36).slice(2, 8), name: i.name };
|
|
1231
|
-
fw.manager.events.emit("item.created", item, "host");
|
|
1232
|
-
return item as Record<string, unknown>;
|
|
1233
|
-
}),
|
|
1234
|
-
"host",
|
|
1235
|
-
{ input: s.object({ name: s.string() }) },
|
|
1236
|
-
);
|
|
1237
|
-
return fw;
|
|
1238
|
-
}
|
|
1239
|
-
`,
|
|
1240
|
-
"src/main.ts": `// Electron main process.
|
|
1241
|
-
import { app, BrowserWindow, ipcMain, protocol, webContents } from "electron";
|
|
1242
|
-
import path from "node:path";
|
|
1243
|
-
import { PLUGIN_SCHEME, attachElectron, registerPluginProtocol } from "fluxplugin/main";
|
|
1244
|
-
import { createFramework } from "./framework.js";
|
|
1245
|
-
|
|
1246
|
-
protocol.registerSchemesAsPrivileged([PLUGIN_SCHEME]); // must run before app is ready
|
|
1247
|
-
|
|
1248
|
-
app.whenReady().then(async () => {
|
|
1249
|
-
const dev = !!process.env["FLUXPLUGIN_DEV"];
|
|
1250
|
-
const root = process.env["FLUXPLUGIN_PROJECT"] ?? path.resolve(__dirname, "..");
|
|
1251
|
-
const fw = createFramework({
|
|
1252
|
-
pluginDirs: [path.join(root, "plugins"), path.join(app.getPath("userData"), "plugins")], // bundled + user-installed
|
|
1253
|
-
dataDir: path.join(app.getPath("userData"), "plugins-data"),
|
|
1254
|
-
dev,
|
|
1255
|
-
});
|
|
1256
|
-
registerPluginProtocol(fw, protocol);
|
|
1257
|
-
attachElectron(fw, { ipcMain: ipcMain as never, webContents: webContents as never });
|
|
1258
|
-
await fw.start();
|
|
1259
|
-
|
|
1260
|
-
const win = new BrowserWindow({
|
|
1261
|
-
width: 1100,
|
|
1262
|
-
height: 760,
|
|
1263
|
-
webPreferences: { preload: path.join(__dirname, "preload.cjs"), contextIsolation: true, sandbox: true, nodeIntegration: false },
|
|
1264
|
-
});
|
|
1265
|
-
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
|
1266
|
-
win.webContents.on("will-navigate", (e) => e.preventDefault());
|
|
1267
|
-
await win.loadFile(path.join(__dirname, "index.html"));
|
|
1268
|
-
app.on("before-quit", () => void fw.stop());
|
|
1269
|
-
});
|
|
1270
|
-
app.on("window-all-closed", () => app.quit());
|
|
1271
|
-
`,
|
|
1272
|
-
"src/preload.ts": `import { contextBridge, ipcRenderer } from "electron";
|
|
1273
|
-
import { exposeFluxBridge } from "fluxplugin/preload";
|
|
1274
|
-
|
|
1275
|
-
exposeFluxBridge(contextBridge, ipcRenderer); // the only thing renderer code receives: window.__fluxplugin
|
|
1276
|
-
`,
|
|
1277
|
-
"src/web.ts": `// Browser mode (no Electron): \`fluxplugin dev --web\`.
|
|
1278
|
-
import path from "node:path";
|
|
1279
|
-
import { serveWeb } from "fluxplugin/main";
|
|
1280
|
-
import { createFramework } from "./framework.js";
|
|
1281
|
-
|
|
1282
|
-
const root = process.env["FLUXPLUGIN_PROJECT"] ?? process.cwd();
|
|
1283
|
-
const fw = createFramework({
|
|
1284
|
-
pluginDirs: [path.join(root, "plugins")],
|
|
1285
|
-
dataDir: path.join(root, ".flux-data"),
|
|
1286
|
-
dev: true,
|
|
1287
|
-
assetUrl: (id, rel) => \`/@plugins/\${id}/\${rel.replace(/^\\.\\//, "")}\`,
|
|
1288
|
-
});
|
|
1289
|
-
await fw.start();
|
|
1290
|
-
const srv = await serveWeb(fw, { distDir: path.join(root, "dist"), port: Number(process.env["PORT"] ?? 5173) });
|
|
1291
|
-
console.log(\`${v.name} \u2192 http://127.0.0.1:\${srv.port}\`);
|
|
1292
|
-
`,
|
|
1293
|
-
"src/renderer.tsx": renderer(v.name),
|
|
1294
|
-
"README.md": `# ${v.name}
|
|
1295
|
-
|
|
1296
|
-
Created with [fluxplugin](https://www.npmjs.com/package/fluxplugin).
|
|
1297
|
-
|
|
1298
|
-
\`\`\`bash
|
|
1299
|
-
npm install
|
|
1300
|
-
npm run dev:web # in a browser, no Electron needed
|
|
1301
|
-
npm run dev # in Electron (hot reload for plugins)
|
|
1302
|
-
npm run build && npm start
|
|
1303
|
-
npm run plugin:new my-plugin -- -t backend # scaffold a plugin in ./plugins
|
|
1304
|
-
\`\`\`
|
|
1305
|
-
|
|
1306
|
-
* \`src/renderer.tsx\` \u2014 your React UI. \`<PluginSlot name="\u2026"/>\` and extension points are where plugins plug in.
|
|
1307
|
-
* \`src/framework.ts\` \u2014 host-side setup (permissions policy, host operations exposed to plugins, hooks).
|
|
1308
|
-
* \`plugins/\` \u2014 plugins loaded at startup; users can also install \`.tgz\` plugins from the Plugins page.
|
|
1309
|
-
`
|
|
1311
|
+
"README.md": readme(v.name)
|
|
1310
1312
|
};
|
|
1313
|
+
for (const [rel, content] of Object.entries(HOST_APP_FILES)) files[rel] = content.split("__APP_NAME__").join(v.name);
|
|
1311
1314
|
if (withSample) {
|
|
1312
1315
|
const sample = TEMPLATES.dashboard(varsFromName("hello"));
|
|
1313
1316
|
for (const [rel, content] of Object.entries(sample)) {
|
|
@@ -1317,97 +1320,35 @@ npm run plugin:new my-plugin -- -t backend # scaffold a plugin in ./plugins
|
|
|
1317
1320
|
}
|
|
1318
1321
|
return files;
|
|
1319
1322
|
}
|
|
1320
|
-
var
|
|
1321
|
-
import { createRoot } from "react-dom/client";
|
|
1322
|
-
import * as jsxRuntime from "react/jsx-runtime";
|
|
1323
|
-
import * as ReactDOMClient from "react-dom/client";
|
|
1324
|
-
import * as flux from "fluxplugin";
|
|
1325
|
-
import * as fluxReact from "fluxplugin/react";
|
|
1326
|
-
import { s, type FluxBridge } from "fluxplugin";
|
|
1327
|
-
import {
|
|
1328
|
-
Breadcrumbs, CommandPalette, Link, PluginProvider, PluginRoutes, PluginSlot, RendererFramework, StyleHost,
|
|
1329
|
-
baseCss, createHttpBridge, installSharedModules, useExtensionItems, useKeybindings, useMenu,
|
|
1330
|
-
} from "fluxplugin/react";
|
|
1331
|
-
import { PluginManagerUI } from "fluxplugin/manager-ui";
|
|
1332
|
-
|
|
1333
|
-
// One copy of React and of the runtime, shared with plugin bundles (they import these from globalThis).
|
|
1334
|
-
installSharedModules({
|
|
1335
|
-
react: React, "react/jsx-runtime": jsxRuntime, "react/jsx-dev-runtime": jsxRuntime, "react-dom/client": ReactDOMClient,
|
|
1336
|
-
fluxplugin: flux, "fluxplugin/react": fluxReact,
|
|
1337
|
-
});
|
|
1338
|
-
|
|
1339
|
-
// Electron: the preload bridge. Browser mode: HTTP bridge to \`fluxplugin dev --web\`.
|
|
1340
|
-
const bridge: FluxBridge = (window as unknown as { __fluxplugin?: FluxBridge }).__fluxplugin ?? createHttpBridge();
|
|
1341
|
-
const framework = new RendererFramework({ bridge });
|
|
1323
|
+
var readme = (name) => `# ${name}
|
|
1342
1324
|
|
|
1343
|
-
|
|
1344
|
-
framework.manager.extensionPoints.declare({
|
|
1345
|
-
name: "dashboard",
|
|
1346
|
-
contributions: { toolbar: s.object({ id: s.string(), label: s.string(), command: s.string().optional() }) },
|
|
1347
|
-
});
|
|
1348
|
-
|
|
1349
|
-
const css = baseCss + \`
|
|
1350
|
-
body{background:var(--flux-bg);color:var(--flux-fg)}
|
|
1351
|
-
.app{display:grid;grid-template-columns:220px 1fr;min-height:100vh}
|
|
1352
|
-
.side{border-right:1px solid var(--flux-border);padding:16px;background:var(--flux-surface)}
|
|
1353
|
-
.side a{display:block;padding:7px 10px;border-radius:8px;color:inherit;text-decoration:none;margin:2px 0}
|
|
1354
|
-
.side a[data-active]{background:var(--flux-accent);color:#fff}.side h1{font-size:16px;margin:0 0 14px}
|
|
1355
|
-
.main{padding:24px;max-width:860px}.row{display:flex;gap:8px;margin:12px 0}
|
|
1356
|
-
.btn{border:1px solid var(--flux-border);background:var(--flux-bg);color:inherit;border-radius:8px;padding:6px 12px;cursor:pointer}
|
|
1357
|
-
.hint{color:var(--flux-muted);font-size:12px}\`;
|
|
1358
|
-
|
|
1359
|
-
function Dashboard() {
|
|
1360
|
-
const tools = useExtensionItems<{ id: string; label: string; command?: string }>("dashboard", "toolbar");
|
|
1361
|
-
const [out, setOut] = React.useState("");
|
|
1362
|
-
const create = async (name: string) => {
|
|
1363
|
-
try { setOut(JSON.stringify(await bridge.invoke("host.item.create", { name }))); }
|
|
1364
|
-
catch (e) { setOut("blocked: " + (e as Error).message); }
|
|
1365
|
-
};
|
|
1366
|
-
return (
|
|
1367
|
-
<div>
|
|
1368
|
-
<h2>Dashboard</h2>
|
|
1369
|
-
<PluginSlot name="dashboard.before" />
|
|
1370
|
-
<p className="hint">These buttons are contributed by plugins through a host-declared extension point.</p>
|
|
1371
|
-
<div className="row" data-testid="toolbar">
|
|
1372
|
-
{tools.map((t) => <button key={t.id} className="btn" onClick={() => t.command && void framework.manager.commands.execute(t.command)}>{t.label}</button>)}
|
|
1373
|
-
</div>
|
|
1374
|
-
<div className="row">
|
|
1375
|
-
<button className="btn" onClick={() => void create(" Ada ")}>Create item \u201C Ada \u201D (plugin hooks may rewrite it)</button>
|
|
1376
|
-
<button className="btn" onClick={() => void create(" ")}>Create empty item (a plugin may veto)</button>
|
|
1377
|
-
</div>
|
|
1378
|
-
<pre data-testid="hook-result" className="hint">{out}</pre>
|
|
1379
|
-
<PluginSlot name="dashboard.after" />
|
|
1380
|
-
</div>
|
|
1381
|
-
);
|
|
1382
|
-
}
|
|
1325
|
+
Created with [fluxplugin](https://www.npmjs.com/package/fluxplugin).
|
|
1383
1326
|
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
<Link to="/">Dashboard</Link>
|
|
1392
|
-
{items.map((i) => <a key={i.id} href={"#" + i.path} onClick={(e) => { e.preventDefault(); run(i); }}>{i.label}</a>)}
|
|
1393
|
-
<Link to="/plugins">Plugins</Link>
|
|
1394
|
-
<p className="hint">Ctrl/Cmd+K: commands</p>
|
|
1395
|
-
</aside>
|
|
1396
|
-
<main className="main"><Breadcrumbs /><PluginRoutes /></main>
|
|
1397
|
-
<CommandPalette />
|
|
1398
|
-
</div>
|
|
1399
|
-
);
|
|
1400
|
-
}
|
|
1327
|
+
\`\`\`bash
|
|
1328
|
+
npm install
|
|
1329
|
+
npm run dev:web # in a browser, no Electron needed
|
|
1330
|
+
npm run dev # in Electron (plugins hot-reload)
|
|
1331
|
+
npm run build && npm start
|
|
1332
|
+
npm run plugin:new my-plugin -- -t backend # scaffold a plugin in ./plugins
|
|
1333
|
+
\`\`\`
|
|
1401
1334
|
|
|
1402
|
-
|
|
1403
|
-
framework.manager.routes.register({ path: "/plugins", component: PluginManagerUI, meta: { title: "Plugins", breadcrumb: "Plugins" } }, "host");
|
|
1335
|
+
## Structure (everything lives in src/app)
|
|
1404
1336
|
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1337
|
+
\`\`\`
|
|
1338
|
+
src/app/
|
|
1339
|
+
shared/ constants.ts, types.ts names used by both processes (depends on nothing)
|
|
1340
|
+
main/ index.ts (Electron entry) \xB7 web.ts (browser entry) \xB7 framework.ts \xB7 operations.ts \xB7 window.ts \xB7 paths.ts
|
|
1341
|
+
preload/ index.ts exposes window.__fluxplugin
|
|
1342
|
+
renderer/
|
|
1343
|
+
index.tsx \xB7 index.html entry
|
|
1344
|
+
bootstrap.ts bridge, shared modules, framework start (infrastructure)
|
|
1345
|
+
extension-points.ts \xB7 routes.ts what plugins may extend / host pages
|
|
1346
|
+
providers/ shell/ pages/ UI layers
|
|
1347
|
+
hooks/ services/ app logic (services know the IPC channels, hooks serve the UI)
|
|
1348
|
+
styles/app.css
|
|
1349
|
+
plugins/ plugins loaded at startup (users can also install .tgz from the Plugins page)
|
|
1350
|
+
\`\`\`
|
|
1351
|
+
`;
|
|
1411
1352
|
|
|
1412
1353
|
// src/cli/cli.ts
|
|
1413
1354
|
var HELP = `fluxplugin <command>
|
|
@@ -1497,6 +1438,7 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
|
|
|
1497
1438
|
}
|
|
1498
1439
|
const dispose = await buildHost(cwd, { watch: true });
|
|
1499
1440
|
const { child, mode } = startApp(cwd, { web: f.web, dev: true, port: f.port ? Number(f.port) : void 0 });
|
|
1441
|
+
child.on("error", (e) => console.error(`error: ${e.message}`));
|
|
1500
1442
|
log(`running in ${mode} mode \u2014 edit a plugin and it reloads; Ctrl+C to stop`);
|
|
1501
1443
|
const stop = () => {
|
|
1502
1444
|
child.kill();
|
package/dist/node/cli-api.js
CHANGED
|
@@ -91,7 +91,7 @@ async function buildPlugin(root, watch = false, log2 = console.log) {
|
|
|
91
91
|
|
|
92
92
|
// src/cli/host.ts
|
|
93
93
|
import { spawn } from "node:child_process";
|
|
94
|
-
import { promises as fs3 } from "node:fs";
|
|
94
|
+
import { existsSync, promises as fs3, statSync } from "node:fs";
|
|
95
95
|
import { createRequire } from "node:module";
|
|
96
96
|
import path4 from "node:path";
|
|
97
97
|
import { build as build2, context as context2 } from "esbuild";
|
|
@@ -114,17 +114,25 @@ async function loadConfig(flags, cwd = process.cwd()) {
|
|
|
114
114
|
// src/cli/host.ts
|
|
115
115
|
var exists2 = (f) => fs3.access(f).then(() => true, () => false);
|
|
116
116
|
async function isHostProject(cwd) {
|
|
117
|
-
|
|
117
|
+
if (await exists2(path4.join(cwd, "fluxplugin.config.json"))) return true;
|
|
118
|
+
return await exists2(path4.join(cwd, "src/app/renderer/index.tsx")) || await exists2(path4.join(cwd, "src/renderer.tsx"));
|
|
118
119
|
}
|
|
119
120
|
var nodeBanner = { js: "import { createRequire as __r } from 'node:module'; const require = __r(import.meta.url);" };
|
|
121
|
+
var pick = (cwd, ...rels) => {
|
|
122
|
+
for (const r of rels) if (existsSync(path4.join(cwd, r))) return path4.join(cwd, r);
|
|
123
|
+
return path4.join(cwd, rels[0]);
|
|
124
|
+
};
|
|
120
125
|
function hostBuilds(cwd, outDir, dev) {
|
|
121
126
|
const common = { absWorkingDir: cwd, bundle: true, sourcemap: dev, logLevel: "warning", jsx: "automatic", define: { "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production") } };
|
|
122
|
-
const
|
|
127
|
+
const renderer = pick(cwd, "src/app/renderer/index.tsx", "src/renderer.tsx");
|
|
128
|
+
const main = pick(cwd, "src/app/main/index.ts", "src/main.ts");
|
|
129
|
+
const preload = pick(cwd, "src/app/preload/index.ts", "src/preload.ts");
|
|
130
|
+
const web = pick(cwd, "src/app/main/web.ts", "src/web.ts");
|
|
123
131
|
return [
|
|
124
|
-
{ ...common, entryPoints: [
|
|
125
|
-
{ ...common, entryPoints: [
|
|
126
|
-
{ ...common, entryPoints: [
|
|
127
|
-
{ ...common, entryPoints: [
|
|
132
|
+
{ ...common, entryPoints: [renderer], outfile: path4.join(outDir, "renderer.js"), platform: "browser", format: "esm", target: "chrome120" },
|
|
133
|
+
{ ...common, entryPoints: [main], outfile: path4.join(outDir, "main.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
|
|
134
|
+
{ ...common, entryPoints: [preload], outfile: path4.join(outDir, "preload.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
|
|
135
|
+
{ ...common, entryPoints: [web], outfile: path4.join(outDir, "web.mjs"), platform: "node", format: "esm", target: "node20", external: ["electron"], banner: nodeBanner }
|
|
128
136
|
];
|
|
129
137
|
}
|
|
130
138
|
async function pluginDirs(cwd, pluginsDir) {
|
|
@@ -140,11 +148,12 @@ async function buildHost(cwd, opts = {}) {
|
|
|
140
148
|
const cfg = await loadConfig({}, cwd);
|
|
141
149
|
const outDir = path4.resolve(cwd, cfgFile.outDir ?? "dist");
|
|
142
150
|
await fs3.mkdir(outDir, { recursive: true });
|
|
143
|
-
await fs3.copyFile(
|
|
151
|
+
await fs3.copyFile(pick(cwd, "src/app/renderer/index.html", "src/index.html"), path4.join(outDir, "index.html"));
|
|
144
152
|
const disposers = [];
|
|
145
153
|
const opts2 = hostBuilds(cwd, outDir, !!opts.watch);
|
|
146
154
|
if (opts.watch) {
|
|
147
155
|
const ctxs = await Promise.all(opts2.map((o) => context2(o)));
|
|
156
|
+
await Promise.all(ctxs.map((c) => c.rebuild()));
|
|
148
157
|
await Promise.all(ctxs.map((c) => c.watch()));
|
|
149
158
|
disposers.push(async () => void await Promise.all(ctxs.map((c) => c.dispose())));
|
|
150
159
|
} else for (const o of opts2) await build2(o);
|
|
@@ -161,17 +170,115 @@ async function buildHost(cwd, opts = {}) {
|
|
|
161
170
|
function electronBinary(cwd) {
|
|
162
171
|
try {
|
|
163
172
|
const p = createRequire(path4.join(cwd, "package.json"))("electron");
|
|
164
|
-
|
|
173
|
+
if (typeof p === "string" && existsSync(p)) return { bin: p };
|
|
174
|
+
return { error: "the Electron binary was not found" };
|
|
175
|
+
} catch (e) {
|
|
176
|
+
return { error: e.message.split("\n")[0] };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function needsNoSandbox(bin) {
|
|
180
|
+
if (process.platform !== "linux") return false;
|
|
181
|
+
if (process.getuid?.() === 0) return true;
|
|
182
|
+
try {
|
|
183
|
+
const st = statSync(path4.join(path4.dirname(bin), "chrome-sandbox"));
|
|
184
|
+
return !(st.uid === 0 && (st.mode & 2048) !== 0);
|
|
165
185
|
} catch {
|
|
166
|
-
return
|
|
186
|
+
return false;
|
|
167
187
|
}
|
|
168
188
|
}
|
|
169
189
|
function startApp(cwd, opts = {}) {
|
|
170
|
-
const
|
|
190
|
+
const log2 = opts.log ?? console.log;
|
|
171
191
|
const env = { ...process.env, FLUXPLUGIN_PROJECT: cwd, ...opts.dev ? { FLUXPLUGIN_DEV: "1" } : {}, ...opts.port ? { PORT: String(opts.port) } : {} };
|
|
172
|
-
if (
|
|
173
|
-
|
|
192
|
+
if (opts.web) return { child: spawn(process.execPath, [path4.join(cwd, "dist/web.mjs")], { cwd, env, stdio: "inherit" }), mode: "web" };
|
|
193
|
+
const { bin, error } = electronBinary(cwd);
|
|
194
|
+
if (!bin) throw new Error(`cannot start Electron: ${error}.
|
|
195
|
+
Install it with: npm install -D electron (then re-run)
|
|
196
|
+
or run in a browser: fluxplugin ${opts.dev ? "dev" : "start"} --web`);
|
|
197
|
+
const args = [...process.env["FLUXPLUGIN_ELECTRON_ARGS"]?.split(" ").filter(Boolean) ?? [], path4.join(cwd, "dist/main.cjs")];
|
|
198
|
+
if (needsNoSandbox(bin)) {
|
|
199
|
+
args.unshift("--no-sandbox");
|
|
200
|
+
log2("! Linux: Electron's sandbox helper is not configured (or you run as root) \u2192 starting with --no-sandbox.\n To fix permanently: sudo chown root node_modules/electron/dist/chrome-sandbox && sudo chmod 4755 node_modules/electron/dist/chrome-sandbox");
|
|
201
|
+
}
|
|
202
|
+
const started = Date.now();
|
|
203
|
+
const child = spawn(bin, args, { cwd, env, stdio: "inherit" });
|
|
204
|
+
child.on("exit", (code) => {
|
|
205
|
+
if (code && Date.now() - started < 5e3) log2(`Electron exited with code ${code} right after starting. Scroll up for its error, or try: fluxplugin dev --web`);
|
|
206
|
+
});
|
|
207
|
+
return { child, mode: "electron" };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/cli/host-files.generated.ts
|
|
211
|
+
var HOST_APP_FILES = {
|
|
212
|
+
"src/app/main/framework.ts": `import { MainFramework } from "fluxplugin/main";
|
|
213
|
+
import { CHANNELS } from "../shared/constants";
|
|
214
|
+
import { registerOperations } from "./operations";
|
|
215
|
+
|
|
216
|
+
export interface FrameworkOptions {
|
|
217
|
+
pluginDirs: string[];
|
|
218
|
+
dataDir: string;
|
|
219
|
+
dev?: boolean;
|
|
220
|
+
assetUrl?: (id: string, rel: string) => string;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Creates the plugin framework. Shared by the Electron entry (index.ts) and the browser entry (web.ts). */
|
|
224
|
+
export function createFramework(opts: FrameworkOptions): MainFramework {
|
|
225
|
+
const fw = new MainFramework({
|
|
226
|
+
pluginDirs: opts.pluginDirs,
|
|
227
|
+
dataDir: opts.dataDir,
|
|
228
|
+
hotReload: opts.dev,
|
|
229
|
+
assetUrl: opts.assetUrl,
|
|
230
|
+
requireApproval: false, // true \u2192 users must approve each plugin's permissions
|
|
231
|
+
allowedRendererChannels: [CHANNELS.createItem],
|
|
232
|
+
});
|
|
233
|
+
registerOperations(fw);
|
|
234
|
+
return fw;
|
|
235
|
+
}
|
|
236
|
+
`,
|
|
237
|
+
"src/app/main/index.ts": '// Electron entry (main process). Keep it thin: wiring only, logic lives in the other files of this folder.\nimport { app, ipcMain, protocol, webContents } from "electron";\nimport path from "node:path";\nimport { PLUGIN_SCHEME, attachElectron, registerPluginProtocol } from "fluxplugin/main";\nimport { createFramework } from "./framework";\nimport { pluginDirs, projectRoot } from "./paths";\nimport { createMainWindow } from "./window";\n\nprotocol.registerSchemesAsPrivileged([PLUGIN_SCHEME]); // must run before the app is ready\n\napp.whenReady().then(async () => {\n const fw = createFramework({\n pluginDirs: pluginDirs(projectRoot(__dirname), app.getPath("userData")),\n dataDir: path.join(app.getPath("userData"), "plugins-data"),\n dev: !!process.env["FLUXPLUGIN_DEV"],\n });\n registerPluginProtocol(fw, protocol);\n attachElectron(fw, { ipcMain: ipcMain as never, webContents: webContents as never });\n await fw.start();\n createMainWindow(__dirname);\n app.on("before-quit", () => void fw.stop());\n});\n\napp.on("window-all-closed", () => app.quit());\n',
|
|
238
|
+
"src/app/main/operations.ts": 'import { s } from "fluxplugin";\nimport type { MainFramework } from "fluxplugin/main";\nimport { CHANNELS, OPERATIONS } from "../shared/constants";\nimport type { Item } from "../shared/types";\n\n/**\n * Host operations. Each one runs through the hook pipeline, so plugins can validate, normalize or veto it\n * (before.item.create, transform.item.create, ...) without touching this file.\n */\nexport function registerOperations(fw: MainFramework): void {\n fw.manager.ipc.handle(\n CHANNELS.createItem,\n (input: { name: string }) =>\n fw.manager.hooks.run(OPERATIONS.createItem, input, (i) => {\n const item: Item = { id: Math.random().toString(36).slice(2, 8), name: i.name };\n fw.manager.events.emit("item.created", item, "host");\n return item as unknown as Record<string, unknown>;\n }),\n "host",\n { input: s.object({ name: s.string() }) },\n );\n}\n',
|
|
239
|
+
"src/app/main/paths.ts": 'import path from "node:path";\n\n/** Project root: set by `fluxplugin dev|start`; falls back to the folder above dist/ (packaged app). */\nexport function projectRoot(distDir: string): string {\n return process.env["FLUXPLUGIN_PROJECT"] ?? path.resolve(distDir, "..");\n}\n\n/** Plugins bundled with the app (read-only) and plugins installed by the user (writable). */\nexport function pluginDirs(root: string, userData: string): string[] {\n return [path.join(root, "plugins"), path.join(userData, "plugins")];\n}\n',
|
|
240
|
+
"src/app/main/web.ts": '// Browser entry: `fluxplugin dev --web` (no Electron needed).\nimport path from "node:path";\nimport { serveWeb } from "fluxplugin/main";\nimport { APP_NAME } from "../shared/constants";\nimport { createFramework } from "./framework";\n\nconst root = process.env["FLUXPLUGIN_PROJECT"] ?? process.cwd();\nconst fw = createFramework({\n pluginDirs: [path.join(root, "plugins")],\n dataDir: path.join(root, ".flux-data"),\n dev: true,\n assetUrl: (id, rel) => "/@plugins/" + id + "/" + rel.replace("./", ""),\n});\nawait fw.start();\nconst srv = await serveWeb(fw, { distDir: path.join(root, "dist"), port: Number(process.env["PORT"] ?? 5173) });\nconsole.log(APP_NAME + " -> http://127.0.0.1:" + srv.port);\n',
|
|
241
|
+
"src/app/main/window.ts": 'import { BrowserWindow } from "electron";\nimport path from "node:path";\n\nexport function createMainWindow(distDir: string): BrowserWindow {\n const win = new BrowserWindow({\n width: 1100,\n height: 760,\n show: false,\n webPreferences: {\n preload: path.join(distDir, "preload.cjs"),\n contextIsolation: true,\n sandbox: true,\n nodeIntegration: false,\n },\n });\n win.once("ready-to-show", () => win.show());\n // The app never navigates away and never opens new windows.\n win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));\n win.webContents.on("will-navigate", (e) => e.preventDefault());\n void win.loadFile(path.join(distDir, "index.html"));\n return win;\n}\n',
|
|
242
|
+
"src/app/preload/index.ts": 'import { contextBridge, ipcRenderer } from "electron";\nimport { exposeFluxBridge } from "fluxplugin/preload";\n\n// The only thing renderer code receives from the preload: window.__fluxplugin\nexposeFluxBridge(contextBridge, ipcRenderer);\n',
|
|
243
|
+
"src/app/renderer/App.tsx": 'import type { FluxBridge } from "fluxplugin";\nimport type { RendererFramework } from "fluxplugin/react";\nimport { AppProviders } from "./providers/AppProviders";\nimport { AppShell } from "./shell/AppShell";\n\nexport function App({ framework, bridge }: { framework: RendererFramework; bridge: FluxBridge }) {\n return (\n <AppProviders framework={framework} bridge={bridge}>\n <AppShell />\n </AppProviders>\n );\n}\n',
|
|
244
|
+
"src/app/renderer/bootstrap.ts": 'import React from "react";\nimport * as jsxRuntime from "react/jsx-runtime";\nimport * as ReactDOMClient from "react-dom/client";\nimport * as flux from "fluxplugin";\nimport * as fluxReact from "fluxplugin/react";\nimport type { FluxBridge } from "fluxplugin";\nimport { RendererFramework, createHttpBridge, installSharedModules } from "fluxplugin/react";\nimport { declareExtensionPoints } from "./extension-points";\nimport { registerHostRoutes } from "./routes";\n\n/**\n * Infrastructure layer: connects to the main process, shares singletons with plugin bundles,\n * declares what plugins may extend, registers the host\'s own routes, then starts the plugins.\n */\nexport async function bootstrap(): Promise<{ framework: RendererFramework; bridge: FluxBridge }> {\n // One copy of React and of the runtime: plugin bundles import these from globalThis.\n installSharedModules({\n react: React,\n "react/jsx-runtime": jsxRuntime,\n "react/jsx-dev-runtime": jsxRuntime,\n "react-dom/client": ReactDOMClient,\n fluxplugin: flux,\n "fluxplugin/react": fluxReact,\n });\n\n // Electron: the preload bridge. Browser mode: HTTP bridge to `fluxplugin dev --web`.\n const bridge: FluxBridge = (window as unknown as { __fluxplugin?: FluxBridge }).__fluxplugin ?? createHttpBridge();\n const framework = new RendererFramework({ bridge });\n\n declareExtensionPoints(framework); // BEFORE plugins start\n registerHostRoutes(framework);\n await framework.start();\n return { framework, bridge };\n}\n',
|
|
245
|
+
"src/app/renderer/extension-points.ts": 'import { s } from "fluxplugin";\nimport type { RendererFramework } from "fluxplugin/react";\nimport { EXTENSION_POINTS } from "../shared/constants";\n\n/** Declare what plugins may contribute. Contributions are validated against these schemas. */\nexport function declareExtensionPoints(framework: RendererFramework): void {\n framework.manager.extensionPoints.declare({\n name: EXTENSION_POINTS.dashboard,\n contributions: {\n toolbar: s.object({ id: s.string(), label: s.string(), command: s.string().optional() }),\n },\n });\n}\n',
|
|
246
|
+
"src/app/renderer/hooks/useCreateItem.ts": 'import { useState } from "react";\nimport { useBridge } from "../providers/AppProviders";\nimport { itemsApi } from "../services/items";\n\n/** Calls the host operation (which plugins may rewrite or veto through hooks) and exposes the outcome. */\nexport function useCreateItem() {\n const bridge = useBridge();\n const [result, setResult] = useState("");\n const create = async (name: string) => {\n try {\n setResult(JSON.stringify(await itemsApi.create(bridge, name)));\n } catch (e) {\n setResult("blocked: " + (e as Error).message);\n }\n };\n return { create, result };\n}\n',
|
|
247
|
+
"src/app/renderer/index.html": `<!doctype html>
|
|
248
|
+
<html>
|
|
249
|
+
<head>
|
|
250
|
+
<meta charset="utf-8" />
|
|
251
|
+
<title>__APP_NAME__</title>
|
|
252
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self' fluxplugin:; script-src 'self' fluxplugin:; style-src 'self' 'unsafe-inline' fluxplugin:; connect-src 'self'; img-src 'self' data:" />
|
|
253
|
+
<link rel="stylesheet" href="./renderer.css" />
|
|
254
|
+
</head>
|
|
255
|
+
<body>
|
|
256
|
+
<div id="root"></div>
|
|
257
|
+
<script type="module" src="./renderer.js"></script>
|
|
258
|
+
</body>
|
|
259
|
+
</html>
|
|
260
|
+
`,
|
|
261
|
+
"src/app/renderer/index.tsx": '// Renderer entry. Order matters: styles \u2192 bootstrap (infrastructure) \u2192 mount (UI).\nimport "./styles/app.css";\nimport { createRoot } from "react-dom/client";\nimport { bootstrap } from "./bootstrap";\nimport { App } from "./App";\n\nconst { framework, bridge } = await bootstrap();\ncreateRoot(document.getElementById("root")!).render(<App framework={framework} bridge={bridge} />);\n',
|
|
262
|
+
"src/app/renderer/pages/DashboardPage.tsx": 'import { PluginSlot, useExtensionItems, usePluginFramework } from "fluxplugin/react";\nimport { EXTENSION_POINTS, SLOTS } from "../../shared/constants";\nimport type { ToolbarAction } from "../../shared/types";\nimport { useCreateItem } from "../hooks/useCreateItem";\n\nexport function DashboardPage() {\n const framework = usePluginFramework();\n const actions = useExtensionItems<ToolbarAction>(EXTENSION_POINTS.dashboard, "toolbar");\n const { create, result } = useCreateItem();\n return (\n <div>\n <h2>Dashboard</h2>\n <PluginSlot name={SLOTS.dashboardBefore} />\n <p className="hint">These buttons are contributed by plugins through a host-declared extension point.</p>\n <div className="row" data-testid="toolbar">\n {actions.map((a) => (\n <button key={a.id} className="btn" onClick={() => a.command && void framework.manager.commands.execute(a.command)}>\n {a.label}\n </button>\n ))}\n </div>\n <div className="row">\n <button className="btn" onClick={() => void create(" Ada ")}>Create item \u201C Ada \u201D (plugin hooks may rewrite it)</button>\n <button className="btn" onClick={() => void create(" ")}>Create empty item (a plugin may veto)</button>\n </div>\n <pre data-testid="hook-result" className="hint">{result}</pre>\n <PluginSlot name={SLOTS.dashboardAfter} />\n </div>\n );\n}\n',
|
|
263
|
+
"src/app/renderer/pages/PluginsPage.tsx": 'import { PluginManagerUI } from "fluxplugin/manager-ui";\n\n/** Ready-made plugin manager (enable/disable, permissions, settings, logs, install). Replace with your own UI if you like. */\nexport function PluginsPage() {\n return <PluginManagerUI />;\n}\n',
|
|
264
|
+
"src/app/renderer/providers/AppProviders.tsx": 'import { createContext, useContext, type ReactNode } from "react";\nimport type { FluxBridge } from "fluxplugin";\nimport { PluginProvider, StyleHost, baseCss, type RendererFramework } from "fluxplugin/react";\n\nconst BridgeContext = createContext<FluxBridge | null>(null);\n\n/** Gives pages access to the main-process bridge without importing globals. */\nexport function useBridge(): FluxBridge {\n const b = useContext(BridgeContext);\n if (!b) throw new Error("useBridge must be used inside <AppProviders>");\n return b;\n}\n\nexport function AppProviders({ framework, bridge, children }: { framework: RendererFramework; bridge: FluxBridge; children: ReactNode }) {\n return (\n <PluginProvider framework={framework}>\n <BridgeContext.Provider value={bridge}>\n <style>{baseCss}</style>\n <StyleHost />\n {children}\n </BridgeContext.Provider>\n </PluginProvider>\n );\n}\n',
|
|
265
|
+
"src/app/renderer/routes.ts": `import type { RendererFramework } from "fluxplugin/react";
|
|
266
|
+
import { DashboardPage } from "./pages/DashboardPage";
|
|
267
|
+
import { PluginsPage } from "./pages/PluginsPage";
|
|
268
|
+
|
|
269
|
+
/** The host's own pages (owner "host"). Plugins add theirs at runtime through the same registry. */
|
|
270
|
+
export function registerHostRoutes(framework: RendererFramework): void {
|
|
271
|
+
framework.manager.routes.register({ path: "/", component: DashboardPage, meta: { title: "Dashboard" } }, "host");
|
|
272
|
+
framework.manager.routes.register({ path: "/plugins", component: PluginsPage, meta: { title: "Plugins", breadcrumb: "Plugins" } }, "host");
|
|
174
273
|
}
|
|
274
|
+
`,
|
|
275
|
+
"src/app/renderer/services/items.ts": 'import type { FluxBridge } from "fluxplugin";\nimport { CHANNELS } from "../../shared/constants";\nimport type { Item } from "../../shared/types";\n\n/** Service layer: the only place that knows the IPC channel names. UI code calls these functions. */\nexport const itemsApi = {\n create: (bridge: FluxBridge, name: string) => bridge.invoke(CHANNELS.createItem, { name }) as Promise<Item>,\n};\n',
|
|
276
|
+
"src/app/renderer/shell/AppShell.tsx": 'import { Breadcrumbs, CommandPalette, PluginRoutes, useKeybindings } from "fluxplugin/react";\nimport { Sidebar } from "./Sidebar";\n\n/** Layout of the whole window: sidebar + routed content + command palette. */\nexport function AppShell() {\n useKeybindings(); // runs commands bound to keyboard shortcuts (Mod+Shift+R, ...)\n return (\n <div className="app">\n <Sidebar />\n <main className="main">\n <Breadcrumbs />\n <PluginRoutes />\n </main>\n <CommandPalette />\n </div>\n );\n}\n',
|
|
277
|
+
"src/app/renderer/shell/Sidebar.tsx": 'import { Link, PluginSlot, useMenu } from "fluxplugin/react";\nimport { APP_NAME, SLOTS } from "../../shared/constants";\n\nexport function Sidebar() {\n const { items, run } = useMenu("sidebar"); // entries added by plugins (routes with meta.menu, or menus.register)\n return (\n <aside className="side">\n <h1>\n {APP_NAME} <PluginSlot name={SLOTS.sidebarHeader} />\n </h1>\n <Link to="/">Dashboard</Link>\n {items.map((i) => (\n <a key={i.id} href={"#" + i.path} onClick={(e) => (e.preventDefault(), run(i))}>\n {i.label}\n </a>\n ))}\n <Link to="/plugins">Plugins</Link>\n <p className="hint">Ctrl/Cmd+K: commands</p>\n </aside>\n );\n}\n',
|
|
278
|
+
"src/app/renderer/styles/app.css": "body { margin: 0; font: 14px system-ui; background: var(--flux-bg); color: var(--flux-fg); }\n.app { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }\n.side { border-right: 1px solid var(--flux-border); padding: 16px; background: var(--flux-surface); }\n.side h1 { font-size: 16px; margin: 0 0 14px; }\n.side a { display: block; padding: 7px 10px; border-radius: 8px; color: inherit; text-decoration: none; margin: 2px 0; }\n.side a[data-active] { background: var(--flux-accent); color: #fff; }\n.main { padding: 24px; max-width: 860px; }\n.row { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; }\n.btn { border: 1px solid var(--flux-border); background: var(--flux-bg); color: inherit; border-radius: 8px; padding: 6px 12px; cursor: pointer; }\n.hint { color: var(--flux-muted); font-size: 12px; }\n",
|
|
279
|
+
"src/app/shared/constants.ts": '/** Names shared by the main process and the renderer. No imports here: this layer depends on nothing. */\nexport const APP_NAME = "__APP_NAME__";\n\n/** IPC channels the host exposes to the renderer (also allow-listed in main/framework.ts). */\nexport const CHANNELS = {\n createItem: "host.item.create",\n} as const;\n\n/** Declarative extension points the host offers to plugins (see renderer/extension-points.ts). */\nexport const EXTENSION_POINTS = {\n dashboard: "dashboard",\n} as const;\n\n/** UI slots where plugins can inject components (see <PluginSlot name=... />). */\nexport const SLOTS = {\n sidebarHeader: "header.badges",\n dashboardBefore: "dashboard.before",\n dashboardAfter: "dashboard.after",\n} as const;\n\n/** Hook pipeline operations plugins can intercept: before./after./transform./validate.<operation>. */\nexport const OPERATIONS = {\n createItem: "item.create",\n} as const;\n',
|
|
280
|
+
"src/app/shared/types.ts": 'export interface Item {\n id: string;\n name: string;\n}\n\n/** Shape of a contribution to the "dashboard" extension point (validated in renderer/extension-points.ts). */\nexport interface ToolbarAction {\n id: string;\n label: string;\n command?: string;\n}\n'
|
|
281
|
+
};
|
|
175
282
|
|
|
176
283
|
// src/cli/templates.ts
|
|
177
284
|
var pkg = (v) => JSON.stringify(
|
|
@@ -400,113 +507,9 @@ function hostFiles(v, withSample = true) {
|
|
|
400
507
|
2
|
|
401
508
|
),
|
|
402
509
|
".gitignore": "node_modules\ndist\n.flux-data\n*.tgz\n",
|
|
403
|
-
"
|
|
404
|
-
<html><head><meta charset="utf-8"><title>${v.name}</title>
|
|
405
|
-
<meta http-equiv="Content-Security-Policy" content="default-src 'self' fluxplugin:; script-src 'self' fluxplugin:; style-src 'self' 'unsafe-inline' fluxplugin:; connect-src 'self'; img-src 'self' data:">
|
|
406
|
-
<style>body{margin:0;font:14px system-ui}</style></head>
|
|
407
|
-
<body><div id="root"></div><script type="module" src="./renderer.js"></script></body></html>
|
|
408
|
-
`,
|
|
409
|
-
"src/framework.ts": `import { MainFramework } from "fluxplugin/main";
|
|
410
|
-
import { s } from "fluxplugin";
|
|
411
|
-
|
|
412
|
-
/** Shared by Electron (main.ts) and browser mode (web.ts). Put host-side setup here. */
|
|
413
|
-
export function createFramework(opts: { pluginDirs: string[]; dataDir: string; dev?: boolean; assetUrl?: (id: string, rel: string) => string }) {
|
|
414
|
-
const fw = new MainFramework({
|
|
415
|
-
pluginDirs: opts.pluginDirs,
|
|
416
|
-
dataDir: opts.dataDir,
|
|
417
|
-
hotReload: opts.dev,
|
|
418
|
-
assetUrl: opts.assetUrl,
|
|
419
|
-
requireApproval: false, // set true to make users approve each plugin's permissions
|
|
420
|
-
allowedRendererChannels: ["host.item.create"],
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
// A host operation wrapped in the hook pipeline: plugins can validate, normalize or veto it
|
|
424
|
-
// (before./transform./after.item.create) without changing this file.
|
|
425
|
-
fw.manager.ipc.handle(
|
|
426
|
-
"host.item.create",
|
|
427
|
-
(input: { name: string }) =>
|
|
428
|
-
fw.manager.hooks.run("item.create", input, (i) => {
|
|
429
|
-
const item = { id: Math.random().toString(36).slice(2, 8), name: i.name };
|
|
430
|
-
fw.manager.events.emit("item.created", item, "host");
|
|
431
|
-
return item as Record<string, unknown>;
|
|
432
|
-
}),
|
|
433
|
-
"host",
|
|
434
|
-
{ input: s.object({ name: s.string() }) },
|
|
435
|
-
);
|
|
436
|
-
return fw;
|
|
437
|
-
}
|
|
438
|
-
`,
|
|
439
|
-
"src/main.ts": `// Electron main process.
|
|
440
|
-
import { app, BrowserWindow, ipcMain, protocol, webContents } from "electron";
|
|
441
|
-
import path from "node:path";
|
|
442
|
-
import { PLUGIN_SCHEME, attachElectron, registerPluginProtocol } from "fluxplugin/main";
|
|
443
|
-
import { createFramework } from "./framework.js";
|
|
444
|
-
|
|
445
|
-
protocol.registerSchemesAsPrivileged([PLUGIN_SCHEME]); // must run before app is ready
|
|
446
|
-
|
|
447
|
-
app.whenReady().then(async () => {
|
|
448
|
-
const dev = !!process.env["FLUXPLUGIN_DEV"];
|
|
449
|
-
const root = process.env["FLUXPLUGIN_PROJECT"] ?? path.resolve(__dirname, "..");
|
|
450
|
-
const fw = createFramework({
|
|
451
|
-
pluginDirs: [path.join(root, "plugins"), path.join(app.getPath("userData"), "plugins")], // bundled + user-installed
|
|
452
|
-
dataDir: path.join(app.getPath("userData"), "plugins-data"),
|
|
453
|
-
dev,
|
|
454
|
-
});
|
|
455
|
-
registerPluginProtocol(fw, protocol);
|
|
456
|
-
attachElectron(fw, { ipcMain: ipcMain as never, webContents: webContents as never });
|
|
457
|
-
await fw.start();
|
|
458
|
-
|
|
459
|
-
const win = new BrowserWindow({
|
|
460
|
-
width: 1100,
|
|
461
|
-
height: 760,
|
|
462
|
-
webPreferences: { preload: path.join(__dirname, "preload.cjs"), contextIsolation: true, sandbox: true, nodeIntegration: false },
|
|
463
|
-
});
|
|
464
|
-
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
|
465
|
-
win.webContents.on("will-navigate", (e) => e.preventDefault());
|
|
466
|
-
await win.loadFile(path.join(__dirname, "index.html"));
|
|
467
|
-
app.on("before-quit", () => void fw.stop());
|
|
468
|
-
});
|
|
469
|
-
app.on("window-all-closed", () => app.quit());
|
|
470
|
-
`,
|
|
471
|
-
"src/preload.ts": `import { contextBridge, ipcRenderer } from "electron";
|
|
472
|
-
import { exposeFluxBridge } from "fluxplugin/preload";
|
|
473
|
-
|
|
474
|
-
exposeFluxBridge(contextBridge, ipcRenderer); // the only thing renderer code receives: window.__fluxplugin
|
|
475
|
-
`,
|
|
476
|
-
"src/web.ts": `// Browser mode (no Electron): \`fluxplugin dev --web\`.
|
|
477
|
-
import path from "node:path";
|
|
478
|
-
import { serveWeb } from "fluxplugin/main";
|
|
479
|
-
import { createFramework } from "./framework.js";
|
|
480
|
-
|
|
481
|
-
const root = process.env["FLUXPLUGIN_PROJECT"] ?? process.cwd();
|
|
482
|
-
const fw = createFramework({
|
|
483
|
-
pluginDirs: [path.join(root, "plugins")],
|
|
484
|
-
dataDir: path.join(root, ".flux-data"),
|
|
485
|
-
dev: true,
|
|
486
|
-
assetUrl: (id, rel) => \`/@plugins/\${id}/\${rel.replace(/^\\.\\//, "")}\`,
|
|
487
|
-
});
|
|
488
|
-
await fw.start();
|
|
489
|
-
const srv = await serveWeb(fw, { distDir: path.join(root, "dist"), port: Number(process.env["PORT"] ?? 5173) });
|
|
490
|
-
console.log(\`${v.name} \u2192 http://127.0.0.1:\${srv.port}\`);
|
|
491
|
-
`,
|
|
492
|
-
"src/renderer.tsx": renderer(v.name),
|
|
493
|
-
"README.md": `# ${v.name}
|
|
494
|
-
|
|
495
|
-
Created with [fluxplugin](https://www.npmjs.com/package/fluxplugin).
|
|
496
|
-
|
|
497
|
-
\`\`\`bash
|
|
498
|
-
npm install
|
|
499
|
-
npm run dev:web # in a browser, no Electron needed
|
|
500
|
-
npm run dev # in Electron (hot reload for plugins)
|
|
501
|
-
npm run build && npm start
|
|
502
|
-
npm run plugin:new my-plugin -- -t backend # scaffold a plugin in ./plugins
|
|
503
|
-
\`\`\`
|
|
504
|
-
|
|
505
|
-
* \`src/renderer.tsx\` \u2014 your React UI. \`<PluginSlot name="\u2026"/>\` and extension points are where plugins plug in.
|
|
506
|
-
* \`src/framework.ts\` \u2014 host-side setup (permissions policy, host operations exposed to plugins, hooks).
|
|
507
|
-
* \`plugins/\` \u2014 plugins loaded at startup; users can also install \`.tgz\` plugins from the Plugins page.
|
|
508
|
-
`
|
|
510
|
+
"README.md": readme(v.name)
|
|
509
511
|
};
|
|
512
|
+
for (const [rel, content] of Object.entries(HOST_APP_FILES)) files[rel] = content.split("__APP_NAME__").join(v.name);
|
|
510
513
|
if (withSample) {
|
|
511
514
|
const sample = TEMPLATES.dashboard(varsFromName("hello"));
|
|
512
515
|
for (const [rel, content] of Object.entries(sample)) {
|
|
@@ -516,97 +519,35 @@ npm run plugin:new my-plugin -- -t backend # scaffold a plugin in ./plugins
|
|
|
516
519
|
}
|
|
517
520
|
return files;
|
|
518
521
|
}
|
|
519
|
-
var
|
|
520
|
-
import { createRoot } from "react-dom/client";
|
|
521
|
-
import * as jsxRuntime from "react/jsx-runtime";
|
|
522
|
-
import * as ReactDOMClient from "react-dom/client";
|
|
523
|
-
import * as flux from "fluxplugin";
|
|
524
|
-
import * as fluxReact from "fluxplugin/react";
|
|
525
|
-
import { s, type FluxBridge } from "fluxplugin";
|
|
526
|
-
import {
|
|
527
|
-
Breadcrumbs, CommandPalette, Link, PluginProvider, PluginRoutes, PluginSlot, RendererFramework, StyleHost,
|
|
528
|
-
baseCss, createHttpBridge, installSharedModules, useExtensionItems, useKeybindings, useMenu,
|
|
529
|
-
} from "fluxplugin/react";
|
|
530
|
-
import { PluginManagerUI } from "fluxplugin/manager-ui";
|
|
531
|
-
|
|
532
|
-
// One copy of React and of the runtime, shared with plugin bundles (they import these from globalThis).
|
|
533
|
-
installSharedModules({
|
|
534
|
-
react: React, "react/jsx-runtime": jsxRuntime, "react/jsx-dev-runtime": jsxRuntime, "react-dom/client": ReactDOMClient,
|
|
535
|
-
fluxplugin: flux, "fluxplugin/react": fluxReact,
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
// Electron: the preload bridge. Browser mode: HTTP bridge to \`fluxplugin dev --web\`.
|
|
539
|
-
const bridge: FluxBridge = (window as unknown as { __fluxplugin?: FluxBridge }).__fluxplugin ?? createHttpBridge();
|
|
540
|
-
const framework = new RendererFramework({ bridge });
|
|
541
|
-
|
|
542
|
-
// Declare extension points BEFORE plugins start. Plugins contribute validated data to them.
|
|
543
|
-
framework.manager.extensionPoints.declare({
|
|
544
|
-
name: "dashboard",
|
|
545
|
-
contributions: { toolbar: s.object({ id: s.string(), label: s.string(), command: s.string().optional() }) },
|
|
546
|
-
});
|
|
522
|
+
var readme = (name) => `# ${name}
|
|
547
523
|
|
|
548
|
-
|
|
549
|
-
body{background:var(--flux-bg);color:var(--flux-fg)}
|
|
550
|
-
.app{display:grid;grid-template-columns:220px 1fr;min-height:100vh}
|
|
551
|
-
.side{border-right:1px solid var(--flux-border);padding:16px;background:var(--flux-surface)}
|
|
552
|
-
.side a{display:block;padding:7px 10px;border-radius:8px;color:inherit;text-decoration:none;margin:2px 0}
|
|
553
|
-
.side a[data-active]{background:var(--flux-accent);color:#fff}.side h1{font-size:16px;margin:0 0 14px}
|
|
554
|
-
.main{padding:24px;max-width:860px}.row{display:flex;gap:8px;margin:12px 0}
|
|
555
|
-
.btn{border:1px solid var(--flux-border);background:var(--flux-bg);color:inherit;border-radius:8px;padding:6px 12px;cursor:pointer}
|
|
556
|
-
.hint{color:var(--flux-muted);font-size:12px}\`;
|
|
557
|
-
|
|
558
|
-
function Dashboard() {
|
|
559
|
-
const tools = useExtensionItems<{ id: string; label: string; command?: string }>("dashboard", "toolbar");
|
|
560
|
-
const [out, setOut] = React.useState("");
|
|
561
|
-
const create = async (name: string) => {
|
|
562
|
-
try { setOut(JSON.stringify(await bridge.invoke("host.item.create", { name }))); }
|
|
563
|
-
catch (e) { setOut("blocked: " + (e as Error).message); }
|
|
564
|
-
};
|
|
565
|
-
return (
|
|
566
|
-
<div>
|
|
567
|
-
<h2>Dashboard</h2>
|
|
568
|
-
<PluginSlot name="dashboard.before" />
|
|
569
|
-
<p className="hint">These buttons are contributed by plugins through a host-declared extension point.</p>
|
|
570
|
-
<div className="row" data-testid="toolbar">
|
|
571
|
-
{tools.map((t) => <button key={t.id} className="btn" onClick={() => t.command && void framework.manager.commands.execute(t.command)}>{t.label}</button>)}
|
|
572
|
-
</div>
|
|
573
|
-
<div className="row">
|
|
574
|
-
<button className="btn" onClick={() => void create(" Ada ")}>Create item \u201C Ada \u201D (plugin hooks may rewrite it)</button>
|
|
575
|
-
<button className="btn" onClick={() => void create(" ")}>Create empty item (a plugin may veto)</button>
|
|
576
|
-
</div>
|
|
577
|
-
<pre data-testid="hook-result" className="hint">{out}</pre>
|
|
578
|
-
<PluginSlot name="dashboard.after" />
|
|
579
|
-
</div>
|
|
580
|
-
);
|
|
581
|
-
}
|
|
524
|
+
Created with [fluxplugin](https://www.npmjs.com/package/fluxplugin).
|
|
582
525
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
<Link to="/">Dashboard</Link>
|
|
591
|
-
{items.map((i) => <a key={i.id} href={"#" + i.path} onClick={(e) => { e.preventDefault(); run(i); }}>{i.label}</a>)}
|
|
592
|
-
<Link to="/plugins">Plugins</Link>
|
|
593
|
-
<p className="hint">Ctrl/Cmd+K: commands</p>
|
|
594
|
-
</aside>
|
|
595
|
-
<main className="main"><Breadcrumbs /><PluginRoutes /></main>
|
|
596
|
-
<CommandPalette />
|
|
597
|
-
</div>
|
|
598
|
-
);
|
|
599
|
-
}
|
|
526
|
+
\`\`\`bash
|
|
527
|
+
npm install
|
|
528
|
+
npm run dev:web # in a browser, no Electron needed
|
|
529
|
+
npm run dev # in Electron (plugins hot-reload)
|
|
530
|
+
npm run build && npm start
|
|
531
|
+
npm run plugin:new my-plugin -- -t backend # scaffold a plugin in ./plugins
|
|
532
|
+
\`\`\`
|
|
600
533
|
|
|
601
|
-
|
|
602
|
-
framework.manager.routes.register({ path: "/plugins", component: PluginManagerUI, meta: { title: "Plugins", breadcrumb: "Plugins" } }, "host");
|
|
534
|
+
## Structure (everything lives in src/app)
|
|
603
535
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
536
|
+
\`\`\`
|
|
537
|
+
src/app/
|
|
538
|
+
shared/ constants.ts, types.ts names used by both processes (depends on nothing)
|
|
539
|
+
main/ index.ts (Electron entry) \xB7 web.ts (browser entry) \xB7 framework.ts \xB7 operations.ts \xB7 window.ts \xB7 paths.ts
|
|
540
|
+
preload/ index.ts exposes window.__fluxplugin
|
|
541
|
+
renderer/
|
|
542
|
+
index.tsx \xB7 index.html entry
|
|
543
|
+
bootstrap.ts bridge, shared modules, framework start (infrastructure)
|
|
544
|
+
extension-points.ts \xB7 routes.ts what plugins may extend / host pages
|
|
545
|
+
providers/ shell/ pages/ UI layers
|
|
546
|
+
hooks/ services/ app logic (services know the IPC channels, hooks serve the UI)
|
|
547
|
+
styles/app.css
|
|
548
|
+
plugins/ plugins loaded at startup (users can also install .tgz from the Plugins page)
|
|
549
|
+
\`\`\`
|
|
550
|
+
`;
|
|
610
551
|
|
|
611
552
|
// src/cli/cli.ts
|
|
612
553
|
var HELP = `fluxplugin <command>
|
|
@@ -696,6 +637,7 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
|
|
|
696
637
|
}
|
|
697
638
|
const dispose = await buildHost(cwd, { watch: true });
|
|
698
639
|
const { child, mode } = startApp(cwd, { web: f.web, dev: true, port: f.port ? Number(f.port) : void 0 });
|
|
640
|
+
child.on("error", (e) => console.error(`error: ${e.message}`));
|
|
699
641
|
log(`running in ${mode} mode \u2014 edit a plugin and it reloads; Ctrl+C to stop`);
|
|
700
642
|
const stop = () => {
|
|
701
643
|
child.kill();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const HOST_APP_FILES: Record<string, string>;
|
package/dist/types/cli/host.d.ts
CHANGED
|
@@ -5,11 +5,12 @@ export declare function buildHost(cwd: string, opts?: {
|
|
|
5
5
|
watch?: boolean;
|
|
6
6
|
log?: (m: string) => void;
|
|
7
7
|
}): Promise<() => Promise<void>>;
|
|
8
|
-
/** Starts the app: Electron
|
|
8
|
+
/** Starts the app: Electron by default, browser mode with `web`. Throws a readable error if Electron cannot run. */
|
|
9
9
|
export declare function startApp(cwd: string, opts?: {
|
|
10
10
|
web?: boolean;
|
|
11
11
|
dev?: boolean;
|
|
12
12
|
port?: number;
|
|
13
|
+
log?: (m: string) => void;
|
|
13
14
|
}): {
|
|
14
15
|
child: ChildProcess;
|
|
15
16
|
mode: "electron" | "web";
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fluxplugin",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Plugin architecture for Electron + React + TypeScript. Build apps that others can extend: routes, menus, commands, UI slots, IPC, hooks, permissions — with a CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"author": "Cellsflux",
|
|
7
6
|
"keywords": [
|
|
8
7
|
"electron",
|
|
9
8
|
"react",
|