fluxplugin 1.0.3 → 1.0.4
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 +15 -12
- package/dist/browser/chunks/chunk-AV4KI7LI.js +1792 -0
- package/dist/browser/chunks/{chunk-TL63576R.js → chunk-OAYWYU5O.js} +48 -11
- package/dist/browser/index.js +3 -1
- package/dist/browser/manager-ui.js +386 -185
- package/dist/browser/react.js +46 -2
- package/dist/cli.js +3622 -257
- package/dist/node/chunks/{chunk-EVX2O65A.js → chunk-SDCGUBEW.js} +49 -11
- package/dist/node/chunks/{chunk-C7VWZZOX.js → chunk-ZLA5XUXN.js} +175 -51
- package/dist/node/cli-api.js +742 -76
- package/dist/node/index.js +3 -1
- package/dist/node/main.js +8 -2
- package/dist/node/preload.js +1 -1
- package/dist/types/cli/build.d.ts +5 -0
- package/dist/types/cli/cli.d.ts +2 -0
- package/dist/types/cli/dist.d.ts +18 -0
- package/dist/types/cli/host-templates.d.ts +2 -0
- package/dist/types/cli/store.d.ts +13 -0
- package/dist/types/cli/templates.d.ts +2 -0
- package/dist/types/core/manifest.d.ts +15 -1
- package/dist/types/core/protocol.d.ts +18 -5
- package/dist/types/core/registry.d.ts +11 -0
- package/dist/types/core/routes.d.ts +3 -1
- package/dist/types/main/adapters.d.ts +3 -0
- package/dist/types/main/assets.d.ts +8 -0
- package/dist/types/main/github-registry.d.ts +2 -1
- package/dist/types/main/index.d.ts +1 -0
- package/dist/types/main/registry.d.ts +55 -18
- package/dist/types/main/stores.d.ts +7 -1
- package/dist/types/manager-ui/index.d.ts +35 -3
- package/dist/types/react/base-css.d.ts +6 -2
- package/dist/types/react/http-bridge.d.ts +5 -0
- package/dist/types/react/i18n.d.ts +33 -0
- package/dist/types/react/index.d.ts +5 -0
- package/dist/types/react/markdown.d.ts +2 -1
- package/dist/types/react/media.d.ts +33 -0
- package/dist/types/react/settings.d.ts +6 -0
- package/dist/types/react/sidebar.d.ts +42 -0
- package/dist/types/react/theme.d.ts +42 -4
- package/dist/types/react/ui.d.ts +42 -0
- package/docs/CUSTOMIZING.md +56 -0
- package/docs/STORE.md +19 -0
- package/docs/screenshots/plugin-page.png +0 -0
- package/docs/screenshots/settings-appearance.png +0 -0
- package/docs/screenshots/site-guide-dark.png +0 -0
- package/docs/screenshots/site-home.png +0 -0
- package/docs/screenshots/site-mobile.png +0 -0
- package/docs/screenshots/site-search.png +0 -0
- package/package.json +1 -1
- package/dist/browser/chunks/chunk-VXQJIDXW.js +0 -1232
package/dist/cli.js
CHANGED
|
@@ -24,14 +24,14 @@ __export(tailwind_exports, {
|
|
|
24
24
|
import { spawn, spawnSync } from "node:child_process";
|
|
25
25
|
import { existsSync, readFileSync } from "node:fs";
|
|
26
26
|
import { createRequire } from "node:module";
|
|
27
|
-
import
|
|
27
|
+
import path9 from "node:path";
|
|
28
28
|
function resolveTailwindCli(startDirs) {
|
|
29
29
|
for (const dir of startDirs) {
|
|
30
30
|
try {
|
|
31
|
-
const pj = createRequire(
|
|
31
|
+
const pj = createRequire(path9.join(dir, "package.json")).resolve("@tailwindcss/cli/package.json");
|
|
32
32
|
const pkg2 = JSON.parse(readFileSync(pj, "utf8"));
|
|
33
33
|
const rel = typeof pkg2.bin === "string" ? pkg2.bin : Object.values(pkg2.bin ?? {})[0];
|
|
34
|
-
if (rel) return
|
|
34
|
+
if (rel) return path9.join(path9.dirname(pj), rel);
|
|
35
35
|
} catch {
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -44,13 +44,13 @@ function compileTailwind(opts, log2 = console.log) {
|
|
|
44
44
|
if (!opts.watch) {
|
|
45
45
|
const r = spawnSync(process.execPath, args, { cwd: opts.cwd, encoding: "utf8" });
|
|
46
46
|
if (r.status !== 0) throw new Error(`tailwind failed: ${(r.stderr || r.stdout || "").trim().split("\n").slice(-3).join(" ")}`);
|
|
47
|
-
log2(`tailwind \u2192 ${
|
|
47
|
+
log2(`tailwind \u2192 ${path9.relative(opts.cwd, opts.output)}`);
|
|
48
48
|
return () => {
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
const child = spawn(process.execPath, [...args, "--watch=always"], { cwd: opts.cwd, stdio: ["ignore", "ignore", "pipe"] });
|
|
52
52
|
child.stderr?.on("data", (d) => /error/i.test(String(d)) && log2("tailwind: " + String(d).trim()));
|
|
53
|
-
log2(`tailwind watching ${
|
|
53
|
+
log2(`tailwind watching ${path9.relative(opts.cwd, opts.input)}`);
|
|
54
54
|
return () => void child.kill();
|
|
55
55
|
}
|
|
56
56
|
var init_tailwind = __esm({
|
|
@@ -61,17 +61,231 @@ var init_tailwind = __esm({
|
|
|
61
61
|
|
|
62
62
|
// src/cli/cli.ts
|
|
63
63
|
import { parseArgs } from "node:util";
|
|
64
|
-
import { promises as
|
|
65
|
-
import
|
|
64
|
+
import { promises as fs13 } from "node:fs";
|
|
65
|
+
import path17 from "node:path";
|
|
66
|
+
|
|
67
|
+
// src/core/index.ts
|
|
68
|
+
var core_exports = {};
|
|
69
|
+
__export(core_exports, {
|
|
70
|
+
BUILTIN_PERMISSIONS: () => BUILTIN_PERMISSIONS,
|
|
71
|
+
CHANNELS: () => CHANNELS,
|
|
72
|
+
CommandRegistry: () => CommandRegistry,
|
|
73
|
+
DisposableStore: () => DisposableStore,
|
|
74
|
+
EventBus: () => EventBus,
|
|
75
|
+
ExtensionPointRegistry: () => ExtensionPointRegistry,
|
|
76
|
+
FRAMEWORK_ENGINE_KEY: () => FRAMEWORK_ENGINE_KEY,
|
|
77
|
+
FRAMEWORK_VERSION: () => FRAMEWORK_VERSION,
|
|
78
|
+
FrameworkError: () => FrameworkError,
|
|
79
|
+
HookCancelledError: () => HookCancelledError,
|
|
80
|
+
HookSystem: () => HookSystem,
|
|
81
|
+
IpcError: () => IpcError,
|
|
82
|
+
IpcRegistry: () => IpcRegistry,
|
|
83
|
+
LifecycleError: () => LifecycleError,
|
|
84
|
+
LogStore: () => LogStore,
|
|
85
|
+
MGMT: () => MGMT,
|
|
86
|
+
MemoryStorageBackend: () => MemoryStorageBackend,
|
|
87
|
+
MenuRegistry: () => MenuRegistry,
|
|
88
|
+
NotificationCenter: () => NotificationCenter,
|
|
89
|
+
PermissionDeniedError: () => PermissionDeniedError,
|
|
90
|
+
PermissionManager: () => PermissionManager,
|
|
91
|
+
PluginManager: () => PluginManager,
|
|
92
|
+
PreloadRegistry: () => PreloadRegistry,
|
|
93
|
+
Registry: () => Registry,
|
|
94
|
+
RouteRegistry: () => RouteRegistry,
|
|
95
|
+
ServiceRegistry: () => ServiceRegistry,
|
|
96
|
+
SlotRegistry: () => SlotRegistry,
|
|
97
|
+
StyleRegistry: () => StyleRegistry,
|
|
98
|
+
TimeoutError: () => TimeoutError,
|
|
99
|
+
ValidationError: () => ValidationError,
|
|
100
|
+
activationOrderFor: () => activationOrderFor,
|
|
101
|
+
buildDependencyGraph: () => buildDependencyGraph,
|
|
102
|
+
compareVersions: () => compareVersions,
|
|
103
|
+
createPluginConfig: () => createPluginConfig,
|
|
104
|
+
createPluginState: () => createPluginState,
|
|
105
|
+
createPluginStorage: () => createPluginStorage,
|
|
106
|
+
defineCommand: () => defineCommand,
|
|
107
|
+
defineComponent: () => defineComponent,
|
|
108
|
+
defineEvent: () => defineEvent,
|
|
109
|
+
defineHook: () => defineHook,
|
|
110
|
+
defineIPC: () => defineIPC,
|
|
111
|
+
defineIpcContract: () => defineIpcContract,
|
|
112
|
+
defineMenu: () => defineMenu,
|
|
113
|
+
definePlugin: () => definePlugin,
|
|
114
|
+
defineRoute: () => defineRoute,
|
|
115
|
+
defineService: () => defineService,
|
|
116
|
+
dependentsOf: () => dependentsOf,
|
|
117
|
+
describeIssue: () => describeIssue,
|
|
118
|
+
errorMessage: () => errorMessage,
|
|
119
|
+
hasPluginApi: () => hasPluginApi,
|
|
120
|
+
isValidRange: () => isValidRange,
|
|
121
|
+
isValidVersion: () => isValidVersion,
|
|
122
|
+
manifestSchema: () => manifestSchema,
|
|
123
|
+
matchesPattern: () => matchesPattern,
|
|
124
|
+
menuLocations: () => menuLocations,
|
|
125
|
+
normalizePath: () => normalizePath,
|
|
126
|
+
parseOrThrow: () => parseOrThrow,
|
|
127
|
+
parseVersion: () => parseVersion,
|
|
128
|
+
parseVideo: () => parseVideo,
|
|
129
|
+
pluginApi: () => pluginApi,
|
|
130
|
+
preloadMember: () => preloadMember,
|
|
131
|
+
routePatternMatches: () => routePatternMatches,
|
|
132
|
+
s: () => s,
|
|
133
|
+
satisfies: () => satisfies,
|
|
134
|
+
scopeCss: () => scopeCss,
|
|
135
|
+
serializeError: () => serializeError,
|
|
136
|
+
toDisposable: () => toDisposable,
|
|
137
|
+
validateManifest: () => validateManifest,
|
|
138
|
+
videoEmbedUrl: () => videoEmbedUrl,
|
|
139
|
+
withTimeout: () => withTimeout
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// src/core/common.ts
|
|
143
|
+
var toDisposable = (fn) => {
|
|
144
|
+
let done = false;
|
|
145
|
+
return {
|
|
146
|
+
dispose() {
|
|
147
|
+
if (done) return;
|
|
148
|
+
done = true;
|
|
149
|
+
fn();
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
var DisposableStore = class {
|
|
154
|
+
items = [];
|
|
155
|
+
disposed = false;
|
|
156
|
+
add(d) {
|
|
157
|
+
if (this.disposed) d.dispose();
|
|
158
|
+
else this.items.push(d);
|
|
159
|
+
return d;
|
|
160
|
+
}
|
|
161
|
+
get size() {
|
|
162
|
+
return this.items.length;
|
|
163
|
+
}
|
|
164
|
+
dispose() {
|
|
165
|
+
this.disposed = true;
|
|
166
|
+
const items = this.items.splice(0).reverse();
|
|
167
|
+
for (const d of items) {
|
|
168
|
+
try {
|
|
169
|
+
d.dispose();
|
|
170
|
+
} catch {
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Re-arms the store so it can be reused after a deactivate/activate cycle. */
|
|
175
|
+
reset() {
|
|
176
|
+
this.dispose();
|
|
177
|
+
this.disposed = false;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
var FrameworkError = class extends Error {
|
|
181
|
+
constructor(message, code, pluginId, options) {
|
|
182
|
+
super(message, options);
|
|
183
|
+
this.code = code;
|
|
184
|
+
this.pluginId = pluginId;
|
|
185
|
+
this.name = new.target.name;
|
|
186
|
+
}
|
|
187
|
+
code;
|
|
188
|
+
pluginId;
|
|
189
|
+
};
|
|
190
|
+
var PermissionDeniedError = class extends FrameworkError {
|
|
191
|
+
constructor(pluginId, permission, api) {
|
|
192
|
+
super(`Plugin "${pluginId}" is not allowed to use ${api} (missing permission "${permission}")`, "FLUX_PERMISSION_DENIED", pluginId);
|
|
193
|
+
this.permission = permission;
|
|
194
|
+
}
|
|
195
|
+
permission;
|
|
196
|
+
};
|
|
197
|
+
var LifecycleError = class extends FrameworkError {
|
|
198
|
+
constructor(pluginId, phase, message, cause) {
|
|
199
|
+
super(`[${pluginId}] ${phase}: ${message}`, "FLUX_LIFECYCLE", pluginId, { cause });
|
|
200
|
+
this.phase = phase;
|
|
201
|
+
}
|
|
202
|
+
phase;
|
|
203
|
+
};
|
|
204
|
+
var TimeoutError = class extends FrameworkError {
|
|
205
|
+
constructor(what, ms, pluginId) {
|
|
206
|
+
super(`${what} timed out after ${ms}ms`, "FLUX_TIMEOUT", pluginId);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
var HookCancelledError = class extends FrameworkError {
|
|
210
|
+
constructor(operation, reason) {
|
|
211
|
+
super(`Operation "${operation}" was cancelled by a hook${reason ? `: ${reason}` : ""}`, "FLUX_HOOK_CANCELLED");
|
|
212
|
+
this.operation = operation;
|
|
213
|
+
this.reason = reason;
|
|
214
|
+
}
|
|
215
|
+
operation;
|
|
216
|
+
reason;
|
|
217
|
+
};
|
|
218
|
+
function withTimeout(p, ms, what, pluginId) {
|
|
219
|
+
if (!Number.isFinite(ms) || ms <= 0) return Promise.resolve(p);
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
const t = setTimeout(() => reject(new TimeoutError(what, ms, pluginId)), ms);
|
|
222
|
+
Promise.resolve(p).then(
|
|
223
|
+
(v) => {
|
|
224
|
+
clearTimeout(t);
|
|
225
|
+
resolve(v);
|
|
226
|
+
},
|
|
227
|
+
(e) => {
|
|
228
|
+
clearTimeout(t);
|
|
229
|
+
reject(e);
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
var LogStore = class {
|
|
235
|
+
constructor(capacity = 2e3, sink) {
|
|
236
|
+
this.capacity = capacity;
|
|
237
|
+
this.sink = sink;
|
|
238
|
+
}
|
|
239
|
+
capacity;
|
|
240
|
+
sink;
|
|
241
|
+
entries = [];
|
|
242
|
+
listeners = /* @__PURE__ */ new Set();
|
|
243
|
+
write(entry) {
|
|
244
|
+
this.entries.push(entry);
|
|
245
|
+
if (this.entries.length > this.capacity) this.entries.splice(0, this.entries.length - this.capacity);
|
|
246
|
+
this.sink?.(entry);
|
|
247
|
+
for (const l of this.listeners) {
|
|
248
|
+
try {
|
|
249
|
+
l(entry);
|
|
250
|
+
} catch {
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
logger(source) {
|
|
255
|
+
const mk = (level) => (message, data) => this.write({ level, source, message, time: Date.now(), data });
|
|
256
|
+
return { debug: mk("debug"), info: mk("info"), warn: mk("warn"), error: mk("error") };
|
|
257
|
+
}
|
|
258
|
+
query(filter = {}) {
|
|
259
|
+
return this.entries.filter((e) => (!filter.source || e.source === filter.source) && (!filter.level || e.level === filter.level));
|
|
260
|
+
}
|
|
261
|
+
subscribe(l) {
|
|
262
|
+
this.listeners.add(l);
|
|
263
|
+
return toDisposable(() => this.listeners.delete(l));
|
|
264
|
+
}
|
|
265
|
+
clear(source) {
|
|
266
|
+
this.entries = source ? this.entries.filter((e) => e.source !== source) : [];
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
function errorMessage(e) {
|
|
270
|
+
return e instanceof Error ? e.message : String(e);
|
|
271
|
+
}
|
|
66
272
|
|
|
67
273
|
// src/core/schema.ts
|
|
274
|
+
var ValidationError = class extends Error {
|
|
275
|
+
constructor(issues, what = "value") {
|
|
276
|
+
super(`Invalid ${what}: ` + issues.map((i) => `${i.path || "<root>"}: ${i.message}`).join("; "));
|
|
277
|
+
this.issues = issues;
|
|
278
|
+
this.name = "ValidationError";
|
|
279
|
+
}
|
|
280
|
+
issues;
|
|
281
|
+
};
|
|
68
282
|
var ok = (value) => ({ ok: true, value });
|
|
69
|
-
var fail = (
|
|
283
|
+
var fail = (path18, message) => ({ ok: false, issues: [{ path: path18, message }] });
|
|
70
284
|
var join = (p, k) => p ? typeof k === "number" ? `${p}[${k}]` : `${p}.${k}` : String(k);
|
|
71
285
|
function make(kind, parse, describe) {
|
|
72
286
|
const self = {
|
|
73
287
|
kind,
|
|
74
|
-
parse: (input,
|
|
288
|
+
parse: (input, path18 = "") => parse(input, path18),
|
|
75
289
|
describe,
|
|
76
290
|
optional() {
|
|
77
291
|
return make(
|
|
@@ -238,6 +452,11 @@ var s = {
|
|
|
238
452
|
return s.custom((v) => typeof v === "function", "expected function");
|
|
239
453
|
}
|
|
240
454
|
};
|
|
455
|
+
function parseOrThrow(schema, input, what) {
|
|
456
|
+
const r = schema.parse(input);
|
|
457
|
+
if (!r.ok) throw new ValidationError(r.issues, what);
|
|
458
|
+
return r.value;
|
|
459
|
+
}
|
|
241
460
|
|
|
242
461
|
// src/core/semver.ts
|
|
243
462
|
var SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
@@ -454,7 +673,7 @@ var manifestSchema = s.object({
|
|
|
454
673
|
/** Detailed description as Markdown (default: ./README.md). Rendered safely: no raw HTML. */
|
|
455
674
|
readme: s.string().optional(),
|
|
456
675
|
/** Explanation video (https URL; YouTube and Vimeo are embedded, other hosts become a link). */
|
|
457
|
-
video: s.string({ pattern: /^https:\/\/\S
|
|
676
|
+
video: s.string({ pattern: /^(https:\/\/\S+|\.\/\S+)$/ }).optional(),
|
|
458
677
|
categories: s.array(s.string()).default([]),
|
|
459
678
|
keywords: s.array(s.string()).default([]),
|
|
460
679
|
/** Extra links: docs, support, changelog, funding... */
|
|
@@ -509,13 +728,2039 @@ function validateManifest(raw, opts = {}) {
|
|
|
509
728
|
if (!manifest.main && !manifest.renderer && !manifest.preload) {
|
|
510
729
|
issues.push({ path: "", message: "at least one of main, preload or renderer entry is required" });
|
|
511
730
|
}
|
|
512
|
-
if (!required) warnings.push(`engines.${FRAMEWORK_ENGINE_KEY} is not declared; compatibility cannot be verified`);
|
|
513
|
-
for (const dep of Object.keys(manifest.dependencies ?? {})) {
|
|
514
|
-
if (dep === manifest.id) issues.push({ path: `dependencies.${dep}`, message: "a plugin cannot depend on itself" });
|
|
731
|
+
if (!required) warnings.push(`engines.${FRAMEWORK_ENGINE_KEY} is not declared; compatibility cannot be verified`);
|
|
732
|
+
for (const dep of Object.keys(manifest.dependencies ?? {})) {
|
|
733
|
+
if (dep === manifest.id) issues.push({ path: `dependencies.${dep}`, message: "a plugin cannot depend on itself" });
|
|
734
|
+
}
|
|
735
|
+
return { ok: issues.length === 0, manifest: issues.length ? void 0 : manifest, issues, warnings };
|
|
736
|
+
}
|
|
737
|
+
var MEDIA_EXT = /\.(mp4|m4v|webm|ogv|ogg|mov)(\?.*)?$/i;
|
|
738
|
+
function parseVideo(url) {
|
|
739
|
+
if (!url) return void 0;
|
|
740
|
+
if (url.startsWith("./")) return MEDIA_EXT.test(url) ? { kind: "html5", url, src: url } : void 0;
|
|
741
|
+
let u;
|
|
742
|
+
try {
|
|
743
|
+
u = new URL(url);
|
|
744
|
+
} catch {
|
|
745
|
+
return void 0;
|
|
746
|
+
}
|
|
747
|
+
if (u.protocol !== "https:") return void 0;
|
|
748
|
+
if (MEDIA_EXT.test(u.pathname)) return { kind: "html5", url, src: url };
|
|
749
|
+
const host = u.hostname.replace(/^www\./, "");
|
|
750
|
+
const last = u.pathname.split("/").filter(Boolean).pop() ?? "";
|
|
751
|
+
let id;
|
|
752
|
+
if (host === "youtu.be") id = u.pathname.slice(1);
|
|
753
|
+
else if (host === "youtube.com" || host === "m.youtube.com" || host === "youtube-nocookie.com") id = u.searchParams.get("v") ?? (/^\/(embed|shorts|live)\//.test(u.pathname) ? u.pathname.split("/")[2] : void 0);
|
|
754
|
+
if (id && /^[\w-]{6,20}$/.test(id)) return { kind: "youtube", url, embedUrl: `https://www.youtube-nocookie.com/embed/${id}` };
|
|
755
|
+
if ((host === "vimeo.com" || host === "player.vimeo.com") && /^\d+$/.test(last)) return { kind: "vimeo", url, embedUrl: `https://player.vimeo.com/video/${last}` };
|
|
756
|
+
if (host === "dailymotion.com" && /^\/(embed\/)?video\//.test(u.pathname) && /^\w+$/.test(last)) return { kind: "dailymotion", url, embedUrl: `https://www.dailymotion.com/embed/video/${last}` };
|
|
757
|
+
if (host === "dai.ly" && /^\w+$/.test(last)) return { kind: "dailymotion", url, embedUrl: `https://www.dailymotion.com/embed/video/${last}` };
|
|
758
|
+
if (host === "loom.com" && /^\/(share|embed)\//.test(u.pathname) && /^[\w-]+$/.test(last)) return { kind: "loom", url, embedUrl: `https://www.loom.com/embed/${last}` };
|
|
759
|
+
if (host === "streamable.com" && /^\w+$/.test(last)) return { kind: "streamable", url, embedUrl: `https://streamable.com/e/${last}` };
|
|
760
|
+
return { kind: "link", url };
|
|
761
|
+
}
|
|
762
|
+
function videoEmbedUrl(url) {
|
|
763
|
+
return parseVideo(url)?.embedUrl;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// src/core/events.ts
|
|
767
|
+
function matchesPattern(pattern, name) {
|
|
768
|
+
if (pattern === "*" || pattern === "**") return true;
|
|
769
|
+
if (pattern === name) return true;
|
|
770
|
+
if (!pattern.includes("*")) return false;
|
|
771
|
+
const p = pattern.split(".");
|
|
772
|
+
const n = name.split(".");
|
|
773
|
+
for (let i = 0; i < p.length; i++) {
|
|
774
|
+
const seg = p[i];
|
|
775
|
+
if (seg === "*") {
|
|
776
|
+
if (i === p.length - 1) return n.length > i;
|
|
777
|
+
if (n[i] === void 0) return false;
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if (n[i] !== seg) return false;
|
|
781
|
+
}
|
|
782
|
+
return p.length === n.length;
|
|
783
|
+
}
|
|
784
|
+
var EventBus = class {
|
|
785
|
+
constructor(onError = () => {
|
|
786
|
+
}) {
|
|
787
|
+
this.onError = onError;
|
|
788
|
+
}
|
|
789
|
+
onError;
|
|
790
|
+
entries = [];
|
|
791
|
+
seq = 0;
|
|
792
|
+
on(pattern, listener, opts = {}) {
|
|
793
|
+
const entry = {
|
|
794
|
+
pattern,
|
|
795
|
+
segments: pattern.split("."),
|
|
796
|
+
listener,
|
|
797
|
+
priority: opts.priority ?? 0,
|
|
798
|
+
once: opts.once ?? false,
|
|
799
|
+
seq: this.seq++
|
|
800
|
+
};
|
|
801
|
+
this.entries.push(entry);
|
|
802
|
+
return toDisposable(() => {
|
|
803
|
+
const i = this.entries.indexOf(entry);
|
|
804
|
+
if (i >= 0) this.entries.splice(i, 1);
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
once(pattern, listener, opts = {}) {
|
|
808
|
+
return this.on(pattern, listener, { ...opts, once: true });
|
|
809
|
+
}
|
|
810
|
+
off(pattern, listener) {
|
|
811
|
+
this.entries = this.entries.filter((e) => !(e.pattern === pattern && e.listener === listener));
|
|
812
|
+
}
|
|
813
|
+
listenerCount(pattern) {
|
|
814
|
+
return pattern ? this.entries.filter((e) => e.pattern === pattern).length : this.entries.length;
|
|
815
|
+
}
|
|
816
|
+
removeAll() {
|
|
817
|
+
this.entries = [];
|
|
818
|
+
}
|
|
819
|
+
matching(name) {
|
|
820
|
+
return this.entries.filter((e) => matchesPattern(e.pattern, name)).sort((a, b) => b.priority - a.priority || a.seq - b.seq);
|
|
821
|
+
}
|
|
822
|
+
emit(name, payload, source) {
|
|
823
|
+
const ctx = this.makeCtx(name, payload, source);
|
|
824
|
+
const result = { cancelled: false, errors: [], delivered: 0 };
|
|
825
|
+
for (const entry of this.matching(name)) {
|
|
826
|
+
if (ctx.cancelled) break;
|
|
827
|
+
if (entry.once) this.removeEntry(entry);
|
|
828
|
+
try {
|
|
829
|
+
const r = entry.listener(payload, ctx);
|
|
830
|
+
result.delivered++;
|
|
831
|
+
if (r && typeof r.then === "function") {
|
|
832
|
+
r.catch((e) => this.fail(e, name, entry.pattern, result));
|
|
833
|
+
}
|
|
834
|
+
} catch (e) {
|
|
835
|
+
this.fail(e, name, entry.pattern, result);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
result.cancelled = ctx.cancelled;
|
|
839
|
+
return result;
|
|
840
|
+
}
|
|
841
|
+
async emitAsync(name, payload, source) {
|
|
842
|
+
const ctx = this.makeCtx(name, payload, source);
|
|
843
|
+
const result = { cancelled: false, errors: [], delivered: 0 };
|
|
844
|
+
for (const entry of this.matching(name)) {
|
|
845
|
+
if (ctx.cancelled) break;
|
|
846
|
+
if (entry.once) this.removeEntry(entry);
|
|
847
|
+
try {
|
|
848
|
+
await entry.listener(payload, ctx);
|
|
849
|
+
result.delivered++;
|
|
850
|
+
} catch (e) {
|
|
851
|
+
this.fail(e, name, entry.pattern, result);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
result.cancelled = ctx.cancelled;
|
|
855
|
+
return result;
|
|
856
|
+
}
|
|
857
|
+
waitFor(pattern, timeoutMs = 0) {
|
|
858
|
+
return new Promise((resolve, reject) => {
|
|
859
|
+
const sub = this.once(pattern, (p) => {
|
|
860
|
+
if (t) clearTimeout(t);
|
|
861
|
+
resolve(p);
|
|
862
|
+
});
|
|
863
|
+
const t = timeoutMs > 0 ? setTimeout(() => (sub.dispose(), reject(new Error(`waitFor("${pattern}") timed out`))), timeoutMs) : void 0;
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
removeEntry(e) {
|
|
867
|
+
const i = this.entries.indexOf(e);
|
|
868
|
+
if (i >= 0) this.entries.splice(i, 1);
|
|
869
|
+
}
|
|
870
|
+
fail(e, name, pattern, result) {
|
|
871
|
+
result.errors.push(e);
|
|
872
|
+
try {
|
|
873
|
+
this.onError(e, name, pattern);
|
|
874
|
+
} catch {
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
makeCtx(name, payload, source) {
|
|
878
|
+
const ctx = {
|
|
879
|
+
name,
|
|
880
|
+
payload,
|
|
881
|
+
source,
|
|
882
|
+
cancelled: false,
|
|
883
|
+
cancel() {
|
|
884
|
+
ctx.cancelled = true;
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
return ctx;
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
// src/core/hooks.ts
|
|
892
|
+
var KINDS = ["before", "after", "around", "transform", "validate", "filter"];
|
|
893
|
+
function split(name) {
|
|
894
|
+
const i = name.indexOf(".");
|
|
895
|
+
const kind = i > 0 ? name.slice(0, i) : "";
|
|
896
|
+
if (!KINDS.includes(kind) || i === name.length - 1) {
|
|
897
|
+
throw new Error(`Invalid hook name "${name}": expected "<${KINDS.join("|")}>.<operation>"`);
|
|
898
|
+
}
|
|
899
|
+
return { kind, operation: name.slice(i + 1) };
|
|
900
|
+
}
|
|
901
|
+
var HookSystem = class {
|
|
902
|
+
constructor(onError = () => {
|
|
903
|
+
}) {
|
|
904
|
+
this.onError = onError;
|
|
905
|
+
}
|
|
906
|
+
onError;
|
|
907
|
+
entries = [];
|
|
908
|
+
seq = 0;
|
|
909
|
+
use(name, handler, opts = {}) {
|
|
910
|
+
const { kind, operation } = split(name);
|
|
911
|
+
const entry = { kind, operation, handler, priority: opts.priority ?? 0, seq: this.seq++, owner: opts.owner };
|
|
912
|
+
this.entries.push(entry);
|
|
913
|
+
return toDisposable(() => {
|
|
914
|
+
const i = this.entries.indexOf(entry);
|
|
915
|
+
if (i >= 0) this.entries.splice(i, 1);
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
count(kind, operation) {
|
|
919
|
+
return this.entries.filter((e) => (!kind || e.kind === kind) && (!operation || e.operation === operation)).length;
|
|
920
|
+
}
|
|
921
|
+
list() {
|
|
922
|
+
return this.entries.map(({ kind, operation, priority, owner }) => ({ kind, operation, priority, owner }));
|
|
923
|
+
}
|
|
924
|
+
get(kind, operation) {
|
|
925
|
+
return this.entries.filter((e) => e.kind === kind && e.operation === operation).sort((a, b) => b.priority - a.priority || a.seq - b.seq);
|
|
926
|
+
}
|
|
927
|
+
async run(operation, input, exec) {
|
|
928
|
+
const ctx = {
|
|
929
|
+
operation,
|
|
930
|
+
input,
|
|
931
|
+
result: void 0,
|
|
932
|
+
cancelled: false,
|
|
933
|
+
cancelReason: void 0,
|
|
934
|
+
data: /* @__PURE__ */ new Map(),
|
|
935
|
+
cancel(reason) {
|
|
936
|
+
ctx.cancelled = true;
|
|
937
|
+
ctx.cancelReason = reason;
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
const guard = () => {
|
|
941
|
+
if (ctx.cancelled) throw new HookCancelledError(operation, ctx.cancelReason);
|
|
942
|
+
};
|
|
943
|
+
for (const kind of ["validate", "before"]) {
|
|
944
|
+
for (const h of this.get(kind, operation)) {
|
|
945
|
+
await h.handler(ctx);
|
|
946
|
+
guard();
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
const arounds = this.get("around", operation);
|
|
950
|
+
const core = async () => ctx.result = await exec(ctx.input, ctx);
|
|
951
|
+
const composed = arounds.reduceRight(
|
|
952
|
+
(next, h) => async () => {
|
|
953
|
+
guard();
|
|
954
|
+
const r = await h.handler(ctx, next);
|
|
955
|
+
if (r !== void 0) ctx.result = r;
|
|
956
|
+
return ctx.result;
|
|
957
|
+
},
|
|
958
|
+
core
|
|
959
|
+
);
|
|
960
|
+
await composed();
|
|
961
|
+
guard();
|
|
962
|
+
for (const h of this.get("transform", operation)) {
|
|
963
|
+
const r = await h.handler(ctx);
|
|
964
|
+
if (r !== void 0) ctx.result = r;
|
|
965
|
+
}
|
|
966
|
+
for (const h of this.get("after", operation)) {
|
|
967
|
+
try {
|
|
968
|
+
await h.handler(ctx);
|
|
969
|
+
} catch (e) {
|
|
970
|
+
this.onError(e, `after.${operation}`, h.owner);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return ctx.result;
|
|
974
|
+
}
|
|
975
|
+
/** Runs `filter.<name>` handlers as a value pipeline. A failing filter is skipped and reported. */
|
|
976
|
+
async filter(name, value, extra) {
|
|
977
|
+
let current = value;
|
|
978
|
+
for (const h of this.get("filter", name)) {
|
|
979
|
+
try {
|
|
980
|
+
const r = await h.handler(current, extra);
|
|
981
|
+
if (r !== void 0) current = r;
|
|
982
|
+
} catch (e) {
|
|
983
|
+
this.onError(e, `filter.${name}`, h.owner);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return current;
|
|
987
|
+
}
|
|
988
|
+
/** Synchronous variant for render-time filters (async filters are ignored with a reported error). */
|
|
989
|
+
filterSync(name, value, extra) {
|
|
990
|
+
let current = value;
|
|
991
|
+
for (const h of this.get("filter", name)) {
|
|
992
|
+
try {
|
|
993
|
+
const r = h.handler(current, extra);
|
|
994
|
+
if (r && typeof r.then === "function") throw new Error("async filter used in filterSync()");
|
|
995
|
+
if (r !== void 0) current = r;
|
|
996
|
+
} catch (e) {
|
|
997
|
+
this.onError(e, `filter.${name}`, h.owner);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return current;
|
|
1001
|
+
}
|
|
1002
|
+
removeByOwner(owner) {
|
|
1003
|
+
this.entries = this.entries.filter((e) => e.owner !== owner);
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
// src/core/permissions.ts
|
|
1008
|
+
var PermissionManager = class {
|
|
1009
|
+
constructor(opts = {}) {
|
|
1010
|
+
this.opts = opts;
|
|
1011
|
+
this.autoGrant = opts.autoGrant ?? true;
|
|
1012
|
+
for (const [id, perms] of Object.entries(opts.approved ?? {})) this.granted.set(id, new Set(perms));
|
|
1013
|
+
}
|
|
1014
|
+
opts;
|
|
1015
|
+
declared = /* @__PURE__ */ new Map();
|
|
1016
|
+
granted = /* @__PURE__ */ new Map();
|
|
1017
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1018
|
+
autoGrant;
|
|
1019
|
+
/** Restores grants persisted from a previous session (call before `declare`). */
|
|
1020
|
+
restore(approved) {
|
|
1021
|
+
for (const [id, perms] of Object.entries(approved)) this.granted.set(id, new Set(perms));
|
|
1022
|
+
}
|
|
1023
|
+
/** Registers a plugin's requested permissions (called when its manifest is loaded). */
|
|
1024
|
+
declare(pluginId, permissions) {
|
|
1025
|
+
const set = new Set(permissions);
|
|
1026
|
+
this.declared.set(pluginId, set);
|
|
1027
|
+
const granted = this.granted.get(pluginId) ?? /* @__PURE__ */ new Set();
|
|
1028
|
+
if (this.autoGrant) for (const p of set) granted.add(p);
|
|
1029
|
+
for (const p of [...granted]) if (!set.has(p)) granted.delete(p);
|
|
1030
|
+
this.granted.set(pluginId, granted);
|
|
1031
|
+
this.notify(pluginId);
|
|
1032
|
+
}
|
|
1033
|
+
forget(pluginId) {
|
|
1034
|
+
this.declared.delete(pluginId);
|
|
1035
|
+
this.granted.delete(pluginId);
|
|
1036
|
+
}
|
|
1037
|
+
has(pluginId, permission) {
|
|
1038
|
+
return this.granted.get(pluginId)?.has(permission) ?? false;
|
|
1039
|
+
}
|
|
1040
|
+
hasAll(pluginId, permissions) {
|
|
1041
|
+
return permissions.every((p) => this.has(pluginId, p));
|
|
1042
|
+
}
|
|
1043
|
+
assert(pluginId, permission, api = permission) {
|
|
1044
|
+
if (!this.has(pluginId, permission)) throw new PermissionDeniedError(pluginId, permission, api);
|
|
1045
|
+
}
|
|
1046
|
+
grant(pluginId, permission) {
|
|
1047
|
+
if (!this.declared.get(pluginId)?.has(permission)) {
|
|
1048
|
+
throw new Error(`Plugin "${pluginId}" did not declare permission "${permission}"`);
|
|
1049
|
+
}
|
|
1050
|
+
const set = this.granted.get(pluginId) ?? /* @__PURE__ */ new Set();
|
|
1051
|
+
set.add(permission);
|
|
1052
|
+
this.granted.set(pluginId, set);
|
|
1053
|
+
this.notify(pluginId);
|
|
1054
|
+
}
|
|
1055
|
+
grantAll(pluginId) {
|
|
1056
|
+
for (const p of this.declared.get(pluginId) ?? []) this.grant(pluginId, p);
|
|
1057
|
+
}
|
|
1058
|
+
revoke(pluginId, permission) {
|
|
1059
|
+
this.granted.get(pluginId)?.delete(permission);
|
|
1060
|
+
this.notify(pluginId);
|
|
1061
|
+
}
|
|
1062
|
+
snapshot(pluginId) {
|
|
1063
|
+
const declared = [...this.declared.get(pluginId) ?? []];
|
|
1064
|
+
const granted = [...this.granted.get(pluginId) ?? []];
|
|
1065
|
+
return { declared, granted, denied: declared.filter((p) => !granted.includes(p)) };
|
|
1066
|
+
}
|
|
1067
|
+
onChange(l) {
|
|
1068
|
+
this.listeners.add(l);
|
|
1069
|
+
return toDisposable(() => this.listeners.delete(l));
|
|
1070
|
+
}
|
|
1071
|
+
notify(pluginId) {
|
|
1072
|
+
this.opts.onChange?.(pluginId, this.snapshot(pluginId));
|
|
1073
|
+
for (const l of this.listeners) l(pluginId);
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
// src/core/registry.ts
|
|
1078
|
+
var Registry = class {
|
|
1079
|
+
constructor(kind) {
|
|
1080
|
+
this.kind = kind;
|
|
1081
|
+
}
|
|
1082
|
+
kind;
|
|
1083
|
+
table = /* @__PURE__ */ new Map();
|
|
1084
|
+
subscribers = /* @__PURE__ */ new Set();
|
|
1085
|
+
_version = 0;
|
|
1086
|
+
get version() {
|
|
1087
|
+
return this._version;
|
|
1088
|
+
}
|
|
1089
|
+
add(key, item, owner) {
|
|
1090
|
+
if (this.table.has(key)) {
|
|
1091
|
+
const existing = this.table.get(key);
|
|
1092
|
+
throw new FrameworkError(
|
|
1093
|
+
`${this.kind} "${key}" is already registered${existing.owner ? ` by "${existing.owner}"` : ""}`,
|
|
1094
|
+
"FLUX_DUPLICATE",
|
|
1095
|
+
owner
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
const entry = { item, owner };
|
|
1099
|
+
this.table.set(key, entry);
|
|
1100
|
+
this.changed();
|
|
1101
|
+
return toDisposable(() => {
|
|
1102
|
+
if (this.table.get(key) === entry) {
|
|
1103
|
+
this.table.delete(key);
|
|
1104
|
+
this.changed();
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
has(key) {
|
|
1109
|
+
return this.table.has(key);
|
|
1110
|
+
}
|
|
1111
|
+
get(key) {
|
|
1112
|
+
return this.table.get(key)?.item;
|
|
1113
|
+
}
|
|
1114
|
+
ownerOf(key) {
|
|
1115
|
+
return this.table.get(key)?.owner;
|
|
1116
|
+
}
|
|
1117
|
+
list() {
|
|
1118
|
+
return [...this.table.values()].map((e) => e.item);
|
|
1119
|
+
}
|
|
1120
|
+
listByOwner(owner) {
|
|
1121
|
+
return [...this.table.values()].filter((e) => e.owner === owner).map((e) => e.item);
|
|
1122
|
+
}
|
|
1123
|
+
unregisterByOwner(owner) {
|
|
1124
|
+
let n = 0;
|
|
1125
|
+
for (const [k, e] of this.table) {
|
|
1126
|
+
if (e.owner === owner) {
|
|
1127
|
+
this.table.delete(k);
|
|
1128
|
+
n++;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
if (n) this.changed();
|
|
1132
|
+
return n;
|
|
1133
|
+
}
|
|
1134
|
+
clear() {
|
|
1135
|
+
this.table.clear();
|
|
1136
|
+
this.changed();
|
|
1137
|
+
}
|
|
1138
|
+
/** Subscribes to any change. Returns an unsubscribe function compatible with `useSyncExternalStore`. */
|
|
1139
|
+
subscribe = (listener) => {
|
|
1140
|
+
this.subscribers.add(listener);
|
|
1141
|
+
return () => void this.subscribers.delete(listener);
|
|
1142
|
+
};
|
|
1143
|
+
changed() {
|
|
1144
|
+
this._version++;
|
|
1145
|
+
for (const l of [...this.subscribers]) {
|
|
1146
|
+
try {
|
|
1147
|
+
l();
|
|
1148
|
+
} catch {
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
var CommandRegistry = class extends Registry {
|
|
1154
|
+
/** Set by the plugin manager: activates plugins that declared `onCommand:<id>` before execution. */
|
|
1155
|
+
beforeExecute;
|
|
1156
|
+
/** Observer used for diagnostics / hooks. */
|
|
1157
|
+
onExecuted;
|
|
1158
|
+
constructor() {
|
|
1159
|
+
super("Command");
|
|
1160
|
+
}
|
|
1161
|
+
register(command, owner) {
|
|
1162
|
+
return this.add(command.id, command, owner);
|
|
1163
|
+
}
|
|
1164
|
+
async execute(id, ...args) {
|
|
1165
|
+
if (!this.has(id)) await this.beforeExecute?.(id);
|
|
1166
|
+
const cmd = this.get(id);
|
|
1167
|
+
if (!cmd) throw new FrameworkError(`Unknown command "${id}"`, "FLUX_UNKNOWN_COMMAND");
|
|
1168
|
+
if (cmd.when && !cmd.when()) throw new FrameworkError(`Command "${id}" is not available right now`, "FLUX_COMMAND_DISABLED");
|
|
1169
|
+
try {
|
|
1170
|
+
const r = await cmd.execute(...args);
|
|
1171
|
+
this.onExecuted?.(id);
|
|
1172
|
+
return r;
|
|
1173
|
+
} catch (e) {
|
|
1174
|
+
this.onExecuted?.(id, e);
|
|
1175
|
+
throw e;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
/** Commands sorted for display in a palette. */
|
|
1179
|
+
palette(query = "") {
|
|
1180
|
+
const q = query.trim().toLowerCase();
|
|
1181
|
+
return this.list().filter((c) => (!c.when || c.when()) && (!q || `${c.category ?? ""} ${c.title} ${c.id}`.toLowerCase().includes(q))).sort((a, b) => (a.category ?? "").localeCompare(b.category ?? "") || a.title.localeCompare(b.title));
|
|
1182
|
+
}
|
|
1183
|
+
/** Finds the command bound to a keyboard shortcut. */
|
|
1184
|
+
byKeybinding(binding) {
|
|
1185
|
+
const norm = (b) => b.toLowerCase().replace(/\s+/g, "").split("+").sort().join("+");
|
|
1186
|
+
return this.list().find((c) => c.keybinding && norm(c.keybinding) === norm(binding));
|
|
1187
|
+
}
|
|
1188
|
+
};
|
|
1189
|
+
var MenuRegistry = class extends Registry {
|
|
1190
|
+
constructor() {
|
|
1191
|
+
super("Menu item");
|
|
1192
|
+
}
|
|
1193
|
+
register(item, owner) {
|
|
1194
|
+
return this.add(`${item.location}:${item.id}`, item, owner);
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Hierarchical view of a location: items that name a `parent`, plus the parent's own `children`, are nested under it
|
|
1198
|
+
* (sorted by order). Unknown parents fall back to top level, so a missing plugin never hides its siblings.
|
|
1199
|
+
*/
|
|
1200
|
+
tree(location) {
|
|
1201
|
+
const flat = this.items(location);
|
|
1202
|
+
const byId = new Map(flat.map((i) => [i.id, i]));
|
|
1203
|
+
const toNode = (i) => ({ ...i, children: [...i.children ?? []] });
|
|
1204
|
+
const nodes = new Map(flat.map((i) => [i.id, toNode(i)]));
|
|
1205
|
+
const roots = [];
|
|
1206
|
+
for (const i of flat) {
|
|
1207
|
+
const n = nodes.get(i.id);
|
|
1208
|
+
const parent = i.parent && i.parent !== i.id ? nodes.get(i.parent) : void 0;
|
|
1209
|
+
if (parent && byId.has(i.parent)) parent.children.push(n);
|
|
1210
|
+
else roots.push(n);
|
|
1211
|
+
}
|
|
1212
|
+
const sort = (list) => {
|
|
1213
|
+
list.sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label));
|
|
1214
|
+
for (const n of list) {
|
|
1215
|
+
if (n.children?.length) sort(n.children);
|
|
1216
|
+
else delete n.children;
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1219
|
+
sort(roots);
|
|
1220
|
+
return roots;
|
|
1221
|
+
}
|
|
1222
|
+
items(location) {
|
|
1223
|
+
return this.list().filter((m) => m.location === location && (!m.when || m.when())).sort((a, b) => (a.group ?? "").localeCompare(b.group ?? "") || (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label));
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
1226
|
+
var ServiceRegistry = class extends Registry {
|
|
1227
|
+
/** Set by the plugin manager: activates plugins declaring `onService:<name>` on first lookup. */
|
|
1228
|
+
beforeResolve;
|
|
1229
|
+
constructor() {
|
|
1230
|
+
super("Service");
|
|
1231
|
+
}
|
|
1232
|
+
register(name, implementation, owner) {
|
|
1233
|
+
return this.add(name, { id: name, implementation }, owner);
|
|
1234
|
+
}
|
|
1235
|
+
/** Synchronous lookup — returns undefined when the providing plugin is not active. */
|
|
1236
|
+
resolve(name) {
|
|
1237
|
+
return this.get(name)?.implementation;
|
|
1238
|
+
}
|
|
1239
|
+
/** Looks a service up, activating its provider lazily if necessary. */
|
|
1240
|
+
async resolveAsync(name) {
|
|
1241
|
+
if (!this.has(name)) await this.beforeResolve?.(name);
|
|
1242
|
+
const s2 = this.get(name);
|
|
1243
|
+
if (!s2) throw new FrameworkError(`Service "${name}" is not available`, "FLUX_UNKNOWN_SERVICE");
|
|
1244
|
+
return s2.implementation;
|
|
1245
|
+
}
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1248
|
+
// src/core/routes.ts
|
|
1249
|
+
function normalizePath(path18) {
|
|
1250
|
+
const clean = "/" + path18.split("?")[0].split("#")[0].split("/").filter(Boolean).join("/");
|
|
1251
|
+
return clean;
|
|
1252
|
+
}
|
|
1253
|
+
function toSegments(fullPath) {
|
|
1254
|
+
return fullPath.split("/").filter(Boolean).map((s2) => s2 === "*" ? { type: "splat" } : s2.startsWith(":") ? { type: "param", name: s2.slice(1) } : { type: "static", value: s2 });
|
|
1255
|
+
}
|
|
1256
|
+
function scoreOf(segs) {
|
|
1257
|
+
return segs.reduce((n, s2) => n + (s2.type === "static" ? 100 : s2.type === "param" ? 10 : 1), segs.length);
|
|
1258
|
+
}
|
|
1259
|
+
function joinPaths(base, child) {
|
|
1260
|
+
if (child.startsWith("/")) return normalizePath(child);
|
|
1261
|
+
return normalizePath(`${base}/${child}`);
|
|
1262
|
+
}
|
|
1263
|
+
var RouteRegistry = class extends Registry {
|
|
1264
|
+
/** Host-provided values handed to guards (e.g. `{ isAuthenticated: true }`). */
|
|
1265
|
+
guardContext = {};
|
|
1266
|
+
/** Set by the plugin manager to lazily activate plugins declaring `onRoute:<path>`. */
|
|
1267
|
+
beforeResolve;
|
|
1268
|
+
/** Called for every route with `meta.menu`: lets the manager create sidebar entries automatically. */
|
|
1269
|
+
onRouteRegistered;
|
|
1270
|
+
layouts = /* @__PURE__ */ new Map();
|
|
1271
|
+
constructor() {
|
|
1272
|
+
super("Route");
|
|
1273
|
+
}
|
|
1274
|
+
registerLayout(id, component) {
|
|
1275
|
+
if (this.layouts.has(id)) throw new FrameworkError(`Layout "${id}" is already registered`, "FLUX_DUPLICATE");
|
|
1276
|
+
this.layouts.set(id, component);
|
|
1277
|
+
this.bump();
|
|
1278
|
+
return toDisposable(() => {
|
|
1279
|
+
this.layouts.delete(id);
|
|
1280
|
+
this.bump();
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
getLayout(id) {
|
|
1284
|
+
return this.layouts.get(id);
|
|
1285
|
+
}
|
|
1286
|
+
bump() {
|
|
1287
|
+
this.changed();
|
|
1288
|
+
}
|
|
1289
|
+
/** Registers a route (and its children). Returns one disposable removing all of them. */
|
|
1290
|
+
register(def, owner) {
|
|
1291
|
+
const disposables = [];
|
|
1292
|
+
const walk2 = (d, parent) => {
|
|
1293
|
+
if (!d.component && !d.lazy && !d.children?.length) {
|
|
1294
|
+
throw new FrameworkError(`Route "${d.path}" needs a component, a lazy loader or children`, "FLUX_INVALID_ROUTE", owner);
|
|
1295
|
+
}
|
|
1296
|
+
const fullPath = parent ? joinPaths(parent.fullPath, d.path) : normalizePath(d.path);
|
|
1297
|
+
const segments = toSegments(fullPath);
|
|
1298
|
+
const resolved = {
|
|
1299
|
+
fullPath,
|
|
1300
|
+
definition: d,
|
|
1301
|
+
owner,
|
|
1302
|
+
parents: parent ? [...parent.parents, parent] : [],
|
|
1303
|
+
segments,
|
|
1304
|
+
score: scoreOf(segments)
|
|
1305
|
+
};
|
|
1306
|
+
disposables.push(this.add(`${owner ?? ""}|${fullPath}`, resolved, owner));
|
|
1307
|
+
const extra = this.onRouteRegistered?.(resolved, owner);
|
|
1308
|
+
if (extra) disposables.push(extra);
|
|
1309
|
+
for (const child of d.children ?? []) walk2(child, resolved);
|
|
1310
|
+
};
|
|
1311
|
+
try {
|
|
1312
|
+
walk2(def, void 0);
|
|
1313
|
+
} catch (e) {
|
|
1314
|
+
for (const d of disposables.reverse()) d.dispose();
|
|
1315
|
+
throw e;
|
|
1316
|
+
}
|
|
1317
|
+
return toDisposable(() => {
|
|
1318
|
+
for (const d of disposables.reverse()) d.dispose();
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
/** Finds the best route for a concrete pathname. */
|
|
1322
|
+
match(pathname) {
|
|
1323
|
+
const target = normalizePath(pathname).split("/").filter(Boolean).map((s2) => {
|
|
1324
|
+
try {
|
|
1325
|
+
return decodeURIComponent(s2);
|
|
1326
|
+
} catch {
|
|
1327
|
+
return s2;
|
|
1328
|
+
}
|
|
1329
|
+
});
|
|
1330
|
+
let best;
|
|
1331
|
+
for (const route of this.list()) {
|
|
1332
|
+
const def = route.definition;
|
|
1333
|
+
if (!def.component && !def.lazy) continue;
|
|
1334
|
+
const params = {};
|
|
1335
|
+
let ok2 = true;
|
|
1336
|
+
const segs = route.segments;
|
|
1337
|
+
for (let i = 0; i < segs.length; i++) {
|
|
1338
|
+
const seg = segs[i];
|
|
1339
|
+
if (seg.type === "splat") {
|
|
1340
|
+
params["*"] = target.slice(i).join("/");
|
|
1341
|
+
if (target.length < i) ok2 = false;
|
|
1342
|
+
break;
|
|
1343
|
+
}
|
|
1344
|
+
const t = target[i];
|
|
1345
|
+
if (t === void 0) {
|
|
1346
|
+
ok2 = false;
|
|
1347
|
+
break;
|
|
1348
|
+
}
|
|
1349
|
+
if (seg.type === "static" && seg.value !== t) {
|
|
1350
|
+
ok2 = false;
|
|
1351
|
+
break;
|
|
1352
|
+
}
|
|
1353
|
+
if (seg.type === "param") params[seg.name] = t;
|
|
1354
|
+
if (i === segs.length - 1 && target.length > segs.length) ok2 = false;
|
|
1355
|
+
}
|
|
1356
|
+
if (ok2 && segs.length === 0 && target.length > 0) ok2 = false;
|
|
1357
|
+
if (!ok2) continue;
|
|
1358
|
+
if (!best || route.score > best.route.score) best = { route, params, chain: [...route.parents, route] };
|
|
1359
|
+
}
|
|
1360
|
+
return best;
|
|
1361
|
+
}
|
|
1362
|
+
/** Same as `match` but first lets lazily-activated plugins register their routes. */
|
|
1363
|
+
async matchAsync(pathname) {
|
|
1364
|
+
const first = this.match(pathname);
|
|
1365
|
+
if (first) return first;
|
|
1366
|
+
await this.beforeResolve?.(normalizePath(pathname));
|
|
1367
|
+
return this.match(pathname);
|
|
1368
|
+
}
|
|
1369
|
+
/** Evaluates `access`, then every guard from the root layout down to the route itself. */
|
|
1370
|
+
async checkGuards(m, pathname) {
|
|
1371
|
+
for (const r of m.chain) {
|
|
1372
|
+
const base = { path: normalizePath(pathname), params: m.params, route: m.route, host: this.guardContext };
|
|
1373
|
+
if (r.definition.access === "protected" && !this.guardContext["isAuthenticated"]) {
|
|
1374
|
+
return { allow: false, redirect: this.guardContext["loginPath"] ?? "/login", reason: "authentication required" };
|
|
1375
|
+
}
|
|
1376
|
+
for (const g of r.definition.guards ?? []) {
|
|
1377
|
+
let res;
|
|
1378
|
+
try {
|
|
1379
|
+
res = await g(base);
|
|
1380
|
+
} catch (e) {
|
|
1381
|
+
return { allow: false, reason: `guard threw: ${e instanceof Error ? e.message : String(e)}` };
|
|
1382
|
+
}
|
|
1383
|
+
if (typeof res === "string") return { allow: false, redirect: res, reason: "redirected by guard" };
|
|
1384
|
+
if (res === false) return { allow: false, reason: "blocked by guard" };
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
return { allow: true };
|
|
1388
|
+
}
|
|
1389
|
+
breadcrumbs(m) {
|
|
1390
|
+
const out = [];
|
|
1391
|
+
for (const r of m.chain) {
|
|
1392
|
+
const b = r.definition.meta?.breadcrumb ?? r.definition.meta?.title;
|
|
1393
|
+
if (!b) continue;
|
|
1394
|
+
const label = typeof b === "function" ? b(m.params) : b;
|
|
1395
|
+
const path18 = r.fullPath.replace(/:([A-Za-z0-9_]+)/g, (_, k) => encodeURIComponent(m.params[k] ?? `:${k}`));
|
|
1396
|
+
out.push({ label, path: path18 });
|
|
1397
|
+
}
|
|
1398
|
+
return out;
|
|
1399
|
+
}
|
|
1400
|
+
};
|
|
1401
|
+
|
|
1402
|
+
// src/core/slots.ts
|
|
1403
|
+
var SlotRegistry = class extends Registry {
|
|
1404
|
+
counter = 0;
|
|
1405
|
+
/** Set by the plugin manager to activate plugins declaring `onSlot:<name>`. */
|
|
1406
|
+
onSlotRendered;
|
|
1407
|
+
declared = /* @__PURE__ */ new Set();
|
|
1408
|
+
constructor() {
|
|
1409
|
+
super("Slot contribution");
|
|
1410
|
+
}
|
|
1411
|
+
/** Hosts may declare their slots so typos in plugins are caught early. Declaring is optional. */
|
|
1412
|
+
declareSlot(name) {
|
|
1413
|
+
this.declared.add(name);
|
|
1414
|
+
}
|
|
1415
|
+
declaredSlots() {
|
|
1416
|
+
return [...this.declared];
|
|
1417
|
+
}
|
|
1418
|
+
register(c, owner, opts = {}) {
|
|
1419
|
+
if (opts.strict && this.declared.size > 0 && !this.declared.has(c.slot)) {
|
|
1420
|
+
throw new FrameworkError(`Unknown UI slot "${c.slot}"`, "FLUX_UNKNOWN_SLOT", owner);
|
|
1421
|
+
}
|
|
1422
|
+
const id = c.id ?? `${owner ?? "anon"}#${c.slot}#${this.counter++}`;
|
|
1423
|
+
const entry = { id, slot: c.slot, component: c.component, mode: c.mode ?? "append", priority: c.priority ?? 0, props: c.props, owner, when: c.when };
|
|
1424
|
+
return this.add(`${c.slot}::${id}`, entry, owner);
|
|
1425
|
+
}
|
|
1426
|
+
resolve(slot, props = {}) {
|
|
1427
|
+
this.onSlotRendered?.(slot);
|
|
1428
|
+
const all = this.list().filter((c) => c.slot === slot && (!c.when || safe(() => c.when(props)))).sort((a, b) => b.priority - a.priority);
|
|
1429
|
+
const pick2 = (mode) => all.filter((c) => c.mode === mode);
|
|
1430
|
+
return {
|
|
1431
|
+
before: [...pick2("prepend"), ...pick2("before")],
|
|
1432
|
+
replacement: pick2("replace")[0],
|
|
1433
|
+
after: [...pick2("after"), ...pick2("append")]
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
/** Slots that currently have at least one contribution. */
|
|
1437
|
+
activeSlots() {
|
|
1438
|
+
return [...new Set(this.list().map((c) => c.slot))];
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
function safe(fn) {
|
|
1442
|
+
try {
|
|
1443
|
+
return fn();
|
|
1444
|
+
} catch {
|
|
1445
|
+
return false;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
var ExtensionPointRegistry = class extends Registry {
|
|
1449
|
+
points = /* @__PURE__ */ new Map();
|
|
1450
|
+
counter = 0;
|
|
1451
|
+
constructor() {
|
|
1452
|
+
super("Extension point contribution");
|
|
1453
|
+
}
|
|
1454
|
+
declare(def) {
|
|
1455
|
+
if (this.points.has(def.name)) throw new FrameworkError(`Extension point "${def.name}" is already declared`, "FLUX_DUPLICATE");
|
|
1456
|
+
this.points.set(def.name, def);
|
|
1457
|
+
return {
|
|
1458
|
+
dispose: () => {
|
|
1459
|
+
this.points.delete(def.name);
|
|
1460
|
+
}
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
definition(name) {
|
|
1464
|
+
return this.points.get(name);
|
|
1465
|
+
}
|
|
1466
|
+
declaredPoints() {
|
|
1467
|
+
return [...this.points.values()];
|
|
1468
|
+
}
|
|
1469
|
+
contribute(point, value, owner, priority = 0) {
|
|
1470
|
+
const def = this.points.get(point);
|
|
1471
|
+
if (!def) throw new FrameworkError(`Unknown extension point "${point}"`, "FLUX_UNKNOWN_EXTENSION_POINT", owner);
|
|
1472
|
+
for (const [key, v] of Object.entries(value)) {
|
|
1473
|
+
if (!(key in def.contributions)) {
|
|
1474
|
+
throw new FrameworkError(`Extension point "${point}" does not accept "${key}" (allowed: ${Object.keys(def.contributions).join(", ")})`, "FLUX_INVALID_CONTRIBUTION", owner);
|
|
1475
|
+
}
|
|
1476
|
+
const schema = def.contributions[key];
|
|
1477
|
+
if (schema) {
|
|
1478
|
+
for (const item of Array.isArray(v) ? v : [v]) {
|
|
1479
|
+
const r = schema.parse(item, `${point}.${key}`);
|
|
1480
|
+
if (!r.ok) throw new FrameworkError(`Invalid contribution to "${point}.${key}": ${r.issues.map((i) => i.message).join("; ")}`, "FLUX_INVALID_CONTRIBUTION", owner);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
return this.add(`${point}::${owner ?? "anon"}::${this.counter++}`, { point, priority, value }, owner);
|
|
1485
|
+
}
|
|
1486
|
+
/** Merged, priority-ordered items contributed under `key` of `point`. */
|
|
1487
|
+
items(point, key) {
|
|
1488
|
+
return this.list().filter((c) => c.point === point && key in c.value).sort((a, b) => b.priority - a.priority).flatMap((c) => {
|
|
1489
|
+
const v = c.value[key];
|
|
1490
|
+
return Array.isArray(v) ? v : [v];
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1494
|
+
|
|
1495
|
+
// src/core/styles.ts
|
|
1496
|
+
var StyleRegistry = class extends Registry {
|
|
1497
|
+
n = 0;
|
|
1498
|
+
constructor() {
|
|
1499
|
+
super("Style");
|
|
1500
|
+
}
|
|
1501
|
+
register(input, owner) {
|
|
1502
|
+
if (!input.css && !input.href) throw new Error("style needs `css` or `href`");
|
|
1503
|
+
const id = input.id ?? `style-${this.n++}`;
|
|
1504
|
+
const entry = { id, css: input.css, href: input.href, priority: input.priority ?? 100, scoped: input.scoped ?? false, owner };
|
|
1505
|
+
return this.add(`${owner}::${id}`, entry, owner);
|
|
1506
|
+
}
|
|
1507
|
+
/** Entries in cascade order. */
|
|
1508
|
+
ordered() {
|
|
1509
|
+
return this.list().map((e, i) => ({ e, i })).sort((a, b) => a.e.priority - b.e.priority || a.i - b.i).map((x) => x.e);
|
|
1510
|
+
}
|
|
1511
|
+
};
|
|
1512
|
+
function scopeCss(css, pluginId) {
|
|
1513
|
+
return `@scope ([data-fluxplugin="${pluginId.replace(/"/g, "")}"]) {
|
|
1514
|
+
${css}
|
|
1515
|
+
}`;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// src/core/ipc.ts
|
|
1519
|
+
function defineIpcContract(c) {
|
|
1520
|
+
return Object.freeze({ ...c });
|
|
1521
|
+
}
|
|
1522
|
+
function defineIPC(def) {
|
|
1523
|
+
const contract = Object.freeze({
|
|
1524
|
+
name: def.name,
|
|
1525
|
+
input: def.input,
|
|
1526
|
+
output: def.output,
|
|
1527
|
+
permission: def.permission
|
|
1528
|
+
});
|
|
1529
|
+
return { ...contract, handler: def.handler, contract };
|
|
1530
|
+
}
|
|
1531
|
+
var RESERVED = /^flux\./;
|
|
1532
|
+
var IpcError = class extends FrameworkError {
|
|
1533
|
+
constructor(message, code, channel) {
|
|
1534
|
+
super(message, code);
|
|
1535
|
+
this.channel = channel;
|
|
1536
|
+
}
|
|
1537
|
+
channel;
|
|
1538
|
+
};
|
|
1539
|
+
function serializeError(e) {
|
|
1540
|
+
if (e instanceof ValidationError) return { code: "FLUX_VALIDATION", message: e.message, issues: e.issues };
|
|
1541
|
+
if (e instanceof FrameworkError) return { code: e.code, message: e.message };
|
|
1542
|
+
return { code: "FLUX_HANDLER_ERROR", message: errorMessage(e) };
|
|
1543
|
+
}
|
|
1544
|
+
var IpcRegistry = class extends Registry {
|
|
1545
|
+
oneWay = /* @__PURE__ */ new Map();
|
|
1546
|
+
transport;
|
|
1547
|
+
/** Hook for the manager: ensures the owner plugin is active (`onIPC:<name>`) and checks caller permissions. */
|
|
1548
|
+
beforeInvoke;
|
|
1549
|
+
/** Permission check provided by the manager. */
|
|
1550
|
+
assertCallerPermission;
|
|
1551
|
+
/** Observer used for diagnostics. */
|
|
1552
|
+
onCall;
|
|
1553
|
+
constructor() {
|
|
1554
|
+
super("IPC handler");
|
|
1555
|
+
}
|
|
1556
|
+
setTransport(t) {
|
|
1557
|
+
this.transport = t;
|
|
1558
|
+
}
|
|
1559
|
+
handle(a, handler, owner, opts = {}) {
|
|
1560
|
+
const c = typeof a === "string" ? { name: a, ...opts } : a;
|
|
1561
|
+
if (RESERVED.test(c.name) && owner !== "host") {
|
|
1562
|
+
throw new FrameworkError(`IPC names starting with "flux." are reserved for the framework`, "FLUX_RESERVED", owner);
|
|
1563
|
+
}
|
|
1564
|
+
return this.add(c.name, { name: c.name, handler, input: c.input, output: c.output, permission: c.permission, owner }, owner);
|
|
1565
|
+
}
|
|
1566
|
+
/** Registers a full `IpcDefinition` (contract + handler). */
|
|
1567
|
+
register(def, owner) {
|
|
1568
|
+
return this.handle(def.contract, def.handler, owner);
|
|
1569
|
+
}
|
|
1570
|
+
/** Dispatches a call: validate input → permission → handler → validate output. */
|
|
1571
|
+
async invoke(name, input, opts = {}) {
|
|
1572
|
+
if (!this.has(name)) await this.beforeInvoke?.(name, opts);
|
|
1573
|
+
const entry = this.table.get(name)?.item;
|
|
1574
|
+
if (!entry) throw new IpcError(`No handler registered for "${name}"`, "FLUX_NO_HANDLER", name);
|
|
1575
|
+
const caller = opts.caller ?? "host";
|
|
1576
|
+
const started = Date.now();
|
|
1577
|
+
try {
|
|
1578
|
+
if (entry.permission && caller !== "host") this.assertCallerPermission?.(caller, entry.permission, name);
|
|
1579
|
+
let value = input;
|
|
1580
|
+
if (entry.input) {
|
|
1581
|
+
const r = entry.input.parse(input);
|
|
1582
|
+
if (!r.ok) throw new ValidationError(r.issues, `input of "${name}"`);
|
|
1583
|
+
value = r.value;
|
|
1584
|
+
}
|
|
1585
|
+
const out = await entry.handler(value, { pluginId: entry.owner, caller, senderId: opts.senderId });
|
|
1586
|
+
if (entry.output) {
|
|
1587
|
+
const r = entry.output.parse(out);
|
|
1588
|
+
if (!r.ok) throw new ValidationError(r.issues, `output of "${name}"`);
|
|
1589
|
+
this.onCall?.({ name, owner: entry.owner, caller, ms: Date.now() - started });
|
|
1590
|
+
return r.value;
|
|
1591
|
+
}
|
|
1592
|
+
this.onCall?.({ name, owner: entry.owner, caller, ms: Date.now() - started });
|
|
1593
|
+
return out;
|
|
1594
|
+
} catch (e) {
|
|
1595
|
+
this.onCall?.({ name, owner: entry.owner, caller, ms: Date.now() - started, error: e });
|
|
1596
|
+
throw e;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
/** Fire-and-forget messages from renderer/other plugins to main (`ipc.on`). */
|
|
1600
|
+
on(name, fn, owner) {
|
|
1601
|
+
let set = this.oneWay.get(name);
|
|
1602
|
+
if (!set) this.oneWay.set(name, set = /* @__PURE__ */ new Set());
|
|
1603
|
+
const entry = { fn, owner };
|
|
1604
|
+
set.add(entry);
|
|
1605
|
+
return toDisposable(() => set.delete(entry));
|
|
1606
|
+
}
|
|
1607
|
+
/** Delivers a one-way message to main-side listeners. */
|
|
1608
|
+
dispatch(name, payload, opts = {}) {
|
|
1609
|
+
const set = this.oneWay.get(name);
|
|
1610
|
+
if (!set) return 0;
|
|
1611
|
+
let n = 0;
|
|
1612
|
+
for (const l of [...set]) {
|
|
1613
|
+
try {
|
|
1614
|
+
l.fn(payload, { pluginId: l.owner, caller: opts.caller ?? "host", senderId: opts.senderId });
|
|
1615
|
+
n++;
|
|
1616
|
+
} catch {
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
return n;
|
|
1620
|
+
}
|
|
1621
|
+
/** Main → renderer push. */
|
|
1622
|
+
send(channel, payload, target) {
|
|
1623
|
+
this.transport?.broadcast(channel, payload, target);
|
|
1624
|
+
}
|
|
1625
|
+
removeListenersByOwner(owner) {
|
|
1626
|
+
for (const set of this.oneWay.values()) for (const l of [...set]) if (l.owner === owner) set.delete(l);
|
|
1627
|
+
}
|
|
1628
|
+
};
|
|
1629
|
+
var preloadMember = {
|
|
1630
|
+
invoke: (channel) => ({ kind: "invoke", channel }),
|
|
1631
|
+
send: (channel) => ({ kind: "send", channel }),
|
|
1632
|
+
event: (channel) => ({ kind: "event", channel })
|
|
1633
|
+
};
|
|
1634
|
+
var NAMESPACE_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1635
|
+
var FORBIDDEN_NS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype", "flux", "__fluxplugin"]);
|
|
1636
|
+
var PreloadRegistry = class extends Registry {
|
|
1637
|
+
constructor() {
|
|
1638
|
+
super("Preload API");
|
|
1639
|
+
}
|
|
1640
|
+
expose(namespace, members, owner) {
|
|
1641
|
+
if (!NAMESPACE_RE.test(namespace) || FORBIDDEN_NS.has(namespace)) {
|
|
1642
|
+
throw new FrameworkError(`Invalid preload namespace "${namespace}"`, "FLUX_INVALID_NAMESPACE", owner);
|
|
1643
|
+
}
|
|
1644
|
+
const spec = {};
|
|
1645
|
+
for (const [key, m] of Object.entries(members)) {
|
|
1646
|
+
if (!NAMESPACE_RE.test(key) || FORBIDDEN_NS.has(key)) {
|
|
1647
|
+
throw new FrameworkError(`Invalid preload member "${namespace}.${key}"`, "FLUX_INVALID_NAMESPACE", owner);
|
|
1648
|
+
}
|
|
1649
|
+
spec[key] = typeof m === "string" ? { kind: "invoke", channel: m } : m;
|
|
1650
|
+
}
|
|
1651
|
+
return this.add(namespace, { id: namespace, namespace, spec, owner }, owner);
|
|
1652
|
+
}
|
|
1653
|
+
/** JSON-serializable manifest sent to the preload bridge. */
|
|
1654
|
+
manifest() {
|
|
1655
|
+
const out = {};
|
|
1656
|
+
for (const e of this.list()) out[e.namespace] = e.spec;
|
|
1657
|
+
return out;
|
|
1658
|
+
}
|
|
1659
|
+
};
|
|
1660
|
+
|
|
1661
|
+
// src/core/storage.ts
|
|
1662
|
+
var MemoryStorageBackend = class {
|
|
1663
|
+
data = /* @__PURE__ */ new Map();
|
|
1664
|
+
async read(ns) {
|
|
1665
|
+
return structuredClone(this.data.get(ns) ?? {});
|
|
1666
|
+
}
|
|
1667
|
+
async write(ns, data) {
|
|
1668
|
+
this.data.set(ns, structuredClone(data));
|
|
1669
|
+
}
|
|
1670
|
+
async remove(ns) {
|
|
1671
|
+
this.data.delete(ns);
|
|
1672
|
+
}
|
|
1673
|
+
};
|
|
1674
|
+
var CONFIG_KEY = "__fluxplugin_config__";
|
|
1675
|
+
function createPluginStorage(backend2, namespace) {
|
|
1676
|
+
let queue = Promise.resolve();
|
|
1677
|
+
const exclusive = (fn) => {
|
|
1678
|
+
const run2 = queue.then(fn, fn);
|
|
1679
|
+
queue = run2.catch(() => void 0);
|
|
1680
|
+
return run2;
|
|
1681
|
+
};
|
|
1682
|
+
const guard = (key) => {
|
|
1683
|
+
if (typeof key !== "string" || key.length === 0) throw new Error("Storage key must be a non-empty string");
|
|
1684
|
+
if (key === CONFIG_KEY || key === "__proto__") throw new Error(`Storage key "${key}" is reserved`);
|
|
1685
|
+
};
|
|
1686
|
+
return {
|
|
1687
|
+
async get(key, fallback) {
|
|
1688
|
+
guard(key);
|
|
1689
|
+
const data = await exclusive(() => backend2.read(namespace));
|
|
1690
|
+
return Object.prototype.hasOwnProperty.call(data, key) ? structuredClone(data[key]) : fallback;
|
|
1691
|
+
},
|
|
1692
|
+
async set(key, value) {
|
|
1693
|
+
guard(key);
|
|
1694
|
+
await exclusive(async () => {
|
|
1695
|
+
const data = await backend2.read(namespace);
|
|
1696
|
+
data[key] = structuredClone(value);
|
|
1697
|
+
await backend2.write(namespace, data);
|
|
1698
|
+
});
|
|
1699
|
+
},
|
|
1700
|
+
async delete(key) {
|
|
1701
|
+
guard(key);
|
|
1702
|
+
await exclusive(async () => {
|
|
1703
|
+
const data = await backend2.read(namespace);
|
|
1704
|
+
delete data[key];
|
|
1705
|
+
await backend2.write(namespace, data);
|
|
1706
|
+
});
|
|
1707
|
+
},
|
|
1708
|
+
async keys() {
|
|
1709
|
+
const data = await exclusive(() => backend2.read(namespace));
|
|
1710
|
+
return Object.keys(data).filter((k) => k !== CONFIG_KEY);
|
|
1711
|
+
},
|
|
1712
|
+
async clear() {
|
|
1713
|
+
await exclusive(async () => {
|
|
1714
|
+
const data = await backend2.read(namespace);
|
|
1715
|
+
await backend2.write(namespace, CONFIG_KEY in data ? { [CONFIG_KEY]: data[CONFIG_KEY] } : {});
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
function createPluginConfig(backend2, namespace, def, onChanged) {
|
|
1721
|
+
const version = def.version ?? 1;
|
|
1722
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1723
|
+
let queue = Promise.resolve();
|
|
1724
|
+
const exclusive = (fn) => {
|
|
1725
|
+
const run2 = queue.then(fn, fn);
|
|
1726
|
+
queue = run2.catch(() => void 0);
|
|
1727
|
+
return run2;
|
|
1728
|
+
};
|
|
1729
|
+
const defaults = () => {
|
|
1730
|
+
const r = def.schema.parse({});
|
|
1731
|
+
if (!r.ok) throw new ValidationError(r.issues, `default configuration of "${namespace}" (every field needs a default or to be optional)`);
|
|
1732
|
+
return r.value;
|
|
1733
|
+
};
|
|
1734
|
+
const load = async () => {
|
|
1735
|
+
const data = await backend2.read(namespace);
|
|
1736
|
+
const stored = data[CONFIG_KEY];
|
|
1737
|
+
if (!stored) return defaults();
|
|
1738
|
+
let values = stored.values ?? {};
|
|
1739
|
+
if (stored.version < version && def.migrate) values = def.migrate(values, stored.version);
|
|
1740
|
+
const r = def.schema.parse(values);
|
|
1741
|
+
if (r.ok) return r.value;
|
|
1742
|
+
return defaults();
|
|
1743
|
+
};
|
|
1744
|
+
const save = async (values) => {
|
|
1745
|
+
const data = await backend2.read(namespace);
|
|
1746
|
+
data[CONFIG_KEY] = { version, values };
|
|
1747
|
+
await backend2.write(namespace, data);
|
|
1748
|
+
};
|
|
1749
|
+
const emit = (next, prev) => {
|
|
1750
|
+
onChanged?.(next, prev);
|
|
1751
|
+
for (const l of [...listeners]) {
|
|
1752
|
+
try {
|
|
1753
|
+
l(next, prev);
|
|
1754
|
+
} catch {
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
};
|
|
1758
|
+
return {
|
|
1759
|
+
get: () => exclusive(load),
|
|
1760
|
+
set: (patch) => exclusive(async () => {
|
|
1761
|
+
const prev = await load();
|
|
1762
|
+
const r = def.schema.parse({ ...prev, ...patch });
|
|
1763
|
+
if (!r.ok) throw new ValidationError(r.issues, `configuration of "${namespace}"`);
|
|
1764
|
+
await save(r.value);
|
|
1765
|
+
emit(r.value, prev);
|
|
1766
|
+
return r.value;
|
|
1767
|
+
}),
|
|
1768
|
+
reset: () => exclusive(async () => {
|
|
1769
|
+
const prev = await load();
|
|
1770
|
+
const next = defaults();
|
|
1771
|
+
await save(next);
|
|
1772
|
+
emit(next, prev);
|
|
1773
|
+
return next;
|
|
1774
|
+
}),
|
|
1775
|
+
onChange(l) {
|
|
1776
|
+
listeners.add(l);
|
|
1777
|
+
return toDisposable(() => listeners.delete(l));
|
|
1778
|
+
},
|
|
1779
|
+
describe: () => def.schema.describe()
|
|
1780
|
+
};
|
|
1781
|
+
}
|
|
1782
|
+
function createPluginState() {
|
|
1783
|
+
const data = /* @__PURE__ */ new Map();
|
|
1784
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1785
|
+
const notify = (k, v) => {
|
|
1786
|
+
for (const l of [...listeners]) {
|
|
1787
|
+
if (l.key !== void 0 && l.key !== k) continue;
|
|
1788
|
+
try {
|
|
1789
|
+
l.fn(k, v);
|
|
1790
|
+
} catch {
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
};
|
|
1794
|
+
return {
|
|
1795
|
+
get: ((key, fallback) => data.has(key) ? data.get(key) : fallback),
|
|
1796
|
+
set(key, value) {
|
|
1797
|
+
if (Object.is(data.get(key), value) && data.has(key)) return;
|
|
1798
|
+
data.set(key, value);
|
|
1799
|
+
notify(key, value);
|
|
1800
|
+
},
|
|
1801
|
+
delete(key) {
|
|
1802
|
+
if (data.delete(key)) notify(key, void 0);
|
|
1803
|
+
},
|
|
1804
|
+
subscribe(fn, key) {
|
|
1805
|
+
const l = { fn, key };
|
|
1806
|
+
listeners.add(l);
|
|
1807
|
+
return toDisposable(() => listeners.delete(l));
|
|
1808
|
+
},
|
|
1809
|
+
snapshot: () => Object.fromEntries(data)
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// src/core/deps.ts
|
|
1814
|
+
function buildDependencyGraph(manifests) {
|
|
1815
|
+
const map = /* @__PURE__ */ new Map();
|
|
1816
|
+
for (const m of manifests) map.set(m.id, m);
|
|
1817
|
+
const issues = [];
|
|
1818
|
+
const edges = /* @__PURE__ */ new Map();
|
|
1819
|
+
const blocked = /* @__PURE__ */ new Set();
|
|
1820
|
+
for (const m of map.values()) {
|
|
1821
|
+
const out = [];
|
|
1822
|
+
for (const kind of ["dependencies", "peerDependencies"]) {
|
|
1823
|
+
for (const [dep, range] of Object.entries(m[kind] ?? {})) {
|
|
1824
|
+
const target = map.get(dep);
|
|
1825
|
+
if (!target) {
|
|
1826
|
+
issues.push({ type: "missing", plugin: m.id, dependency: dep, range, kind });
|
|
1827
|
+
blocked.add(m.id);
|
|
1828
|
+
continue;
|
|
1829
|
+
}
|
|
1830
|
+
if (!satisfies(target.version, range)) {
|
|
1831
|
+
issues.push({ type: "version", plugin: m.id, dependency: dep, range, found: target.version });
|
|
1832
|
+
blocked.add(m.id);
|
|
1833
|
+
continue;
|
|
1834
|
+
}
|
|
1835
|
+
out.push(dep);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
for (const [dep, range] of Object.entries(m.optionalDependencies ?? {})) {
|
|
1839
|
+
const target = map.get(dep);
|
|
1840
|
+
if (target && satisfies(target.version, range)) out.push(dep);
|
|
1841
|
+
}
|
|
1842
|
+
edges.set(m.id, out);
|
|
1843
|
+
}
|
|
1844
|
+
const state = /* @__PURE__ */ new Map();
|
|
1845
|
+
const order = [];
|
|
1846
|
+
const stack = [];
|
|
1847
|
+
const visit = (id) => {
|
|
1848
|
+
const st = state.get(id) ?? 0;
|
|
1849
|
+
if (st === 2) return;
|
|
1850
|
+
if (st === 1) {
|
|
1851
|
+
const path18 = [...stack.slice(stack.indexOf(id)), id];
|
|
1852
|
+
issues.push({ type: "cycle", plugin: id, path: path18 });
|
|
1853
|
+
for (const p of path18) blocked.add(p);
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
state.set(id, 1);
|
|
1857
|
+
stack.push(id);
|
|
1858
|
+
for (const dep of edges.get(id) ?? []) visit(dep);
|
|
1859
|
+
stack.pop();
|
|
1860
|
+
state.set(id, 2);
|
|
1861
|
+
order.push(id);
|
|
1862
|
+
};
|
|
1863
|
+
for (const id of [...map.keys()].sort()) visit(id);
|
|
1864
|
+
let changed = true;
|
|
1865
|
+
while (changed) {
|
|
1866
|
+
changed = false;
|
|
1867
|
+
for (const [id, deps] of edges) {
|
|
1868
|
+
if (!blocked.has(id) && deps.some((d) => blocked.has(d))) {
|
|
1869
|
+
blocked.add(id);
|
|
1870
|
+
changed = true;
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
return { manifests: map, edges, issues, blocked, order: order.filter((id) => !blocked.has(id)) };
|
|
1875
|
+
}
|
|
1876
|
+
function activationOrderFor(graph, id) {
|
|
1877
|
+
const out = [];
|
|
1878
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1879
|
+
const walk2 = (n) => {
|
|
1880
|
+
if (seen.has(n)) return;
|
|
1881
|
+
seen.add(n);
|
|
1882
|
+
for (const d of graph.edges.get(n) ?? []) walk2(d);
|
|
1883
|
+
out.push(n);
|
|
1884
|
+
};
|
|
1885
|
+
walk2(id);
|
|
1886
|
+
return out;
|
|
1887
|
+
}
|
|
1888
|
+
function dependentsOf(graph, id) {
|
|
1889
|
+
const reverse = /* @__PURE__ */ new Map();
|
|
1890
|
+
for (const [p, deps] of graph.edges) for (const d of deps) (reverse.get(d) ?? reverse.set(d, []).get(d)).push(p);
|
|
1891
|
+
const out = [];
|
|
1892
|
+
const seen = /* @__PURE__ */ new Set([id]);
|
|
1893
|
+
const walk2 = (n) => {
|
|
1894
|
+
for (const p of reverse.get(n) ?? []) {
|
|
1895
|
+
if (seen.has(p)) continue;
|
|
1896
|
+
seen.add(p);
|
|
1897
|
+
walk2(p);
|
|
1898
|
+
out.push(p);
|
|
1899
|
+
}
|
|
1900
|
+
};
|
|
1901
|
+
walk2(id);
|
|
1902
|
+
return out;
|
|
1903
|
+
}
|
|
1904
|
+
function describeIssue(i) {
|
|
1905
|
+
switch (i.type) {
|
|
1906
|
+
case "missing":
|
|
1907
|
+
return `${i.plugin} requires "${i.dependency}" (${i.range}) which is not installed`;
|
|
1908
|
+
case "version":
|
|
1909
|
+
return `${i.plugin} requires "${i.dependency}" ${i.range} but ${i.found} is installed`;
|
|
1910
|
+
case "cycle":
|
|
1911
|
+
return `dependency cycle: ${i.path.join(" \u2192 ")}`;
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
// src/core/plugin.ts
|
|
1916
|
+
function definePlugin(def) {
|
|
1917
|
+
return def;
|
|
1918
|
+
}
|
|
1919
|
+
var defineRoute = (r) => r;
|
|
1920
|
+
var defineCommand = (c) => c;
|
|
1921
|
+
var defineService = (name, impl) => ({ name, implementation: impl });
|
|
1922
|
+
var defineHook = (name, handler, priority) => ({ name, handler, priority });
|
|
1923
|
+
var defineEvent = (name, handler, opts = {}) => ({ name, handler, ...opts });
|
|
1924
|
+
var defineComponent = (c) => c;
|
|
1925
|
+
var defineMenu = (m) => m;
|
|
1926
|
+
|
|
1927
|
+
// src/core/notifications.ts
|
|
1928
|
+
var NotificationCenter = class {
|
|
1929
|
+
constructor(capacity = 200) {
|
|
1930
|
+
this.capacity = capacity;
|
|
1931
|
+
}
|
|
1932
|
+
capacity;
|
|
1933
|
+
items = [];
|
|
1934
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1935
|
+
pushListeners = /* @__PURE__ */ new Set();
|
|
1936
|
+
seq = 0;
|
|
1937
|
+
version = 0;
|
|
1938
|
+
push(input, owner = "host") {
|
|
1939
|
+
const id = input.id ?? `${owner}#${Date.now().toString(36)}-${this.seq++}`;
|
|
1940
|
+
this.items = this.items.filter((n) => n.id !== id);
|
|
1941
|
+
const item = {
|
|
1942
|
+
id,
|
|
1943
|
+
title: input.title,
|
|
1944
|
+
body: input.body,
|
|
1945
|
+
level: input.level ?? "info",
|
|
1946
|
+
actions: input.actions ?? [],
|
|
1947
|
+
timeoutMs: input.timeoutMs ?? 5e3,
|
|
1948
|
+
owner,
|
|
1949
|
+
time: Date.now(),
|
|
1950
|
+
read: false
|
|
1951
|
+
};
|
|
1952
|
+
this.items.unshift(item);
|
|
1953
|
+
if (this.items.length > this.capacity) this.items.length = this.capacity;
|
|
1954
|
+
this.changed();
|
|
1955
|
+
for (const l of [...this.pushListeners]) l(item);
|
|
1956
|
+
return { id, dismiss: () => this.dismiss(id) };
|
|
1957
|
+
}
|
|
1958
|
+
list() {
|
|
1959
|
+
return this.items;
|
|
1960
|
+
}
|
|
1961
|
+
get unread() {
|
|
1962
|
+
return this.items.filter((n) => !n.read).length;
|
|
1963
|
+
}
|
|
1964
|
+
markRead(id) {
|
|
1965
|
+
for (const n of this.items) if (!id || n.id === id) n.read = true;
|
|
1966
|
+
this.changed();
|
|
1967
|
+
}
|
|
1968
|
+
dismiss(id) {
|
|
1969
|
+
this.items = this.items.filter((n) => n.id !== id);
|
|
1970
|
+
this.changed();
|
|
1971
|
+
}
|
|
1972
|
+
clear() {
|
|
1973
|
+
this.items = [];
|
|
1974
|
+
this.changed();
|
|
1975
|
+
}
|
|
1976
|
+
subscribe = (l) => {
|
|
1977
|
+
this.listeners.add(l);
|
|
1978
|
+
return () => void this.listeners.delete(l);
|
|
1979
|
+
};
|
|
1980
|
+
/** Called for every new notification (toast host, native bridges, main→renderer forwarding). */
|
|
1981
|
+
onPush(l) {
|
|
1982
|
+
this.pushListeners.add(l);
|
|
1983
|
+
return toDisposable(() => this.pushListeners.delete(l));
|
|
1984
|
+
}
|
|
1985
|
+
changed() {
|
|
1986
|
+
this.version++;
|
|
1987
|
+
for (const l of [...this.listeners]) l();
|
|
1988
|
+
}
|
|
1989
|
+
};
|
|
1990
|
+
|
|
1991
|
+
// src/core/manager.ts
|
|
1992
|
+
var DEFAULT_TIMEOUTS = { load: 15e3, initialize: 1e4, activate: 15e3, ready: 1e4, deactivate: 1e4, unload: 1e4 };
|
|
1993
|
+
var PluginManager = class {
|
|
1994
|
+
constructor(opts) {
|
|
1995
|
+
this.opts = opts;
|
|
1996
|
+
this.runtime = opts.runtime;
|
|
1997
|
+
this.logs = opts.logs ?? new LogStore();
|
|
1998
|
+
this.log = this.logs.logger("core");
|
|
1999
|
+
this.storageBackend = opts.storage ?? new MemoryStorageBackend();
|
|
2000
|
+
this.permissions = opts.permissions ?? new PermissionManager();
|
|
2001
|
+
this.timeouts = { ...DEFAULT_TIMEOUTS, ...opts.timeouts };
|
|
2002
|
+
this.events = new EventBus((e, name, pattern) => this.log.error(`event listener for "${pattern}" failed on "${name}": ${errorMessage(e)}`));
|
|
2003
|
+
this.hooks = new HookSystem((e, name, owner) => this.logs.logger(owner ?? "core").error(`hook "${name}" failed: ${errorMessage(e)}`));
|
|
2004
|
+
this.wireLazyActivation();
|
|
2005
|
+
this.permissions.onChange(() => this.changed());
|
|
2006
|
+
}
|
|
2007
|
+
opts;
|
|
2008
|
+
runtime;
|
|
2009
|
+
logs;
|
|
2010
|
+
permissions;
|
|
2011
|
+
events;
|
|
2012
|
+
hooks;
|
|
2013
|
+
commands = new CommandRegistry();
|
|
2014
|
+
menus = new MenuRegistry();
|
|
2015
|
+
routes = new RouteRegistry();
|
|
2016
|
+
services = new ServiceRegistry();
|
|
2017
|
+
slots = new SlotRegistry();
|
|
2018
|
+
extensionPoints = new ExtensionPointRegistry();
|
|
2019
|
+
ipc = new IpcRegistry();
|
|
2020
|
+
preload = new PreloadRegistry();
|
|
2021
|
+
styles = new StyleRegistry();
|
|
2022
|
+
notifications = new NotificationCenter();
|
|
2023
|
+
storageBackend;
|
|
2024
|
+
records = /* @__PURE__ */ new Map();
|
|
2025
|
+
states = /* @__PURE__ */ new Map();
|
|
2026
|
+
hostApis = /* @__PURE__ */ new Map();
|
|
2027
|
+
timeouts;
|
|
2028
|
+
log;
|
|
2029
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2030
|
+
_version = 0;
|
|
2031
|
+
disposedFlag = false;
|
|
2032
|
+
/* ------------------------------------------------------------- observers */
|
|
2033
|
+
get version() {
|
|
2034
|
+
return this._version;
|
|
2035
|
+
}
|
|
2036
|
+
subscribe = (l) => {
|
|
2037
|
+
this.listeners.add(l);
|
|
2038
|
+
return () => void this.listeners.delete(l);
|
|
2039
|
+
};
|
|
2040
|
+
changed() {
|
|
2041
|
+
this._version++;
|
|
2042
|
+
for (const l of [...this.listeners]) {
|
|
2043
|
+
try {
|
|
2044
|
+
l();
|
|
2045
|
+
} catch {
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
/* ------------------------------------------------------------ host APIs */
|
|
2050
|
+
/** Exposes a controlled capability to plugins (`context.host.api(name)`), optionally gated by a permission. */
|
|
2051
|
+
provide(name, factory, permission) {
|
|
2052
|
+
if (this.hostApis.has(name)) throw new FrameworkError(`Host API "${name}" already provided`, "FLUX_DUPLICATE");
|
|
2053
|
+
this.hostApis.set(name, { factory, permission });
|
|
2054
|
+
return toDisposable(() => this.hostApis.delete(name));
|
|
2055
|
+
}
|
|
2056
|
+
/* --------------------------------------------------- discovery / validate */
|
|
2057
|
+
/** Persisted state (disabled plugins) must be loaded before registering sources. */
|
|
2058
|
+
async init() {
|
|
2059
|
+
const disabled = await this.opts.stateStore?.load() ?? [];
|
|
2060
|
+
this.persistedDisabled = new Set(disabled);
|
|
2061
|
+
}
|
|
2062
|
+
persistedDisabled = /* @__PURE__ */ new Set();
|
|
2063
|
+
/**
|
|
2064
|
+
* Discovers + validates one plugin. Invalid plugins are recorded in the `error` state (so the UI can show why)
|
|
2065
|
+
* and never throw — one broken plugin must not stop the others.
|
|
2066
|
+
*/
|
|
2067
|
+
register(source) {
|
|
2068
|
+
const v = validateManifest(source.manifest, { frameworkVersion: this.opts.frameworkVersion ?? FRAMEWORK_VERSION });
|
|
2069
|
+
const raw = source.manifest;
|
|
2070
|
+
const id = v.manifest?.id ?? (typeof raw?.id === "string" ? raw.id : `invalid-${this.records.size}`);
|
|
2071
|
+
if (this.records.has(id)) {
|
|
2072
|
+
this.log.warn(`plugin "${id}" is already registered; ignoring duplicate from ${source.origin ?? "unknown origin"}`);
|
|
2073
|
+
return this.info(id);
|
|
2074
|
+
}
|
|
2075
|
+
const manifest = v.manifest ?? {
|
|
2076
|
+
id,
|
|
2077
|
+
name: typeof raw?.name === "string" ? raw.name : id,
|
|
2078
|
+
version: typeof raw?.version === "string" ? raw.version : "0.0.0",
|
|
2079
|
+
activationEvents: [],
|
|
2080
|
+
deactivationEvents: [],
|
|
2081
|
+
permissions: [],
|
|
2082
|
+
dependencies: {},
|
|
2083
|
+
optionalDependencies: {},
|
|
2084
|
+
peerDependencies: {},
|
|
2085
|
+
engines: {},
|
|
2086
|
+
contributes: {},
|
|
2087
|
+
screenshots: [],
|
|
2088
|
+
categories: [],
|
|
2089
|
+
keywords: [],
|
|
2090
|
+
links: {}
|
|
2091
|
+
};
|
|
2092
|
+
const rec = {
|
|
2093
|
+
id,
|
|
2094
|
+
manifest,
|
|
2095
|
+
source,
|
|
2096
|
+
state: v.ok ? "validated" : "error",
|
|
2097
|
+
enabled: !this.persistedDisabled.has(id),
|
|
2098
|
+
store: new DisposableStore(),
|
|
2099
|
+
deactivators: [],
|
|
2100
|
+
lazy: [],
|
|
2101
|
+
crashes: [],
|
|
2102
|
+
warnings: v.warnings
|
|
2103
|
+
};
|
|
2104
|
+
if (!v.ok) {
|
|
2105
|
+
rec.error = { message: v.issues.map((i) => `${i.path || "<root>"}: ${i.message}`).join("; "), phase: "validate", time: Date.now() };
|
|
2106
|
+
this.logs.logger(id).error(`manifest validation failed: ${rec.error.message}`);
|
|
2107
|
+
} else {
|
|
2108
|
+
this.permissions.declare(id, manifest.permissions);
|
|
2109
|
+
if (!rec.enabled) rec.state = "disabled";
|
|
2110
|
+
}
|
|
2111
|
+
this.records.set(id, rec);
|
|
2112
|
+
if (v.ok && rec.enabled) this.installLazyProxies(rec);
|
|
2113
|
+
this.log.info(`discovered ${id}@${manifest.version} (${rec.state})`);
|
|
2114
|
+
this.events.emit("plugin.registered", { id }, "core");
|
|
2115
|
+
this.changed();
|
|
2116
|
+
return this.info(id);
|
|
2117
|
+
}
|
|
2118
|
+
async unregister(id) {
|
|
2119
|
+
const rec = this.records.get(id);
|
|
2120
|
+
if (!rec) return;
|
|
2121
|
+
await this.deactivate(id).catch(() => void 0);
|
|
2122
|
+
await this.unload(id).catch(() => void 0);
|
|
2123
|
+
for (const d of rec.lazy.splice(0)) d.dispose();
|
|
2124
|
+
this.permissions.forget(id);
|
|
2125
|
+
this.records.delete(id);
|
|
2126
|
+
this.events.emit("plugin.unregistered", { id }, "core");
|
|
2127
|
+
this.changed();
|
|
2128
|
+
}
|
|
2129
|
+
/* ----------------------------------------------------------- inspection */
|
|
2130
|
+
has(id) {
|
|
2131
|
+
return this.records.has(id);
|
|
2132
|
+
}
|
|
2133
|
+
list() {
|
|
2134
|
+
return [...this.records.keys()].sort().map((id) => this.info(id));
|
|
2135
|
+
}
|
|
2136
|
+
info(id) {
|
|
2137
|
+
const rec = this.mustGet(id);
|
|
2138
|
+
const m = rec.manifest;
|
|
2139
|
+
const graph = this.graph();
|
|
2140
|
+
const author = typeof m.author === "string" ? m.author : m.author?.name;
|
|
2141
|
+
return {
|
|
2142
|
+
id,
|
|
2143
|
+
name: m.name,
|
|
2144
|
+
version: m.version,
|
|
2145
|
+
description: m.description,
|
|
2146
|
+
author,
|
|
2147
|
+
state: rec.state,
|
|
2148
|
+
enabled: rec.enabled,
|
|
2149
|
+
runtime: this.runtime,
|
|
2150
|
+
permissions: this.permissions.snapshot(id),
|
|
2151
|
+
dependencies: { ...m.dependencies, ...m.peerDependencies, ...m.optionalDependencies },
|
|
2152
|
+
dependents: dependentsOf(graph, id),
|
|
2153
|
+
activationEvents: m.activationEvents ?? [],
|
|
2154
|
+
contributes: m.contributes ?? {},
|
|
2155
|
+
error: rec.error,
|
|
2156
|
+
crashes: rec.crashes.length,
|
|
2157
|
+
hasConfig: !!rec.definition?.config,
|
|
2158
|
+
root: rec.source.root,
|
|
2159
|
+
origin: rec.source.origin,
|
|
2160
|
+
warnings: rec.warnings
|
|
2161
|
+
};
|
|
2162
|
+
}
|
|
2163
|
+
/** Reactive per-plugin state store (same instance as `ctx.state`). Survives reloads. */
|
|
2164
|
+
stateOf(id) {
|
|
2165
|
+
let s2 = this.states.get(id);
|
|
2166
|
+
if (!s2) this.states.set(id, s2 = createPluginState());
|
|
2167
|
+
return s2;
|
|
2168
|
+
}
|
|
2169
|
+
/** Validated manifest (or the best-effort one for plugins rejected at validation). */
|
|
2170
|
+
manifest(id) {
|
|
2171
|
+
return this.mustGet(id).manifest;
|
|
2172
|
+
}
|
|
2173
|
+
getConfig(id) {
|
|
2174
|
+
return this.records.get(id)?.config;
|
|
2175
|
+
}
|
|
2176
|
+
/** Active plugin's context (for hosts and tests). */
|
|
2177
|
+
contextOf(id) {
|
|
2178
|
+
return this.records.get(id)?.context;
|
|
2179
|
+
}
|
|
2180
|
+
diagnostics(id) {
|
|
2181
|
+
const owned = (r) => r.listByOwner(id).length;
|
|
2182
|
+
return {
|
|
2183
|
+
info: this.info(id),
|
|
2184
|
+
logs: this.logs.query({ source: id }),
|
|
2185
|
+
contributions: {
|
|
2186
|
+
commands: owned(this.commands),
|
|
2187
|
+
menus: owned(this.menus),
|
|
2188
|
+
routes: owned(this.routes),
|
|
2189
|
+
services: owned(this.services),
|
|
2190
|
+
slots: owned(this.slots),
|
|
2191
|
+
extensions: owned(this.extensionPoints),
|
|
2192
|
+
ipc: owned(this.ipc),
|
|
2193
|
+
preload: owned(this.preload),
|
|
2194
|
+
styles: owned(this.styles)
|
|
2195
|
+
}
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
2198
|
+
mustGet(id) {
|
|
2199
|
+
const r = this.records.get(id);
|
|
2200
|
+
if (!r) throw new FrameworkError(`Unknown plugin "${id}"`, "FLUX_UNKNOWN_PLUGIN");
|
|
2201
|
+
return r;
|
|
2202
|
+
}
|
|
2203
|
+
graph() {
|
|
2204
|
+
return buildDependencyGraph([...this.records.values()].filter((r) => r.state !== "error" || r.error?.phase !== "validate").map((r) => r.manifest));
|
|
2205
|
+
}
|
|
2206
|
+
/* ------------------------------------------------------------ activation */
|
|
2207
|
+
/** Activates every plugin that wants to start with the application (`onStartup`, `*`, or no activation events). */
|
|
2208
|
+
async startup() {
|
|
2209
|
+
const graph = this.graph();
|
|
2210
|
+
for (const issue of graph.issues) this.log.warn(describeIssue(issue));
|
|
2211
|
+
const ids = graph.order.filter((id) => {
|
|
2212
|
+
const r = this.records.get(id);
|
|
2213
|
+
const ev = r.manifest.activationEvents ?? [];
|
|
2214
|
+
return r.enabled && (ev.length === 0 || ev.includes("onStartup") || ev.includes("*"));
|
|
2215
|
+
});
|
|
2216
|
+
for (const id of ids) await this.activate(id).catch(() => void 0);
|
|
2217
|
+
}
|
|
2218
|
+
/** Activates `id` and, first, everything it depends on. Rejects with a `LifecycleError` on failure. */
|
|
2219
|
+
async activate(id) {
|
|
2220
|
+
const rec = this.mustGet(id);
|
|
2221
|
+
if (!rec.enabled) throw new LifecycleError(id, "activate", "plugin is disabled");
|
|
2222
|
+
if (rec.state === "error" && rec.error?.phase === "validate") throw new LifecycleError(id, "validate", rec.error.message);
|
|
2223
|
+
const graph = this.graph();
|
|
2224
|
+
if (graph.blocked.has(id)) {
|
|
2225
|
+
const reasons = graph.issues.filter((i) => i.plugin === id || i.type === "cycle" && i.path.includes(id)).map(describeIssue);
|
|
2226
|
+
throw new LifecycleError(id, "activate", reasons.join("; ") || "a dependency cannot be activated");
|
|
2227
|
+
}
|
|
2228
|
+
await this.ensureReady(id, graph, []);
|
|
2229
|
+
}
|
|
2230
|
+
async ensureReady(id, graph, chain) {
|
|
2231
|
+
const rec = this.mustGet(id);
|
|
2232
|
+
if (rec.state === "ready") return;
|
|
2233
|
+
if (rec.inflight) return rec.inflight;
|
|
2234
|
+
if (!rec.enabled) throw new LifecycleError(id, "activate", "plugin is disabled");
|
|
2235
|
+
if (chain.includes(id)) throw new LifecycleError(id, "activate", `dependency cycle: ${[...chain, id].join(" \u2192 ")}`);
|
|
2236
|
+
const optional = new Set(Object.keys(rec.manifest.optionalDependencies ?? {}).filter((d) => !(d in (rec.manifest.dependencies ?? {})) && !(d in (rec.manifest.peerDependencies ?? {}))));
|
|
2237
|
+
const run2 = (async () => {
|
|
2238
|
+
for (const dep of graph.edges.get(id) ?? []) {
|
|
2239
|
+
try {
|
|
2240
|
+
await this.ensureReady(dep, graph, [...chain, id]);
|
|
2241
|
+
} catch (e) {
|
|
2242
|
+
if (optional.has(dep)) {
|
|
2243
|
+
this.logs.logger(id).warn(`optional dependency "${dep}" could not be activated: ${errorMessage(e)}`);
|
|
2244
|
+
continue;
|
|
2245
|
+
}
|
|
2246
|
+
const err = new LifecycleError(id, "activate", `required dependency "${dep}" failed: ${errorMessage(e)}`, e);
|
|
2247
|
+
await this.fail(rec, "activate", err, false);
|
|
2248
|
+
throw err;
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
await this.bringUp(rec);
|
|
2252
|
+
})();
|
|
2253
|
+
rec.inflight = run2;
|
|
2254
|
+
try {
|
|
2255
|
+
await run2;
|
|
2256
|
+
} finally {
|
|
2257
|
+
rec.inflight = void 0;
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
async phase(rec, phase, fn) {
|
|
2261
|
+
try {
|
|
2262
|
+
return await withTimeout(Promise.resolve().then(fn), this.timeouts[phase], `${phase} of "${rec.id}"`, rec.id);
|
|
2263
|
+
} catch (e) {
|
|
2264
|
+
if (e instanceof LifecycleError) throw e;
|
|
2265
|
+
throw new LifecycleError(rec.id, phase, errorMessage(e), e);
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
setState(rec, state) {
|
|
2269
|
+
rec.state = state;
|
|
2270
|
+
this.events.emit("plugin.state", { id: rec.id, state }, "core");
|
|
2271
|
+
this.changed();
|
|
2272
|
+
}
|
|
2273
|
+
async bringUp(rec) {
|
|
2274
|
+
let phase = "load";
|
|
2275
|
+
try {
|
|
2276
|
+
if (!rec.definition) {
|
|
2277
|
+
const def2 = await this.phase(rec, "load", () => rec.source.load());
|
|
2278
|
+
if (!def2 || typeof def2 !== "object") throw new Error("entry did not export a plugin definition (use definePlugin)");
|
|
2279
|
+
if (def2.id && def2.id !== rec.id) throw new Error(`plugin id mismatch: manifest "${rec.id}" vs entry "${def2.id}"`);
|
|
2280
|
+
rec.definition = def2;
|
|
2281
|
+
rec.context = this.createContext(rec);
|
|
2282
|
+
this.setState(rec, "loaded");
|
|
2283
|
+
await this.phase(rec, "load", () => def2.onLoad?.(rec.context));
|
|
2284
|
+
}
|
|
2285
|
+
const def = rec.definition;
|
|
2286
|
+
const ctx = rec.context;
|
|
2287
|
+
phase = "initialize";
|
|
2288
|
+
this.setState(rec, "initialized");
|
|
2289
|
+
await this.phase(rec, "initialize", () => def.onInitialize?.(ctx));
|
|
2290
|
+
phase = "activate";
|
|
2291
|
+
this.removeLazyProxies(rec);
|
|
2292
|
+
await this.phase(rec, "activate", async () => {
|
|
2293
|
+
await this.applyDeclarative(rec);
|
|
2294
|
+
await (def.onActivate ?? def.activate)?.(ctx);
|
|
2295
|
+
});
|
|
2296
|
+
this.setState(rec, "activated");
|
|
2297
|
+
phase = "ready";
|
|
2298
|
+
await this.phase(rec, "ready", () => def.onReady?.(ctx));
|
|
2299
|
+
rec.error = void 0;
|
|
2300
|
+
this.setState(rec, "ready");
|
|
2301
|
+
this.logs.logger(rec.id).info("ready");
|
|
2302
|
+
this.events.emit("plugin.ready", { id: rec.id }, "core");
|
|
2303
|
+
} catch (e) {
|
|
2304
|
+
await this.fail(rec, phase, e, true);
|
|
2305
|
+
throw e;
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
/** Records a failure, tears down everything the plugin registered and auto-disables repeat offenders. */
|
|
2309
|
+
async fail(rec, phase, e, cleanup) {
|
|
2310
|
+
const message = errorMessage(e);
|
|
2311
|
+
this.logs.logger(rec.id).error(`${phase} failed: ${message}`, e instanceof Error ? e.stack : void 0);
|
|
2312
|
+
if (cleanup) this.teardown(rec);
|
|
2313
|
+
rec.error = { message, phase, time: Date.now() };
|
|
2314
|
+
const now = Date.now();
|
|
2315
|
+
const windowMs = this.opts.crashWindowMs ?? 6e4;
|
|
2316
|
+
rec.crashes = [...rec.crashes.filter((t) => now - t < windowMs), now];
|
|
2317
|
+
this.events.emit("plugin.error", { id: rec.id, phase, message }, "core");
|
|
2318
|
+
if (rec.crashes.length >= (this.opts.maxCrashes ?? 3)) {
|
|
2319
|
+
rec.enabled = false;
|
|
2320
|
+
this.logs.logger(rec.id).warn(`disabled automatically after ${rec.crashes.length} failures`);
|
|
2321
|
+
await this.persistDisabled();
|
|
2322
|
+
this.setState(rec, "disabled");
|
|
2323
|
+
this.events.emit("plugin.autodisabled", { id: rec.id }, "core");
|
|
2324
|
+
} else {
|
|
2325
|
+
this.setState(rec, "error");
|
|
2326
|
+
}
|
|
2327
|
+
if (rec.enabled) this.installLazyProxies(rec);
|
|
2328
|
+
}
|
|
2329
|
+
/** Disposes every contribution of a plugin (store + owner-based sweep as a safety net). */
|
|
2330
|
+
teardown(rec) {
|
|
2331
|
+
rec.store.dispose();
|
|
2332
|
+
rec.store = new DisposableStore();
|
|
2333
|
+
rec.deactivators = [];
|
|
2334
|
+
this.sweep(rec.id);
|
|
2335
|
+
}
|
|
2336
|
+
sweep(owner) {
|
|
2337
|
+
this.commands.unregisterByOwner(owner);
|
|
2338
|
+
this.menus.unregisterByOwner(owner);
|
|
2339
|
+
this.routes.unregisterByOwner(owner);
|
|
2340
|
+
this.services.unregisterByOwner(owner);
|
|
2341
|
+
this.slots.unregisterByOwner(owner);
|
|
2342
|
+
this.extensionPoints.unregisterByOwner(owner);
|
|
2343
|
+
this.ipc.unregisterByOwner(owner);
|
|
2344
|
+
this.ipc.removeListenersByOwner(owner);
|
|
2345
|
+
this.preload.unregisterByOwner(owner);
|
|
2346
|
+
this.styles.unregisterByOwner(owner);
|
|
2347
|
+
this.hooks.removeByOwner(owner);
|
|
2348
|
+
}
|
|
2349
|
+
/* ---------------------------------------------------------- deactivation */
|
|
2350
|
+
/** Deactivates `id` after deactivating every active plugin that depends on it. Never throws for plugin errors. */
|
|
2351
|
+
async deactivate(id) {
|
|
2352
|
+
const rec = this.mustGet(id);
|
|
2353
|
+
const graph = this.graph();
|
|
2354
|
+
for (const dep of dependentsOf(graph, id)) {
|
|
2355
|
+
const d = this.records.get(dep);
|
|
2356
|
+
if (d && (d.state === "ready" || d.state === "activated" || d.state === "initialized" || d.state === "loaded")) await this.deactivateOne(d);
|
|
2357
|
+
}
|
|
2358
|
+
if (rec.inflight) await rec.inflight.catch(() => void 0);
|
|
2359
|
+
await this.deactivateOne(rec);
|
|
2360
|
+
}
|
|
2361
|
+
async deactivateOne(rec) {
|
|
2362
|
+
if (!["ready", "activated", "initialized", "loaded", "error"].includes(rec.state)) return;
|
|
2363
|
+
const log2 = this.logs.logger(rec.id);
|
|
2364
|
+
const def = rec.definition;
|
|
2365
|
+
const ctx = rec.context;
|
|
2366
|
+
if (def && ctx && rec.state !== "error") {
|
|
2367
|
+
try {
|
|
2368
|
+
await this.phase(rec, "deactivate", () => (def.onDeactivate ?? def.deactivate)?.(ctx));
|
|
2369
|
+
} catch (e) {
|
|
2370
|
+
log2.error(`deactivate hook failed: ${errorMessage(e)}`);
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
for (const fn of [...rec.deactivators].reverse()) {
|
|
2374
|
+
try {
|
|
2375
|
+
await fn();
|
|
2376
|
+
} catch (e) {
|
|
2377
|
+
log2.error(`cleanup failed: ${errorMessage(e)}`);
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
this.teardown(rec);
|
|
2381
|
+
this.setState(rec, "deactivated");
|
|
2382
|
+
if (rec.enabled) this.installLazyProxies(rec);
|
|
2383
|
+
this.events.emit("plugin.deactivated", { id: rec.id }, "core");
|
|
2384
|
+
}
|
|
2385
|
+
async unload(id) {
|
|
2386
|
+
const rec = this.mustGet(id);
|
|
2387
|
+
if (rec.state === "ready" || rec.state === "activated") await this.deactivate(id);
|
|
2388
|
+
const def = rec.definition;
|
|
2389
|
+
const ctx = rec.context;
|
|
2390
|
+
if (def && ctx) {
|
|
2391
|
+
try {
|
|
2392
|
+
await this.phase(rec, "unload", () => def.onUnload?.(ctx));
|
|
2393
|
+
} catch (e) {
|
|
2394
|
+
this.logs.logger(id).error(`unload hook failed: ${errorMessage(e)}`);
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
rec.definition = void 0;
|
|
2398
|
+
rec.context = void 0;
|
|
2399
|
+
rec.config = void 0;
|
|
2400
|
+
if (rec.state !== "disabled" && !(rec.state === "error" && rec.error?.phase === "validate")) this.setState(rec, "unloaded");
|
|
2401
|
+
}
|
|
2402
|
+
async disable(id) {
|
|
2403
|
+
const rec = this.mustGet(id);
|
|
2404
|
+
await this.deactivate(id);
|
|
2405
|
+
await this.unload(id);
|
|
2406
|
+
rec.enabled = false;
|
|
2407
|
+
for (const d of rec.lazy.splice(0)) d.dispose();
|
|
2408
|
+
await this.persistDisabled();
|
|
2409
|
+
this.setState(rec, "disabled");
|
|
2410
|
+
}
|
|
2411
|
+
async enable(id, opts = {}) {
|
|
2412
|
+
const rec = this.mustGet(id);
|
|
2413
|
+
rec.enabled = true;
|
|
2414
|
+
rec.crashes = [];
|
|
2415
|
+
rec.error = void 0;
|
|
2416
|
+
await this.persistDisabled();
|
|
2417
|
+
this.setState(rec, "validated");
|
|
2418
|
+
this.installLazyProxies(rec);
|
|
2419
|
+
if (opts.activate ?? true) await this.activate(id);
|
|
2420
|
+
}
|
|
2421
|
+
/** Development hot reload: deactivate → unload → load a fresh module → activate (if it was active). */
|
|
2422
|
+
async reload(id) {
|
|
2423
|
+
const rec = this.mustGet(id);
|
|
2424
|
+
const wasActive = rec.state === "ready" || rec.state === "error" && rec.error?.phase !== "validate";
|
|
2425
|
+
await this.deactivate(id);
|
|
2426
|
+
await this.unload(id);
|
|
2427
|
+
rec.error = void 0;
|
|
2428
|
+
if (rec.enabled && rec.state !== "disabled") this.setState(rec, "validated");
|
|
2429
|
+
this.events.emit("plugin.reloading", { id }, "core");
|
|
2430
|
+
if (wasActive || rec.manifest.activationEvents?.includes("onStartup")) await this.activate(id);
|
|
2431
|
+
this.events.emit("plugin.reloaded", { id }, "core");
|
|
2432
|
+
}
|
|
2433
|
+
async persistDisabled() {
|
|
2434
|
+
const ids = [...this.records.values()].filter((r) => !r.enabled).map((r) => r.id);
|
|
2435
|
+
this.persistedDisabled = new Set(ids);
|
|
2436
|
+
try {
|
|
2437
|
+
await this.opts.stateStore?.save(ids);
|
|
2438
|
+
} catch (e) {
|
|
2439
|
+
this.log.error(`could not persist plugin state: ${errorMessage(e)}`);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
async dispose() {
|
|
2443
|
+
this.disposedFlag = true;
|
|
2444
|
+
const graph = this.graph();
|
|
2445
|
+
for (const id of [...graph.order].reverse()) await this.deactivate(id).catch(() => void 0);
|
|
2446
|
+
for (const id of this.records.keys()) await this.unload(id).catch(() => void 0);
|
|
2447
|
+
this.events.removeAll();
|
|
2448
|
+
}
|
|
2449
|
+
/* ------------------------------------------------- lazy activation events */
|
|
2450
|
+
/** Activates plugins whose manifest lists `event` (or `*`). Used internally and by hosts for custom events. */
|
|
2451
|
+
async trigger(event) {
|
|
2452
|
+
if (this.disposedFlag) return;
|
|
2453
|
+
const targets = [...this.records.values()].filter((r) => {
|
|
2454
|
+
if (!r.enabled || r.state === "ready" || r.state === "error" || r.state === "disabled") return false;
|
|
2455
|
+
if (r.inflight) return true;
|
|
2456
|
+
return (r.manifest.activationEvents ?? []).some((e) => e === event || this.eventMatches(e, event));
|
|
2457
|
+
});
|
|
2458
|
+
await Promise.all(targets.map((r) => this.activate(r.id).catch(() => void 0)));
|
|
2459
|
+
}
|
|
2460
|
+
eventMatches(declared, event) {
|
|
2461
|
+
const [kd, ...rd] = declared.split(":");
|
|
2462
|
+
const [ke, ...re] = event.split(":");
|
|
2463
|
+
if (kd !== ke) return false;
|
|
2464
|
+
const pd = rd.join(":");
|
|
2465
|
+
const pe = re.join(":");
|
|
2466
|
+
if (kd === "onRoute") return routePatternMatches(pd, pe);
|
|
2467
|
+
return matchesPattern(pd, pe);
|
|
2468
|
+
}
|
|
2469
|
+
wireLazyActivation() {
|
|
2470
|
+
this.commands.beforeExecute = (id) => this.trigger(`onCommand:${id}`);
|
|
2471
|
+
this.services.beforeResolve = (name) => this.trigger(`onService:${name}`);
|
|
2472
|
+
this.routes.beforeResolve = (path18) => this.trigger(`onRoute:${path18}`);
|
|
2473
|
+
this.slots.onSlotRendered = (slot) => void this.trigger(`onSlot:${slot}`);
|
|
2474
|
+
this.ipc.beforeInvoke = (name) => this.trigger(`onIPC:${name}`);
|
|
2475
|
+
this.ipc.assertCallerPermission = (caller, permission, channel) => this.permissions.assert(caller, permission, `IPC "${channel}"`);
|
|
2476
|
+
this.events.on("**", (_p, ctx) => {
|
|
2477
|
+
if (!ctx.name.startsWith("plugin.")) void this.trigger(`onEvent:${ctx.name}`);
|
|
2478
|
+
}, { priority: 1e3 });
|
|
2479
|
+
this.routes.onRouteRegistered = (route, owner) => {
|
|
2480
|
+
const menu = route.definition.meta?.menu;
|
|
2481
|
+
if (!menu || !owner) return;
|
|
2482
|
+
const meta = route.definition.meta;
|
|
2483
|
+
return this.menus.register(
|
|
2484
|
+
{
|
|
2485
|
+
id: `route:${route.fullPath}`,
|
|
2486
|
+
location: menu.location ?? "sidebar",
|
|
2487
|
+
label: menu.label ?? meta.title ?? route.fullPath,
|
|
2488
|
+
icon: menu.icon ?? meta.icon,
|
|
2489
|
+
path: route.fullPath,
|
|
2490
|
+
order: menu.order,
|
|
2491
|
+
group: menu.group,
|
|
2492
|
+
parent: menu.parent
|
|
2493
|
+
},
|
|
2494
|
+
owner
|
|
2495
|
+
);
|
|
2496
|
+
};
|
|
2497
|
+
}
|
|
2498
|
+
/**
|
|
2499
|
+
* Before a plugin is active, its statically declared commands/menus (manifest `contributes`) are visible
|
|
2500
|
+
* as lightweight proxies: using one activates the plugin on demand. Replaced by the real registrations on activate.
|
|
2501
|
+
*/
|
|
2502
|
+
installLazyProxies(rec) {
|
|
2503
|
+
if (rec.lazy.length) return;
|
|
2504
|
+
const c = rec.manifest.contributes ?? {};
|
|
2505
|
+
const owner = rec.id;
|
|
2506
|
+
for (const cmd of c.commands ?? []) {
|
|
2507
|
+
if (this.commands.has(cmd.id)) continue;
|
|
2508
|
+
rec.lazy.push(
|
|
2509
|
+
this.commands.register(
|
|
2510
|
+
{
|
|
2511
|
+
id: cmd.id,
|
|
2512
|
+
title: cmd.title,
|
|
2513
|
+
category: cmd.category,
|
|
2514
|
+
execute: async (...args) => {
|
|
2515
|
+
await this.activate(owner);
|
|
2516
|
+
return this.commands.execute(cmd.id, ...args);
|
|
2517
|
+
}
|
|
2518
|
+
},
|
|
2519
|
+
`${owner}#lazy`
|
|
2520
|
+
)
|
|
2521
|
+
);
|
|
2522
|
+
}
|
|
2523
|
+
for (const m of c.menus ?? []) {
|
|
2524
|
+
if (this.menus.has(`${m.location}:${m.id}`)) continue;
|
|
2525
|
+
rec.lazy.push(this.menus.register({ id: m.id, location: m.location, label: m.label, command: m.command }, `${owner}#lazy`));
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
removeLazyProxies(rec) {
|
|
2529
|
+
for (const d of rec.lazy.splice(0)) d.dispose();
|
|
2530
|
+
}
|
|
2531
|
+
/* ------------------------------------------------- declarative contributions */
|
|
2532
|
+
async applyDeclarative(rec) {
|
|
2533
|
+
const def = rec.definition;
|
|
2534
|
+
const ctx = rec.context;
|
|
2535
|
+
const renderer = this.runtime === "renderer";
|
|
2536
|
+
const main = this.runtime === "main";
|
|
2537
|
+
for (const [id, component] of Object.entries(def.layouts ?? {})) if (renderer) ctx.routes.registerLayout(id, component);
|
|
2538
|
+
if (renderer) for (const r of def.routes ?? []) ctx.routes.register(r);
|
|
2539
|
+
for (const c of def.commands ?? []) ctx.commands.register(c);
|
|
2540
|
+
for (const m of def.menus ?? []) ctx.menus.register(m);
|
|
2541
|
+
for (const h of def.hooks ?? []) ctx.hooks.use(h.name, h.handler, { priority: h.priority });
|
|
2542
|
+
const services = typeof def.services === "function" ? def.services(ctx) : def.services;
|
|
2543
|
+
for (const [name, impl] of Object.entries(services ?? {})) ctx.services.register(name, impl);
|
|
2544
|
+
if (main) for (const i of def.ipc ?? []) ctx.ipc.define(i);
|
|
2545
|
+
for (const e of def.events ?? []) ctx.events.on(e.name, e.handler, { priority: e.priority, once: e.once });
|
|
2546
|
+
if (renderer) for (const c of def.components ?? []) ctx.ui.register(c);
|
|
2547
|
+
if (renderer) {
|
|
2548
|
+
for (const x of def.extensions ?? []) {
|
|
2549
|
+
try {
|
|
2550
|
+
ctx.ui.extend(x.point, { ...x.contribution, priority: x.priority });
|
|
2551
|
+
} catch (e) {
|
|
2552
|
+
ctx.logger.warn(`extension "${x.point}" skipped: ${errorMessage(e)}`);
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
if (renderer) for (const st of def.styles ?? []) ctx.styles.add(typeof st === "string" ? { css: st } : st);
|
|
2557
|
+
if (main) for (const [ns, members] of Object.entries(def.preload ?? {})) ctx.preload.expose(ns, members);
|
|
2558
|
+
}
|
|
2559
|
+
/* ------------------------------------------------------------- context */
|
|
2560
|
+
createContext(rec) {
|
|
2561
|
+
const id = rec.id;
|
|
2562
|
+
const store = () => rec.store;
|
|
2563
|
+
const track = (d) => store().add(d);
|
|
2564
|
+
const need = (permission, api) => this.permissions.assert(id, permission, api);
|
|
2565
|
+
const logger = this.logs.logger(id);
|
|
2566
|
+
const renderer = this.runtime === "renderer";
|
|
2567
|
+
const bridge = this.opts.ipcBridge;
|
|
2568
|
+
const def = rec.definition;
|
|
2569
|
+
const configDef = def.config ?? { schema: s.object({}) };
|
|
2570
|
+
const config = createPluginConfig(this.storageBackend, id, configDef, (next, prev) => {
|
|
2571
|
+
this.events.emit("plugin.config.changed", { id, next, prev }, id);
|
|
2572
|
+
this.changed();
|
|
2573
|
+
});
|
|
2574
|
+
rec.config = def.config ? config : void 0;
|
|
2575
|
+
const storage = createPluginStorage(this.storageBackend, id);
|
|
2576
|
+
const unsupported = (what) => {
|
|
2577
|
+
throw new FrameworkError(`${what} is not available in the ${this.runtime} process`, "FLUX_UNSUPPORTED_RUNTIME", id);
|
|
2578
|
+
};
|
|
2579
|
+
const ctx = {
|
|
2580
|
+
pluginId: id,
|
|
2581
|
+
manifest: rec.manifest,
|
|
2582
|
+
runtime: this.runtime,
|
|
2583
|
+
root: rec.source.root,
|
|
2584
|
+
logger,
|
|
2585
|
+
config,
|
|
2586
|
+
storage,
|
|
2587
|
+
state: this.stateOf(id),
|
|
2588
|
+
events: {
|
|
2589
|
+
on: (pattern, listener, opts) => (need("events", "events.on"), track(this.events.on(pattern, listener, opts))),
|
|
2590
|
+
once: (pattern, listener, opts) => (need("events", "events.once"), track(this.events.once(pattern, listener, opts))),
|
|
2591
|
+
emit: (name, payload, opts) => {
|
|
2592
|
+
need("events", "events.emit");
|
|
2593
|
+
const r = this.events.emit(name, payload, id);
|
|
2594
|
+
if (opts?.broadcast) this.opts.onBroadcastEvent?.(name, payload, id);
|
|
2595
|
+
return r;
|
|
2596
|
+
},
|
|
2597
|
+
emitAsync: (name, payload) => (need("events", "events.emitAsync"), this.events.emitAsync(name, payload, id))
|
|
2598
|
+
},
|
|
2599
|
+
commands: {
|
|
2600
|
+
register: (c) => (need("commands", "commands.register"), track(this.commands.register(c, id))),
|
|
2601
|
+
execute: (cid, ...args) => (need("commands", "commands.execute"), this.commands.execute(cid, ...args)),
|
|
2602
|
+
list: () => this.commands.list()
|
|
2603
|
+
},
|
|
2604
|
+
routes: {
|
|
2605
|
+
register: (r) => {
|
|
2606
|
+
need("routes", "routes.register");
|
|
2607
|
+
if (r.permission) need(r.permission, `route "${r.path}"`);
|
|
2608
|
+
return track(this.routes.register(r, id));
|
|
2609
|
+
},
|
|
2610
|
+
registerLayout: (lid, component) => (need("routes", "routes.registerLayout"), track(this.routes.registerLayout(lid, component)))
|
|
2611
|
+
},
|
|
2612
|
+
menus: { register: (m) => (need("menus", "menus.register"), track(this.menus.register(m, id))) },
|
|
2613
|
+
ui: {
|
|
2614
|
+
register: (c) => (need("ui", "ui.register"), track(this.slots.register(c, id, { strict: this.opts.strictSlots }))),
|
|
2615
|
+
extend: (point, contribution) => {
|
|
2616
|
+
need("ui", "ui.extend");
|
|
2617
|
+
const { mode, component, priority, ...data } = contribution;
|
|
2618
|
+
const parts = [];
|
|
2619
|
+
try {
|
|
2620
|
+
if (component !== void 0) parts.push(this.slots.register({ slot: point, component, mode: mode ?? "append", priority }, id));
|
|
2621
|
+
if (Object.keys(data).length) parts.push(this.extensionPoints.contribute(point, data, id, priority ?? 0));
|
|
2622
|
+
} catch (e) {
|
|
2623
|
+
for (const p of parts) p.dispose();
|
|
2624
|
+
throw e;
|
|
2625
|
+
}
|
|
2626
|
+
return track(toDisposable(() => parts.forEach((p) => p.dispose())));
|
|
2627
|
+
}
|
|
2628
|
+
},
|
|
2629
|
+
ipc: {
|
|
2630
|
+
handle: ((a, handler, opts) => {
|
|
2631
|
+
need("ipc", "ipc.handle");
|
|
2632
|
+
if (renderer) return unsupported("ipc.handle");
|
|
2633
|
+
return track(typeof a === "string" ? this.ipc.handle(a, handler, id, opts) : this.ipc.handle(a, handler, id));
|
|
2634
|
+
}),
|
|
2635
|
+
define: (d) => {
|
|
2636
|
+
need("ipc", "ipc.define");
|
|
2637
|
+
if (renderer) return unsupported("ipc.define");
|
|
2638
|
+
return track(this.ipc.register(d, id));
|
|
2639
|
+
},
|
|
2640
|
+
on: (name, handler) => {
|
|
2641
|
+
need("ipc", "ipc.on");
|
|
2642
|
+
if (renderer) return track(bridge ? bridge.on(name, (p) => handler(p, { pluginId: id, caller: "host" })) : toDisposable(() => {
|
|
2643
|
+
}));
|
|
2644
|
+
return track(this.ipc.on(name, handler, id));
|
|
2645
|
+
},
|
|
2646
|
+
send: (channel, payload) => {
|
|
2647
|
+
need("ipc", "ipc.send");
|
|
2648
|
+
if (renderer) bridge?.send(channel, payload, id);
|
|
2649
|
+
else this.ipc.send(channel, payload);
|
|
2650
|
+
},
|
|
2651
|
+
invoke: ((a, input) => {
|
|
2652
|
+
need("ipc", "ipc.invoke");
|
|
2653
|
+
const name = typeof a === "string" ? a : a.name;
|
|
2654
|
+
return renderer && bridge ? bridge.invoke(name, input, id) : this.ipc.invoke(name, input, { caller: id });
|
|
2655
|
+
})
|
|
2656
|
+
},
|
|
2657
|
+
preload: {
|
|
2658
|
+
expose: (ns, members) => {
|
|
2659
|
+
need("ipc", "preload.expose");
|
|
2660
|
+
if (renderer) return unsupported("preload.expose");
|
|
2661
|
+
return track(this.preload.expose(ns, members, id));
|
|
2662
|
+
}
|
|
2663
|
+
},
|
|
2664
|
+
services: {
|
|
2665
|
+
register: (name, impl) => (need("services", "services.register"), track(this.services.register(name, impl, id))),
|
|
2666
|
+
get: (name) => (need("services", "services.get"), this.services.resolve(name)),
|
|
2667
|
+
getAsync: (name) => (need("services", "services.getAsync"), this.services.resolveAsync(name))
|
|
2668
|
+
},
|
|
2669
|
+
hooks: {
|
|
2670
|
+
use: (name, handler, opts) => (need("hooks", "hooks.use"), track(this.hooks.use(name, handler, { ...opts, owner: id }))),
|
|
2671
|
+
run: (op, input, exec) => (need("hooks", "hooks.run"), this.hooks.run(op, input, exec)),
|
|
2672
|
+
filter: (name, value, extra) => (need("hooks", "hooks.filter"), this.hooks.filter(name, value, extra))
|
|
2673
|
+
},
|
|
2674
|
+
notifications: { push: (n) => (need("notifications", "notifications.push"), this.notifications.push(n, id)) },
|
|
2675
|
+
styles: { add: (st) => (need("styles", "styles.add"), track(this.styles.register(st, id))) },
|
|
2676
|
+
host: {
|
|
2677
|
+
has: (name) => this.hostApis.has(name),
|
|
2678
|
+
api: (name) => {
|
|
2679
|
+
const p = this.hostApis.get(name);
|
|
2680
|
+
if (!p) throw new FrameworkError(`Host API "${name}" is not provided by this application`, "FLUX_UNKNOWN_HOST_API", id);
|
|
2681
|
+
if (p.permission) need(p.permission, `host API "${name}"`);
|
|
2682
|
+
return p.factory({ pluginId: id, root: rec.source.root, manifest: rec.manifest });
|
|
2683
|
+
}
|
|
2684
|
+
},
|
|
2685
|
+
permissions: {
|
|
2686
|
+
has: (p) => this.permissions.has(id, p),
|
|
2687
|
+
assert: (p) => this.permissions.assert(id, p),
|
|
2688
|
+
list: () => this.permissions.snapshot(id)
|
|
2689
|
+
},
|
|
2690
|
+
lifecycle: {
|
|
2691
|
+
get state() {
|
|
2692
|
+
return rec.state;
|
|
2693
|
+
},
|
|
2694
|
+
onDeactivate: (fn) => void rec.deactivators.push(fn),
|
|
2695
|
+
subscriptions: { add: (d) => track(d) }
|
|
2696
|
+
}
|
|
2697
|
+
};
|
|
2698
|
+
return ctx;
|
|
2699
|
+
}
|
|
2700
|
+
};
|
|
2701
|
+
function routePatternMatches(pattern, path18) {
|
|
2702
|
+
const p = normalizePath(pattern).split("/").filter(Boolean);
|
|
2703
|
+
const t = normalizePath(path18).split("/").filter(Boolean);
|
|
2704
|
+
for (let i = 0; i < p.length; i++) {
|
|
2705
|
+
const seg = p[i];
|
|
2706
|
+
if (seg === "*") return true;
|
|
2707
|
+
if (t[i] === void 0) return false;
|
|
2708
|
+
if (seg.startsWith(":")) continue;
|
|
2709
|
+
if (seg !== t[i]) return false;
|
|
515
2710
|
}
|
|
516
|
-
return
|
|
2711
|
+
return p.length === t.length;
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
// src/core/protocol.ts
|
|
2715
|
+
var CHANNELS = {
|
|
2716
|
+
invoke: "flux:invoke",
|
|
2717
|
+
send: "flux:send",
|
|
2718
|
+
event: "flux:event",
|
|
2719
|
+
bootstrap: "flux:bootstrap"
|
|
2720
|
+
};
|
|
2721
|
+
var MGMT = {
|
|
2722
|
+
list: "flux.plugins.list",
|
|
2723
|
+
enable: "flux.plugins.enable",
|
|
2724
|
+
disable: "flux.plugins.disable",
|
|
2725
|
+
reload: "flux.plugins.reload",
|
|
2726
|
+
grant: "flux.plugins.grant",
|
|
2727
|
+
revoke: "flux.plugins.revoke",
|
|
2728
|
+
uninstall: "flux.plugins.uninstall",
|
|
2729
|
+
install: "flux.plugins.install",
|
|
2730
|
+
update: "flux.plugins.update",
|
|
2731
|
+
rollback: "flux.plugins.rollback",
|
|
2732
|
+
search: "flux.plugins.search",
|
|
2733
|
+
updates: "flux.plugins.updates",
|
|
2734
|
+
configGet: "flux.plugins.config.get",
|
|
2735
|
+
configSet: "flux.plugins.config.set",
|
|
2736
|
+
configReset: "flux.plugins.config.reset",
|
|
2737
|
+
configSchema: "flux.plugins.config.schema",
|
|
2738
|
+
logs: "flux.plugins.logs",
|
|
2739
|
+
diagnostics: "flux.plugins.diagnostics",
|
|
2740
|
+
details: "flux.plugins.details",
|
|
2741
|
+
changed: "flux.plugins.changed"
|
|
2742
|
+
};
|
|
2743
|
+
|
|
2744
|
+
// src/core/api.ts
|
|
2745
|
+
function pluginApi(namespace) {
|
|
2746
|
+
const root = globalThis.plugins;
|
|
2747
|
+
const api = root?.[namespace];
|
|
2748
|
+
if (!api) throw new Error(`window.plugins.${namespace} is not available (plugin disabled, permission missing, or not exposed)`);
|
|
2749
|
+
return api;
|
|
2750
|
+
}
|
|
2751
|
+
function hasPluginApi(namespace) {
|
|
2752
|
+
return !!globalThis.plugins?.[namespace];
|
|
517
2753
|
}
|
|
518
2754
|
|
|
2755
|
+
// src/main/framework.ts
|
|
2756
|
+
import { promises as fsp, watch } from "node:fs";
|
|
2757
|
+
import path7 from "node:path";
|
|
2758
|
+
|
|
2759
|
+
// src/main/discover.ts
|
|
2760
|
+
import { promises as fs2 } from "node:fs";
|
|
2761
|
+
import path2 from "node:path";
|
|
2762
|
+
import { pathToFileURL } from "node:url";
|
|
2763
|
+
|
|
519
2764
|
// src/main/security.ts
|
|
520
2765
|
import crypto from "node:crypto";
|
|
521
2766
|
import { promises as fs } from "node:fs";
|
|
@@ -606,16 +2851,113 @@ async function verifyPlugin(root, raw, manifest, opts = {}) {
|
|
|
606
2851
|
|
|
607
2852
|
// src/main/discover.ts
|
|
608
2853
|
var MANIFEST_FILE = "plugin.json";
|
|
2854
|
+
var counter = 0;
|
|
2855
|
+
async function importFresh(file) {
|
|
2856
|
+
const url = pathToFileURL(file).href + `?flux=${Date.now()}.${counter++}`;
|
|
2857
|
+
const mod = await import(
|
|
2858
|
+
/* @vite-ignore */
|
|
2859
|
+
url
|
|
2860
|
+
);
|
|
2861
|
+
return mod.default ?? mod;
|
|
2862
|
+
}
|
|
2863
|
+
function toDefinition(x, entry) {
|
|
2864
|
+
const def = x && typeof x === "object" && "default" in x ? x.default : x;
|
|
2865
|
+
if (!def || typeof def !== "object") throw new Error(`"${entry}" must default-export definePlugin({...})`);
|
|
2866
|
+
return def;
|
|
2867
|
+
}
|
|
2868
|
+
async function readPluginDir(root, opts = {}) {
|
|
2869
|
+
const abs = path2.resolve(root);
|
|
2870
|
+
let raw;
|
|
2871
|
+
try {
|
|
2872
|
+
raw = JSON.parse(await fs2.readFile(path2.join(abs, MANIFEST_FILE), "utf8"));
|
|
2873
|
+
} catch (e) {
|
|
2874
|
+
raw = { id: `invalid.${path2.basename(abs)}`.toLowerCase().replace(/[^a-z0-9.-]/g, "-") };
|
|
2875
|
+
return {
|
|
2876
|
+
root: abs,
|
|
2877
|
+
manifest: raw,
|
|
2878
|
+
problem: `cannot read ${MANIFEST_FILE}: ${e.message}`,
|
|
2879
|
+
source: { manifest: raw, root: abs, origin: `dir:${abs}`, load: async () => ({}) }
|
|
2880
|
+
};
|
|
2881
|
+
}
|
|
2882
|
+
let problem;
|
|
2883
|
+
const v = validateManifest(raw);
|
|
2884
|
+
if (v.manifest) {
|
|
2885
|
+
try {
|
|
2886
|
+
await verifyPlugin(abs, raw, v.manifest, opts);
|
|
2887
|
+
} catch (e) {
|
|
2888
|
+
problem = e instanceof IntegrityError ? e.message : `verification failed: ${e.message}`;
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
const mainRel = v.manifest?.main;
|
|
2892
|
+
const load = opts.loadEntry ?? importFresh;
|
|
2893
|
+
const source = {
|
|
2894
|
+
manifest: raw,
|
|
2895
|
+
root: abs,
|
|
2896
|
+
origin: `dir:${abs}`,
|
|
2897
|
+
load: async () => {
|
|
2898
|
+
if (problem) throw new IntegrityError(problem);
|
|
2899
|
+
if (!mainRel) return {};
|
|
2900
|
+
const file = path2.resolve(abs, mainRel);
|
|
2901
|
+
if (!file.startsWith(abs + path2.sep)) throw new IntegrityError(`main entry escapes plugin directory`);
|
|
2902
|
+
return toDefinition(await load(file), mainRel);
|
|
2903
|
+
}
|
|
2904
|
+
};
|
|
2905
|
+
return { root: abs, manifest: raw, source, problem };
|
|
2906
|
+
}
|
|
2907
|
+
async function discoverPlugins(dir, opts = {}) {
|
|
2908
|
+
let entries = [];
|
|
2909
|
+
try {
|
|
2910
|
+
entries = (await fs2.readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules").map((e) => e.name);
|
|
2911
|
+
} catch (e) {
|
|
2912
|
+
if (e.code === "ENOENT") return [];
|
|
2913
|
+
throw e;
|
|
2914
|
+
}
|
|
2915
|
+
const out = [];
|
|
2916
|
+
for (const name of entries.sort()) {
|
|
2917
|
+
const root = path2.join(dir, name);
|
|
2918
|
+
try {
|
|
2919
|
+
await fs2.access(path2.join(root, MANIFEST_FILE));
|
|
2920
|
+
} catch {
|
|
2921
|
+
continue;
|
|
2922
|
+
}
|
|
2923
|
+
out.push(await readPluginDir(root, opts));
|
|
2924
|
+
}
|
|
2925
|
+
return out;
|
|
2926
|
+
}
|
|
609
2927
|
|
|
610
2928
|
// src/main/fs-storage.ts
|
|
611
|
-
import { promises as
|
|
612
|
-
import
|
|
2929
|
+
import { promises as fs3 } from "node:fs";
|
|
2930
|
+
import path3 from "node:path";
|
|
2931
|
+
var safe2 = (ns) => ns.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
613
2932
|
async function atomicWrite(file, data) {
|
|
614
|
-
await
|
|
2933
|
+
await fs3.mkdir(path3.dirname(file), { recursive: true });
|
|
615
2934
|
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
616
|
-
await
|
|
617
|
-
await
|
|
2935
|
+
await fs3.writeFile(tmp, data, "utf8");
|
|
2936
|
+
await fs3.rename(tmp, file);
|
|
618
2937
|
}
|
|
2938
|
+
var FsStorageBackend = class {
|
|
2939
|
+
constructor(dir) {
|
|
2940
|
+
this.dir = dir;
|
|
2941
|
+
}
|
|
2942
|
+
dir;
|
|
2943
|
+
file(ns) {
|
|
2944
|
+
return path3.join(this.dir, `${safe2(ns)}.json`);
|
|
2945
|
+
}
|
|
2946
|
+
async read(ns) {
|
|
2947
|
+
try {
|
|
2948
|
+
return JSON.parse(await fs3.readFile(this.file(ns), "utf8"));
|
|
2949
|
+
} catch (e) {
|
|
2950
|
+
if (e.code === "ENOENT") return {};
|
|
2951
|
+
throw e;
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
async write(ns, data) {
|
|
2955
|
+
await atomicWrite(this.file(ns), JSON.stringify(data));
|
|
2956
|
+
}
|
|
2957
|
+
async remove(ns) {
|
|
2958
|
+
await fs3.rm(this.file(ns), { force: true });
|
|
2959
|
+
}
|
|
2960
|
+
};
|
|
619
2961
|
var JsonStateFile = class {
|
|
620
2962
|
constructor(file) {
|
|
621
2963
|
this.file = file;
|
|
@@ -624,7 +2966,7 @@ var JsonStateFile = class {
|
|
|
624
2966
|
queue = Promise.resolve();
|
|
625
2967
|
async readAll() {
|
|
626
2968
|
try {
|
|
627
|
-
const j = JSON.parse(await
|
|
2969
|
+
const j = JSON.parse(await fs3.readFile(this.file, "utf8"));
|
|
628
2970
|
return { disabled: Array.isArray(j.disabled) ? j.disabled : [], approved: j.approved && typeof j.approved === "object" ? j.approved : {} };
|
|
629
2971
|
} catch {
|
|
630
2972
|
return { disabled: [], approved: {} };
|
|
@@ -647,13 +2989,13 @@ var JsonStateFile = class {
|
|
|
647
2989
|
};
|
|
648
2990
|
|
|
649
2991
|
// src/main/registry.ts
|
|
650
|
-
import { promises as
|
|
651
|
-
import
|
|
2992
|
+
import { promises as fs5 } from "node:fs";
|
|
2993
|
+
import path5 from "node:path";
|
|
652
2994
|
import crypto2 from "node:crypto";
|
|
653
2995
|
|
|
654
2996
|
// src/main/tar.ts
|
|
655
|
-
import { promises as
|
|
656
|
-
import
|
|
2997
|
+
import { promises as fs4 } from "node:fs";
|
|
2998
|
+
import path4 from "node:path";
|
|
657
2999
|
import zlib from "node:zlib";
|
|
658
3000
|
import { promisify } from "node:util";
|
|
659
3001
|
var gzip = promisify(zlib.gzip);
|
|
@@ -679,9 +3021,9 @@ function header(name, size, mode, type) {
|
|
|
679
3021
|
}
|
|
680
3022
|
async function walk(dir, base = "") {
|
|
681
3023
|
const out = [];
|
|
682
|
-
for (const e of (await
|
|
3024
|
+
for (const e of (await fs4.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
683
3025
|
if (e.name === "node_modules" || e.name === ".git") continue;
|
|
684
|
-
const abs =
|
|
3026
|
+
const abs = path4.join(dir, e.name);
|
|
685
3027
|
const rel = base ? `${base}/${e.name}` : e.name;
|
|
686
3028
|
if (e.isDirectory()) {
|
|
687
3029
|
out.push({ rel, abs, dir: true });
|
|
@@ -696,7 +3038,7 @@ async function createTarGz(dir, prefix = "package") {
|
|
|
696
3038
|
const name = `${prefix}/${f.rel}${f.dir ? "/" : ""}`;
|
|
697
3039
|
if (f.dir) chunks.push(header(name, 0, 493, "5"));
|
|
698
3040
|
else {
|
|
699
|
-
const data = await
|
|
3041
|
+
const data = await fs4.readFile(f.abs);
|
|
700
3042
|
chunks.push(header(name, data.length, 420, "0"), data, Buffer.alloc((BLOCK - data.length % BLOCK) % BLOCK));
|
|
701
3043
|
}
|
|
702
3044
|
}
|
|
@@ -721,26 +3063,26 @@ async function readTarGz(buf) {
|
|
|
721
3063
|
return entries;
|
|
722
3064
|
}
|
|
723
3065
|
async function extractTarGz(buf, dest) {
|
|
724
|
-
const root =
|
|
725
|
-
await
|
|
3066
|
+
const root = path4.resolve(dest);
|
|
3067
|
+
await fs4.mkdir(root, { recursive: true });
|
|
726
3068
|
for (const e of await readTarGz(buf)) {
|
|
727
3069
|
const parts = e.name.split("/").filter(Boolean);
|
|
728
3070
|
parts.shift();
|
|
729
3071
|
if (!parts.length) continue;
|
|
730
|
-
if (parts.some((p) => p === ".." || p.includes("\0")) ||
|
|
731
|
-
const target =
|
|
732
|
-
if (target !== root && !target.startsWith(root +
|
|
733
|
-
if (e.type === "dir") await
|
|
3072
|
+
if (parts.some((p) => p === ".." || p.includes("\0")) || path4.isAbsolute(e.name)) throw new Error(`unsafe path in archive: ${e.name}`);
|
|
3073
|
+
const target = path4.resolve(root, ...parts);
|
|
3074
|
+
if (target !== root && !target.startsWith(root + path4.sep)) throw new Error(`unsafe path in archive: ${e.name}`);
|
|
3075
|
+
if (e.type === "dir") await fs4.mkdir(target, { recursive: true });
|
|
734
3076
|
else {
|
|
735
|
-
await
|
|
736
|
-
await
|
|
3077
|
+
await fs4.mkdir(path4.dirname(target), { recursive: true });
|
|
3078
|
+
await fs4.writeFile(target, e.data);
|
|
737
3079
|
}
|
|
738
3080
|
}
|
|
739
3081
|
}
|
|
740
3082
|
|
|
741
3083
|
// src/main/registry.ts
|
|
742
3084
|
async function readPluginManifest(dir) {
|
|
743
|
-
const raw = JSON.parse(await
|
|
3085
|
+
const raw = JSON.parse(await fs5.readFile(path5.join(dir, MANIFEST_FILE), "utf8"));
|
|
744
3086
|
const v = validateManifest(raw);
|
|
745
3087
|
if (!v.manifest) throw new Error(`invalid manifest: ${v.issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`);
|
|
746
3088
|
return { raw, manifest: v.manifest };
|
|
@@ -761,65 +3103,125 @@ var RUNTIME_FILES = /^(plugin\.json|readme(\.[a-z]+)?|license(\.[a-z]+)?|changel
|
|
|
761
3103
|
var SOURCE_FILE = /\.(tsx?|jsx|map|scss|sass|less)$/i;
|
|
762
3104
|
async function copyPluginRuntime(src, dest) {
|
|
763
3105
|
const copied = [];
|
|
764
|
-
const manifest = JSON.parse(await
|
|
3106
|
+
const manifest = JSON.parse(await fs5.readFile(path5.join(src, MANIFEST_FILE), "utf8"));
|
|
765
3107
|
const referenced = new Set(
|
|
766
|
-
[manifest["main"], manifest["preload"], manifest["renderer"], manifest["icon"], manifest["banner"], manifest["readme"], ...manifest["styles"] ?? [], ...manifest["screenshots"] ?? []].filter((x) => typeof x === "string").map((x) =>
|
|
3108
|
+
[manifest["main"], manifest["preload"], manifest["renderer"], manifest["icon"], manifest["banner"], manifest["readme"], ...manifest["styles"] ?? [], ...manifest["screenshots"] ?? []].filter((x) => typeof x === "string").map((x) => path5.normalize(x).replace(/^(\.\/|\.\\)/, ""))
|
|
767
3109
|
);
|
|
768
3110
|
const walk2 = async (rel) => {
|
|
769
|
-
for (const e of await
|
|
770
|
-
const r = rel ?
|
|
3111
|
+
for (const e of await fs5.readdir(path5.join(src, rel), { withFileTypes: true })) {
|
|
3112
|
+
const r = rel ? path5.join(rel, e.name) : e.name;
|
|
771
3113
|
if (e.isDirectory()) {
|
|
772
3114
|
if (rel === "" ? RUNTIME_DIRS.has(e.name) : true) await walk2(r);
|
|
773
3115
|
} else if (e.isFile()) {
|
|
774
|
-
const top = r.split(
|
|
3116
|
+
const top = r.split(path5.sep)[0];
|
|
775
3117
|
const ok2 = referenced.has(r) || (rel === "" ? RUNTIME_FILES.test(e.name) : RUNTIME_DIRS.has(top) && !SOURCE_FILE.test(e.name));
|
|
776
3118
|
if (!ok2 || SOURCE_FILE.test(e.name) && !referenced.has(r)) continue;
|
|
777
|
-
await
|
|
778
|
-
await
|
|
779
|
-
copied.push(r.split(
|
|
3119
|
+
await fs5.mkdir(path5.dirname(path5.join(dest, r)), { recursive: true });
|
|
3120
|
+
await fs5.copyFile(path5.join(src, r), path5.join(dest, r));
|
|
3121
|
+
copied.push(r.split(path5.sep).join("/"));
|
|
780
3122
|
}
|
|
781
3123
|
}
|
|
782
3124
|
};
|
|
783
|
-
await
|
|
3125
|
+
await fs5.mkdir(dest, { recursive: true });
|
|
784
3126
|
await walk2("");
|
|
785
3127
|
return copied;
|
|
786
3128
|
}
|
|
787
3129
|
async function copyDir(src, dest) {
|
|
788
|
-
await
|
|
789
|
-
for (const e of await
|
|
3130
|
+
await fs5.mkdir(dest, { recursive: true });
|
|
3131
|
+
for (const e of await fs5.readdir(src, { withFileTypes: true })) {
|
|
790
3132
|
if (e.name === "node_modules" || e.name === ".git") continue;
|
|
791
|
-
const s2 =
|
|
792
|
-
const d =
|
|
3133
|
+
const s2 = path5.join(src, e.name);
|
|
3134
|
+
const d = path5.join(dest, e.name);
|
|
793
3135
|
if (e.isDirectory()) await copyDir(s2, d);
|
|
794
|
-
else if (e.isFile()) await
|
|
3136
|
+
else if (e.isFile()) await fs5.copyFile(s2, d);
|
|
795
3137
|
}
|
|
796
3138
|
}
|
|
797
3139
|
function entriesFromIndex(index, baseUrl, source, query = "") {
|
|
3140
|
+
const list = Array.isArray(index) ? index : index?.plugins ?? [];
|
|
798
3141
|
const q = query.toLowerCase();
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
3142
|
+
const abs = (u) => {
|
|
3143
|
+
if (!u) return void 0;
|
|
3144
|
+
try {
|
|
3145
|
+
return new URL(u, baseUrl).href;
|
|
3146
|
+
} catch {
|
|
3147
|
+
return void 0;
|
|
3148
|
+
}
|
|
3149
|
+
};
|
|
3150
|
+
const out = [];
|
|
3151
|
+
for (const p of list) {
|
|
3152
|
+
if (!p || typeof p.id !== "string" || typeof p.version !== "string") continue;
|
|
3153
|
+
const isFolder = typeof p.folder === "string" && Array.isArray(p.files);
|
|
3154
|
+
if (typeof p.url !== "string" && !isFolder) continue;
|
|
3155
|
+
if (q && !`${p.id} ${p.name ?? ""} ${p.description ?? ""} ${(p.keywords ?? []).join(" ")}`.toLowerCase().includes(q)) continue;
|
|
3156
|
+
const folderUrl = isFolder ? abs(p.folder.endsWith("/") ? p.folder : p.folder + "/") : void 0;
|
|
3157
|
+
const location = isFolder ? folderUrl : abs(p.url);
|
|
3158
|
+
if (!location) continue;
|
|
3159
|
+
out.push({
|
|
3160
|
+
id: p.id,
|
|
3161
|
+
name: p.name ?? p.id,
|
|
3162
|
+
version: p.version,
|
|
3163
|
+
description: p.description,
|
|
3164
|
+
author: p.author,
|
|
3165
|
+
permissions: p.permissions ?? [],
|
|
3166
|
+
location,
|
|
3167
|
+
source,
|
|
3168
|
+
sha256: p.sha256,
|
|
3169
|
+
homepage: p.homepage,
|
|
3170
|
+
license: p.license,
|
|
3171
|
+
iconUrl: abs(p.icon),
|
|
3172
|
+
bannerUrl: abs(p.banner),
|
|
3173
|
+
screenshots: (p.screenshots ?? []).map((s2) => abs(s2)).filter((s2) => !!s2),
|
|
3174
|
+
video: p.video ? p.video.startsWith("./") && folderUrl ? abs(new URL(p.video, folderUrl).href) : abs(p.video) : void 0,
|
|
3175
|
+
categories: p.categories,
|
|
3176
|
+
keywords: p.keywords,
|
|
3177
|
+
links: p.links,
|
|
3178
|
+
readme: p.readme,
|
|
3179
|
+
folder: isFolder || void 0,
|
|
3180
|
+
files: isFolder ? p.files : void 0,
|
|
3181
|
+
hashes: p.hashes
|
|
3182
|
+
});
|
|
3183
|
+
}
|
|
3184
|
+
return out;
|
|
3185
|
+
}
|
|
3186
|
+
var SAFE_FILE = /^[\w@+.,() \-]+(\/[\w@+.,() \-]+)*$/;
|
|
3187
|
+
async function downloadFolder(entry, dest, get) {
|
|
3188
|
+
const base = entry.location.endsWith("/") ? entry.location : entry.location + "/";
|
|
3189
|
+
const files = entry.files ?? [];
|
|
3190
|
+
if (!files.includes("plugin.json")) throw new Error("catalog entry does not list plugin.json");
|
|
3191
|
+
if (files.length > 800) throw new Error("catalog entry lists too many files");
|
|
3192
|
+
if (!entry.hashes) throw new Error("catalog entry has no file hashes; refusing to install an unverifiable folder (run `fluxplugin store build`)");
|
|
3193
|
+
const root = path5.resolve(dest);
|
|
3194
|
+
await fs5.mkdir(root, { recursive: true });
|
|
3195
|
+
let total = 0;
|
|
3196
|
+
const queue = [...files];
|
|
3197
|
+
const worker = async () => {
|
|
3198
|
+
for (let f = queue.shift(); f !== void 0; f = queue.shift()) {
|
|
3199
|
+
if (!SAFE_FILE.test(f) || f.split("/").some((s2) => s2 === ".." || s2 === ".")) throw new Error(`unsafe file path in catalog: ${f}`);
|
|
3200
|
+
const expected = entry.hashes[f];
|
|
3201
|
+
if (!expected || !/^[0-9a-f]{64}$/.test(expected)) throw new Error(`no sha256 for ${f} in the catalog`);
|
|
3202
|
+
const res = await get(base + f.split("/").map(encodeURIComponent).join("/"));
|
|
3203
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
3204
|
+
total += buf.length;
|
|
3205
|
+
if (total > 100 * 1024 * 1024) throw new Error("plugin is too large");
|
|
3206
|
+
if (crypto2.createHash("sha256").update(buf).digest("hex") !== expected) throw new Error(`${f} does not match the catalog checksum`);
|
|
3207
|
+
const target = path5.resolve(root, f);
|
|
3208
|
+
if (!target.startsWith(root + path5.sep)) throw new Error(`unsafe file path in catalog: ${f}`);
|
|
3209
|
+
await fs5.mkdir(path5.dirname(target), { recursive: true });
|
|
3210
|
+
await fs5.writeFile(target, buf);
|
|
3211
|
+
}
|
|
3212
|
+
};
|
|
3213
|
+
await Promise.all(Array.from({ length: 6 }, worker));
|
|
812
3214
|
}
|
|
813
3215
|
async function pruneNonRuntime(dir) {
|
|
814
3216
|
const removed = [];
|
|
815
3217
|
const walk2 = async (rel) => {
|
|
816
|
-
for (const e of await
|
|
817
|
-
const r = rel ?
|
|
818
|
-
const abs =
|
|
3218
|
+
for (const e of await fs5.readdir(path5.join(dir, rel), { withFileTypes: true })) {
|
|
3219
|
+
const r = rel ? path5.join(rel, e.name) : e.name;
|
|
3220
|
+
const abs = path5.join(dir, r);
|
|
819
3221
|
if (e.isDirectory()) {
|
|
820
|
-
if (e.name === "node_modules" || rel === "" && (e.name === "src" || e.name === ".git")) await
|
|
3222
|
+
if (e.name === "node_modules" || rel === "" && (e.name === "src" || e.name === ".git")) await fs5.rm(abs, { recursive: true, force: true }), removed.push(r + "/");
|
|
821
3223
|
else await walk2(r);
|
|
822
|
-
} else if (/\.(map|tsx?|scss|sass|less)$/i.test(e.name) && !/\.d\.ts$/i.test(e.name)) await
|
|
3224
|
+
} else if (/\.(map|tsx?|scss|sass|less)$/i.test(e.name) && !/\.d\.ts$/i.test(e.name)) await fs5.rm(abs, { force: true }), removed.push(r);
|
|
823
3225
|
}
|
|
824
3226
|
};
|
|
825
3227
|
await walk2("");
|
|
@@ -837,12 +3239,12 @@ var LocalRegistry = class {
|
|
|
837
3239
|
const out = [];
|
|
838
3240
|
let names = [];
|
|
839
3241
|
try {
|
|
840
|
-
names = await
|
|
3242
|
+
names = await fs5.readdir(this.dir);
|
|
841
3243
|
} catch {
|
|
842
3244
|
return [];
|
|
843
3245
|
}
|
|
844
3246
|
for (const n of names.sort()) {
|
|
845
|
-
const root =
|
|
3247
|
+
const root = path5.join(this.dir, n);
|
|
846
3248
|
try {
|
|
847
3249
|
const { manifest } = await readPluginManifest(root);
|
|
848
3250
|
if (!q || `${manifest.id} ${manifest.name} ${manifest.description ?? ""}`.toLowerCase().includes(q)) out.push(toEntry(manifest, root, this.name));
|
|
@@ -857,23 +3259,39 @@ var LocalRegistry = class {
|
|
|
857
3259
|
}
|
|
858
3260
|
};
|
|
859
3261
|
var HttpRegistry = class {
|
|
860
|
-
constructor(baseUrl, fetchImpl = fetch) {
|
|
3262
|
+
constructor(baseUrl, fetchImpl = fetch, headers = {}, direct = false) {
|
|
861
3263
|
this.baseUrl = baseUrl;
|
|
862
3264
|
this.fetchImpl = fetchImpl;
|
|
3265
|
+
this.headers = headers;
|
|
3266
|
+
this.direct = direct;
|
|
863
3267
|
this.name = `http:${baseUrl}`;
|
|
864
3268
|
}
|
|
865
3269
|
baseUrl;
|
|
866
3270
|
fetchImpl;
|
|
3271
|
+
headers;
|
|
3272
|
+
direct;
|
|
867
3273
|
name;
|
|
3274
|
+
/** Direct link (".json" file, or `direct: true` for an API endpoint) or a folder that contains index.json. */
|
|
3275
|
+
get catalogUrl() {
|
|
3276
|
+
if (this.direct || new URL(this.baseUrl).pathname.toLowerCase().endsWith(".json")) return this.baseUrl;
|
|
3277
|
+
return new URL("index.json", this.baseUrl.endsWith("/") ? this.baseUrl : this.baseUrl + "/").href;
|
|
3278
|
+
}
|
|
3279
|
+
get(url) {
|
|
3280
|
+
return this.fetchImpl(url, { headers: { accept: "application/json, application/octet-stream", ...this.headers } }).then((r) => {
|
|
3281
|
+
if (!r.ok) throw new Error(`store responded ${r.status} for ${new URL(url).pathname}`);
|
|
3282
|
+
return r;
|
|
3283
|
+
});
|
|
3284
|
+
}
|
|
868
3285
|
async search(query = "") {
|
|
869
|
-
const
|
|
870
|
-
|
|
871
|
-
if (!res.ok) throw new Error(`store responded ${res.status}`);
|
|
872
|
-
return entriesFromIndex(await res.json(), base, this.name, query);
|
|
3286
|
+
const url = this.catalogUrl;
|
|
3287
|
+
return entriesFromIndex(await (await this.get(url)).json(), url, this.name, query);
|
|
873
3288
|
}
|
|
874
3289
|
async fetch(entry, dest) {
|
|
875
|
-
|
|
876
|
-
|
|
3290
|
+
if (entry.folder) {
|
|
3291
|
+
await downloadFolder(entry, dest, (u) => this.get(u));
|
|
3292
|
+
return (await readPluginManifest(dest)).manifest;
|
|
3293
|
+
}
|
|
3294
|
+
const res = await this.get(entry.location);
|
|
877
3295
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
878
3296
|
if (entry.sha256 && crypto2.createHash("sha256").update(buf).digest("hex") !== entry.sha256.replace(/^sha256-/, "")) {
|
|
879
3297
|
throw new Error("downloaded archive does not match the registry checksum");
|
|
@@ -900,23 +3318,23 @@ var PluginInstaller = class {
|
|
|
900
3318
|
}
|
|
901
3319
|
async installed() {
|
|
902
3320
|
const out = [];
|
|
903
|
-
for (const n of await
|
|
3321
|
+
for (const n of await fs5.readdir(this.pluginsDir).catch(() => [])) {
|
|
904
3322
|
try {
|
|
905
|
-
out.push((await readPluginManifest(
|
|
3323
|
+
out.push((await readPluginManifest(path5.join(this.pluginsDir, n))).manifest);
|
|
906
3324
|
} catch {
|
|
907
3325
|
}
|
|
908
3326
|
}
|
|
909
3327
|
return out;
|
|
910
3328
|
}
|
|
911
3329
|
dirOf(id) {
|
|
912
|
-
return
|
|
3330
|
+
return path5.join(this.pluginsDir, id.replace(/[^a-z0-9.-]/gi, "_"));
|
|
913
3331
|
}
|
|
914
3332
|
/** Installs (or updates) `entry`. The plugin directory is replaced atomically from a staging directory. */
|
|
915
3333
|
async install(entry) {
|
|
916
3334
|
const registry = this.registries.find((r) => r.name === entry.source);
|
|
917
3335
|
if (!registry) throw new Error(`unknown registry "${entry.source}"`);
|
|
918
|
-
const stage =
|
|
919
|
-
await
|
|
3336
|
+
const stage = path5.join(this.backupDir, ".staging", `${entry.id}-${Date.now()}`);
|
|
3337
|
+
await fs5.mkdir(path5.dirname(stage), { recursive: true });
|
|
920
3338
|
try {
|
|
921
3339
|
const manifest = await registry.fetch(entry, stage);
|
|
922
3340
|
await pruneNonRuntime(stage);
|
|
@@ -925,31 +3343,31 @@ var PluginInstaller = class {
|
|
|
925
3343
|
let previousVersion;
|
|
926
3344
|
try {
|
|
927
3345
|
previousVersion = (await readPluginManifest(target)).manifest.version;
|
|
928
|
-
const bk =
|
|
929
|
-
await
|
|
930
|
-
await
|
|
931
|
-
await
|
|
3346
|
+
const bk = path5.join(this.backupDir, entry.id, previousVersion);
|
|
3347
|
+
await fs5.rm(bk, { recursive: true, force: true });
|
|
3348
|
+
await fs5.mkdir(path5.dirname(bk), { recursive: true });
|
|
3349
|
+
await fs5.rename(target, bk);
|
|
932
3350
|
} catch (e) {
|
|
933
3351
|
if (e.code && e.code !== "ENOENT") throw e;
|
|
934
3352
|
}
|
|
935
|
-
await
|
|
936
|
-
await
|
|
3353
|
+
await fs5.mkdir(path5.dirname(target), { recursive: true });
|
|
3354
|
+
await fs5.rename(stage, target);
|
|
937
3355
|
return { id: entry.id, version: manifest.version, previousVersion };
|
|
938
3356
|
} finally {
|
|
939
|
-
await
|
|
3357
|
+
await fs5.rm(stage, { recursive: true, force: true });
|
|
940
3358
|
}
|
|
941
3359
|
}
|
|
942
3360
|
/** Removes the plugin directory (backups are kept so the user can still roll back/reinstall). */
|
|
943
3361
|
async uninstall(id) {
|
|
944
3362
|
const dir = this.dirOf(id);
|
|
945
3363
|
const { manifest } = await readPluginManifest(dir);
|
|
946
|
-
const bk =
|
|
947
|
-
await
|
|
948
|
-
await
|
|
949
|
-
await
|
|
3364
|
+
const bk = path5.join(this.backupDir, id, manifest.version);
|
|
3365
|
+
await fs5.rm(bk, { recursive: true, force: true });
|
|
3366
|
+
await fs5.mkdir(path5.dirname(bk), { recursive: true });
|
|
3367
|
+
await fs5.rename(dir, bk);
|
|
950
3368
|
}
|
|
951
3369
|
async versions(id) {
|
|
952
|
-
const names = await
|
|
3370
|
+
const names = await fs5.readdir(path5.join(this.backupDir, id)).catch(() => []);
|
|
953
3371
|
return names.sort(compareVersions);
|
|
954
3372
|
}
|
|
955
3373
|
/** Restores the newest backup (or `version`) and keeps the replaced version as a backup. */
|
|
@@ -961,13 +3379,13 @@ var PluginInstaller = class {
|
|
|
961
3379
|
let current;
|
|
962
3380
|
try {
|
|
963
3381
|
current = (await readPluginManifest(dir)).manifest.version;
|
|
964
|
-
const bk =
|
|
965
|
-
await
|
|
966
|
-
await
|
|
3382
|
+
const bk = path5.join(this.backupDir, id, current);
|
|
3383
|
+
await fs5.rm(bk, { recursive: true, force: true });
|
|
3384
|
+
await fs5.rename(dir, bk);
|
|
967
3385
|
} catch (e) {
|
|
968
3386
|
if (e.code && e.code !== "ENOENT") throw e;
|
|
969
3387
|
}
|
|
970
|
-
await
|
|
3388
|
+
await fs5.rename(path5.join(this.backupDir, id, target), dir);
|
|
971
3389
|
return { id, version: target, previousVersion: current };
|
|
972
3390
|
}
|
|
973
3391
|
/** Entries from registries that are newer than what is installed. */
|
|
@@ -978,7 +3396,7 @@ var PluginInstaller = class {
|
|
|
978
3396
|
};
|
|
979
3397
|
|
|
980
3398
|
// src/main/stores.ts
|
|
981
|
-
import
|
|
3399
|
+
import path6 from "node:path";
|
|
982
3400
|
|
|
983
3401
|
// src/main/github-registry.ts
|
|
984
3402
|
import crypto3 from "node:crypto";
|
|
@@ -1059,6 +3477,10 @@ var GitHubRegistry = class {
|
|
|
1059
3477
|
return out.sort((a, b) => (b.stars ?? 0) - (a.stars ?? 0) || a.id.localeCompare(b.id));
|
|
1060
3478
|
}
|
|
1061
3479
|
async fetch(entry, dest) {
|
|
3480
|
+
if (entry.folder) {
|
|
3481
|
+
await downloadFolder(entry, dest, (u) => this.get(u, "application/octet-stream"));
|
|
3482
|
+
return (await readPluginManifest(dest)).manifest;
|
|
3483
|
+
}
|
|
1062
3484
|
const res = await this.get(entry.location, "application/octet-stream");
|
|
1063
3485
|
const len = Number(res.headers.get("content-length") ?? 0);
|
|
1064
3486
|
if (len > MAX_PACKAGE_BYTES) throw new Error("package is too large");
|
|
@@ -1081,22 +3503,301 @@ function createRegistries(specs = [DEFAULT_STORE], opts = {}) {
|
|
|
1081
3503
|
case "github":
|
|
1082
3504
|
return new GitHubRegistry({ ...s2, token, fetchImpl: opts.fetchImpl });
|
|
1083
3505
|
case "http":
|
|
1084
|
-
|
|
3506
|
+
case "json":
|
|
3507
|
+
return new HttpRegistry(s2.url, opts.fetchImpl, s2.headers, s2.type === "json");
|
|
1085
3508
|
case "local":
|
|
1086
|
-
return new LocalRegistry(
|
|
3509
|
+
return new LocalRegistry(path6.resolve(opts.baseDir ?? process.cwd(), s2.path));
|
|
1087
3510
|
default:
|
|
1088
3511
|
throw new Error(`unknown store type: ${JSON.stringify(s2)}`);
|
|
1089
3512
|
}
|
|
1090
3513
|
});
|
|
1091
3514
|
}
|
|
1092
3515
|
|
|
3516
|
+
// src/main/framework.ts
|
|
3517
|
+
var MainFramework = class {
|
|
3518
|
+
constructor(options) {
|
|
3519
|
+
this.options = options;
|
|
3520
|
+
this.logs = new LogStore(5e3);
|
|
3521
|
+
this.state = new JsonStateFile(path7.join(options.dataDir, "plugins-state.json"));
|
|
3522
|
+
this.permissions = new PermissionManager({
|
|
3523
|
+
autoGrant: !options.requireApproval,
|
|
3524
|
+
onChange: (id, snap) => void this.state.update((s2) => ({ approved: { ...s2.approved, [id]: snap.granted } })).catch(() => void 0)
|
|
3525
|
+
});
|
|
3526
|
+
this.manager = new PluginManager({
|
|
3527
|
+
runtime: "main",
|
|
3528
|
+
storage: new FsStorageBackend(path7.join(options.dataDir, "storage")),
|
|
3529
|
+
permissions: this.permissions,
|
|
3530
|
+
logs: this.logs,
|
|
3531
|
+
frameworkVersion: options.frameworkVersion,
|
|
3532
|
+
stateStore: {
|
|
3533
|
+
load: async () => (await this.state.readAll()).disabled,
|
|
3534
|
+
save: (ids) => this.state.update({ disabled: ids })
|
|
3535
|
+
},
|
|
3536
|
+
onBroadcastEvent: (name, payload) => this.broadcast("flux.event." + name, payload),
|
|
3537
|
+
...options.manager
|
|
3538
|
+
});
|
|
3539
|
+
this.manager.ipc.setTransport({ broadcast: (c, p, t) => this.broadcast(c, p, t) });
|
|
3540
|
+
this.installer = new PluginInstaller(options.installDir ?? options.pluginDirs[0], path7.join(options.dataDir, "backups"), options.registries ?? createRegistries(options.stores));
|
|
3541
|
+
this.manager.subscribe(() => {
|
|
3542
|
+
this.revision++;
|
|
3543
|
+
this.broadcast(MGMT.changed, { revision: this.revision });
|
|
3544
|
+
});
|
|
3545
|
+
this.manager.notifications.onPush((n) => this.broadcast("flux.notification", n));
|
|
3546
|
+
this.registerManagementApi();
|
|
3547
|
+
}
|
|
3548
|
+
options;
|
|
3549
|
+
manager;
|
|
3550
|
+
logs;
|
|
3551
|
+
installer;
|
|
3552
|
+
permissions;
|
|
3553
|
+
state;
|
|
3554
|
+
broadcastListeners = /* @__PURE__ */ new Set();
|
|
3555
|
+
watchers = [];
|
|
3556
|
+
revision = 0;
|
|
3557
|
+
reloadTimers = /* @__PURE__ */ new Map();
|
|
3558
|
+
started = false;
|
|
3559
|
+
/* --------------------------------------------------------- start / stop */
|
|
3560
|
+
async start() {
|
|
3561
|
+
if (this.started) return;
|
|
3562
|
+
this.started = true;
|
|
3563
|
+
const approved = (await this.state.readAll()).approved;
|
|
3564
|
+
const g = globalThis;
|
|
3565
|
+
g.__FLUXPLUGIN_SHARED__ = { ...g.__FLUXPLUGIN_SHARED__ ?? {}, "fluxplugin": core_exports };
|
|
3566
|
+
this.permissions.restore(approved);
|
|
3567
|
+
await this.manager.init();
|
|
3568
|
+
await this.scan();
|
|
3569
|
+
await this.manager.startup();
|
|
3570
|
+
if (this.options.hotReload) this.watch();
|
|
3571
|
+
}
|
|
3572
|
+
discoverOpts() {
|
|
3573
|
+
return { trustedKeys: this.options.trustedKeys, requireSignature: this.options.requireSignature, ...this.options.discover };
|
|
3574
|
+
}
|
|
3575
|
+
/** (Re)scans the plugin directories and registers plugins that are new. */
|
|
3576
|
+
async scan() {
|
|
3577
|
+
for (const dir of this.options.pluginDirs) {
|
|
3578
|
+
for (const p of await discoverPlugins(dir, this.discoverOpts())) {
|
|
3579
|
+
const id = typeof p.manifest["id"] === "string" ? p.manifest["id"] : "";
|
|
3580
|
+
if (id && this.manager.has(id)) continue;
|
|
3581
|
+
const info = this.manager.register(p.source);
|
|
3582
|
+
if (p.problem) this.logs.logger(info.id).error(p.problem);
|
|
3583
|
+
}
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
async stop() {
|
|
3587
|
+
for (const w of this.watchers) w.close();
|
|
3588
|
+
this.watchers = [];
|
|
3589
|
+
for (const t of this.reloadTimers.values()) clearTimeout(t);
|
|
3590
|
+
await this.manager.dispose();
|
|
3591
|
+
await this.state.flush();
|
|
3592
|
+
}
|
|
3593
|
+
/** Watches plugin directories and reloads the affected plugin (debounced). */
|
|
3594
|
+
watch() {
|
|
3595
|
+
for (const dir of this.options.pluginDirs) {
|
|
3596
|
+
try {
|
|
3597
|
+
const w = watch(dir, { recursive: true }, (_evt, filename) => {
|
|
3598
|
+
if (!filename) return;
|
|
3599
|
+
const parts = String(filename).split(path7.sep);
|
|
3600
|
+
if (parts.includes("node_modules") || parts.includes("src")) return;
|
|
3601
|
+
const folder = parts[0];
|
|
3602
|
+
clearTimeout(this.reloadTimers.get(folder));
|
|
3603
|
+
this.reloadTimers.set(
|
|
3604
|
+
folder,
|
|
3605
|
+
setTimeout(() => void this.hotReloadFolder(path7.join(dir, folder)), 150)
|
|
3606
|
+
);
|
|
3607
|
+
});
|
|
3608
|
+
this.watchers.push(w);
|
|
3609
|
+
} catch (e) {
|
|
3610
|
+
this.logs.logger("core").warn(`hot reload disabled for ${dir}: ${errorMessage(e)}`);
|
|
3611
|
+
}
|
|
3612
|
+
}
|
|
3613
|
+
}
|
|
3614
|
+
async hotReloadFolder(root) {
|
|
3615
|
+
try {
|
|
3616
|
+
const p = await readPluginDir(root, this.discoverOpts());
|
|
3617
|
+
const id = String(p.manifest["id"] ?? "");
|
|
3618
|
+
if (!id) return;
|
|
3619
|
+
if (!this.manager.has(id)) {
|
|
3620
|
+
this.manager.register(p.source);
|
|
3621
|
+
await this.manager.activate(id).catch(() => void 0);
|
|
3622
|
+
} else {
|
|
3623
|
+
await this.manager.unregister(id);
|
|
3624
|
+
this.manager.register(p.source);
|
|
3625
|
+
await this.manager.activate(id).catch(() => void 0);
|
|
3626
|
+
}
|
|
3627
|
+
this.logs.logger(id).info("hot reloaded");
|
|
3628
|
+
this.broadcast("flux.event.plugin.hotreload", { id });
|
|
3629
|
+
} catch (e) {
|
|
3630
|
+
this.logs.logger("core").error(`hot reload failed for ${root}: ${errorMessage(e)}`);
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
/* ----------------------------------------------------------- broadcasting */
|
|
3634
|
+
onBroadcast(l) {
|
|
3635
|
+
this.broadcastListeners.add(l);
|
|
3636
|
+
return () => void this.broadcastListeners.delete(l);
|
|
3637
|
+
}
|
|
3638
|
+
broadcast(channel, payload, target) {
|
|
3639
|
+
for (const l of [...this.broadcastListeners]) {
|
|
3640
|
+
try {
|
|
3641
|
+
l(channel, payload, target);
|
|
3642
|
+
} catch {
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
3645
|
+
}
|
|
3646
|
+
/* ---------------------------------------------------------------- bootstrap */
|
|
3647
|
+
bootstrap() {
|
|
3648
|
+
const assetUrl = this.options.assetUrl ?? ((id, rel) => `fluxplugin://${id}/${rel.replace(/^\.\//, "")}`);
|
|
3649
|
+
const plugins = this.manager.list().map((info) => {
|
|
3650
|
+
const m = this.manager.manifest(info.id);
|
|
3651
|
+
const rejected = info.state === "error" && info.error?.phase === "validate";
|
|
3652
|
+
return {
|
|
3653
|
+
id: info.id,
|
|
3654
|
+
manifest: m,
|
|
3655
|
+
enabled: info.enabled && !rejected,
|
|
3656
|
+
rendererUrl: m.renderer && !rejected ? assetUrl(info.id, m.renderer) : void 0,
|
|
3657
|
+
styleUrls: (m.styles ?? []).map((s2) => assetUrl(info.id, s2)),
|
|
3658
|
+
granted: info.permissions.granted,
|
|
3659
|
+
state: info.state
|
|
3660
|
+
};
|
|
3661
|
+
});
|
|
3662
|
+
return { frameworkVersion: this.options.frameworkVersion ?? FRAMEWORK_VERSION, plugins, preload: this.manager.preload.manifest(), revision: this.revision };
|
|
3663
|
+
}
|
|
3664
|
+
/* --------------------------------------------------- renderer-facing IPC */
|
|
3665
|
+
exposedChannels(kind) {
|
|
3666
|
+
const out = new Set(this.options.allowedRendererChannels ?? []);
|
|
3667
|
+
for (const e of this.manager.preload.list()) for (const m of Object.values(e.spec)) if (m.kind === kind) out.add(m.channel);
|
|
3668
|
+
return out;
|
|
3669
|
+
}
|
|
3670
|
+
/**
|
|
3671
|
+
* Handles an invoke coming from a renderer. Policy: the renderer may only call channels that a plugin explicitly
|
|
3672
|
+
* exposed through `preload.expose`, channels the host allow-listed, and (optionally) `flux.plugins.*`.
|
|
3673
|
+
*/
|
|
3674
|
+
async handleInvoke(req, senderId) {
|
|
3675
|
+
try {
|
|
3676
|
+
if (!req || typeof req.channel !== "string") throw new Error("malformed request");
|
|
3677
|
+
const ch = req.channel;
|
|
3678
|
+
const mgmt = ch.startsWith("flux.plugins.");
|
|
3679
|
+
if (mgmt) {
|
|
3680
|
+
if (this.options.allowManagement === false) throw new Error(`management API is disabled`);
|
|
3681
|
+
} else if (!this.exposedChannels("invoke").has(ch)) {
|
|
3682
|
+
throw new Error(`channel "${ch}" is not exposed to renderers`);
|
|
3683
|
+
}
|
|
3684
|
+
const caller = typeof req.caller === "string" && this.manager.has(req.caller) ? req.caller : "host";
|
|
3685
|
+
if (caller !== "host") this.permissions.assert(caller, "ipc", `invoke("${ch}")`);
|
|
3686
|
+
const value = await this.manager.ipc.invoke(ch, req.input, { caller, senderId });
|
|
3687
|
+
return { ok: true, value };
|
|
3688
|
+
} catch (e) {
|
|
3689
|
+
return { ok: false, error: serializeError(e) };
|
|
3690
|
+
}
|
|
3691
|
+
}
|
|
3692
|
+
handleSend(channel, payload, caller, senderId) {
|
|
3693
|
+
if (typeof channel !== "string" || !this.exposedChannels("send").has(channel)) return;
|
|
3694
|
+
const who = typeof caller === "string" && this.manager.has(caller) ? caller : "host";
|
|
3695
|
+
if (who !== "host" && !this.permissions.has(who, "ipc")) return;
|
|
3696
|
+
this.manager.ipc.dispatch(channel, payload, { caller: who, senderId });
|
|
3697
|
+
}
|
|
3698
|
+
/* -------------------------------------------------------- management API */
|
|
3699
|
+
registerManagementApi() {
|
|
3700
|
+
const ipc = this.manager.ipc;
|
|
3701
|
+
const h = (name, fn) => ipc.handle(name, (i) => fn(i), "host");
|
|
3702
|
+
const list = () => this.manager.list();
|
|
3703
|
+
h(MGMT.list, () => list());
|
|
3704
|
+
h(MGMT.enable, async ({ id }) => (await this.manager.enable(id).catch((e) => this.logs.logger(id).error(errorMessage(e))), list()));
|
|
3705
|
+
h(MGMT.disable, async ({ id }) => (await this.manager.disable(id), list()));
|
|
3706
|
+
h(MGMT.reload, async ({ id }) => (await this.manager.reload(id), list()));
|
|
3707
|
+
h(MGMT.grant, ({ id, permission }) => (this.permissions.grant(id, permission), this.manager.info(id)));
|
|
3708
|
+
h(MGMT.revoke, ({ id, permission }) => (this.permissions.revoke(id, permission), this.manager.info(id)));
|
|
3709
|
+
h(MGMT.configGet, async ({ id }) => this.requireConfig(id).get());
|
|
3710
|
+
h(MGMT.configSet, async ({ id, patch }) => this.requireConfig(id).set(patch));
|
|
3711
|
+
h(MGMT.configReset, async ({ id }) => this.requireConfig(id).reset());
|
|
3712
|
+
h(MGMT.configSchema, ({ id }) => this.manager.getConfig(id)?.describe() ?? null);
|
|
3713
|
+
h(MGMT.logs, ({ id }) => this.logs.query(id ? { source: id } : {}).slice(-200));
|
|
3714
|
+
h(MGMT.diagnostics, ({ id }) => this.manager.diagnostics(id));
|
|
3715
|
+
h(MGMT.details, ({ id }) => this.details(id));
|
|
3716
|
+
h(MGMT.search, async ({ query }) => {
|
|
3717
|
+
const installed = new Map(this.manager.list().map((p) => [p.id, p.version]));
|
|
3718
|
+
return (await this.installer.search(query)).map((e) => ({ ...e, installedVersion: installed.get(e.id) }));
|
|
3719
|
+
});
|
|
3720
|
+
h(MGMT.updates, async () => (await this.installer.updates()).map((e) => ({ ...e, installedVersion: this.manager.has(e.id) ? this.manager.info(e.id).version : void 0 })));
|
|
3721
|
+
h(MGMT.install, async ({ entry }) => this.installAndLoad(entry));
|
|
3722
|
+
h(MGMT.update, async ({ entry }) => this.installAndLoad(entry));
|
|
3723
|
+
h(MGMT.uninstall, async ({ id }) => {
|
|
3724
|
+
await this.manager.unregister(id);
|
|
3725
|
+
await this.installer.uninstall(id);
|
|
3726
|
+
return list();
|
|
3727
|
+
});
|
|
3728
|
+
h(MGMT.rollback, async ({ id, version }) => {
|
|
3729
|
+
await this.manager.unregister(id);
|
|
3730
|
+
const r = await this.installer.rollback(id, version);
|
|
3731
|
+
await this.scan();
|
|
3732
|
+
await this.manager.activate(id).catch(() => void 0);
|
|
3733
|
+
return r;
|
|
3734
|
+
});
|
|
3735
|
+
}
|
|
3736
|
+
/** Store-page data for one plugin: icon, banner, screenshots, video and the README (Markdown). */
|
|
3737
|
+
async details(id) {
|
|
3738
|
+
const m = this.manager.manifest(id);
|
|
3739
|
+
const root = this.manager.info(id).root;
|
|
3740
|
+
const url = this.options.assetUrl ?? ((pid, rel) => `fluxplugin://${pid}/${rel.replace(/^\.\//, "")}`);
|
|
3741
|
+
let readme3;
|
|
3742
|
+
if (root) {
|
|
3743
|
+
const file = path7.resolve(root, m.readme ?? "README.md");
|
|
3744
|
+
if (file.startsWith(path7.resolve(root) + path7.sep)) {
|
|
3745
|
+
try {
|
|
3746
|
+
const st = await fsp.stat(file);
|
|
3747
|
+
if (st.size <= 2e5) readme3 = await fsp.readFile(file, "utf8");
|
|
3748
|
+
} catch {
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
3752
|
+
const author = typeof m.author === "string" ? m.author : m.author?.name;
|
|
3753
|
+
const repository = typeof m.repository === "string" ? m.repository : m.repository?.url;
|
|
3754
|
+
return {
|
|
3755
|
+
id,
|
|
3756
|
+
name: m.name,
|
|
3757
|
+
version: m.version,
|
|
3758
|
+
description: m.description,
|
|
3759
|
+
author,
|
|
3760
|
+
license: m.license,
|
|
3761
|
+
homepage: m.homepage,
|
|
3762
|
+
repository,
|
|
3763
|
+
categories: m.categories ?? [],
|
|
3764
|
+
keywords: m.keywords ?? [],
|
|
3765
|
+
links: m.links ?? {},
|
|
3766
|
+
iconUrl: m.icon ? url(id, m.icon) : void 0,
|
|
3767
|
+
bannerUrl: m.banner ? url(id, m.banner) : void 0,
|
|
3768
|
+
screenshotUrls: (m.screenshots ?? []).map((s2) => url(id, s2)),
|
|
3769
|
+
video: (() => {
|
|
3770
|
+
const v = parseVideo(m.video);
|
|
3771
|
+
return v?.kind === "html5" && v.src?.startsWith("./") ? { ...v, src: url(id, v.src) } : v;
|
|
3772
|
+
})(),
|
|
3773
|
+
readme: readme3,
|
|
3774
|
+
assetBase: url(id, "./")
|
|
3775
|
+
};
|
|
3776
|
+
}
|
|
3777
|
+
requireConfig(id) {
|
|
3778
|
+
const c = this.manager.getConfig(id);
|
|
3779
|
+
if (!c) throw new Error(`plugin "${id}" has no configuration (activate it first)`);
|
|
3780
|
+
return c;
|
|
3781
|
+
}
|
|
3782
|
+
async installAndLoad(entry) {
|
|
3783
|
+
const wasActive = this.manager.has(entry.id) && this.manager.info(entry.id).state === "ready";
|
|
3784
|
+
if (this.manager.has(entry.id)) await this.manager.unregister(entry.id);
|
|
3785
|
+
const r = await this.installer.install(entry);
|
|
3786
|
+
await this.scan();
|
|
3787
|
+
if (this.manager.has(entry.id) && (wasActive || !r.previousVersion)) await this.manager.activate(entry.id).catch(() => void 0);
|
|
3788
|
+
return r;
|
|
3789
|
+
}
|
|
3790
|
+
/** Channels used by transports. */
|
|
3791
|
+
static channels = CHANNELS;
|
|
3792
|
+
};
|
|
3793
|
+
|
|
1093
3794
|
// src/cli/build.ts
|
|
1094
|
-
import { promises as
|
|
1095
|
-
import
|
|
3795
|
+
import { existsSync as existsSync2, promises as fs6, watch as fsWatch } from "node:fs";
|
|
3796
|
+
import path10 from "node:path";
|
|
1096
3797
|
import { build, context } from "esbuild";
|
|
1097
3798
|
|
|
1098
3799
|
// src/cli/shared-modules.ts
|
|
1099
|
-
import
|
|
3800
|
+
import path8 from "node:path";
|
|
1100
3801
|
var SHARED_MODULES = ["react", "react/jsx-runtime", "react/jsx-dev-runtime", "react-dom", "react-dom/client", "fluxplugin", "fluxplugin/react", "fluxplugin/manager-ui"];
|
|
1101
3802
|
function sharedModulesPlugin(shared = SHARED_MODULES) {
|
|
1102
3803
|
const set = new Set(shared);
|
|
@@ -1112,7 +3813,7 @@ function sharedModulesPlugin(shared = SHARED_MODULES) {
|
|
|
1112
3813
|
if (!m) throw new Error("Shared module ${args.path} is not provided by the host application (see installSharedModules).");
|
|
1113
3814
|
module.exports = m;`,
|
|
1114
3815
|
loader: "js",
|
|
1115
|
-
resolveDir:
|
|
3816
|
+
resolveDir: path8.resolve(".")
|
|
1116
3817
|
}));
|
|
1117
3818
|
}
|
|
1118
3819
|
};
|
|
@@ -1120,12 +3821,12 @@ module.exports = m;`,
|
|
|
1120
3821
|
|
|
1121
3822
|
// src/cli/build.ts
|
|
1122
3823
|
init_tailwind();
|
|
1123
|
-
var exists = (f) =>
|
|
3824
|
+
var exists = (f) => fs6.access(f).then(() => true, () => false);
|
|
1124
3825
|
async function firstExisting(root, names) {
|
|
1125
|
-
for (const n of names) if (await exists(
|
|
3826
|
+
for (const n of names) if (await exists(path10.join(root, n))) return path10.join(root, n);
|
|
1126
3827
|
}
|
|
1127
3828
|
async function buildOptions(root, dev = false) {
|
|
1128
|
-
const raw = JSON.parse(await
|
|
3829
|
+
const raw = JSON.parse(await fs6.readFile(path10.join(root, "plugin.json"), "utf8"));
|
|
1129
3830
|
const m = validateManifest(raw).manifest;
|
|
1130
3831
|
if (!m) throw new Error("invalid plugin.json (run `fluxplugin validate`)");
|
|
1131
3832
|
const out = [];
|
|
@@ -1136,75 +3837,138 @@ async function buildOptions(root, dev = false) {
|
|
|
1136
3837
|
logLevel: "warning",
|
|
1137
3838
|
plugins: [sharedModulesPlugin()],
|
|
1138
3839
|
jsx: "automatic",
|
|
1139
|
-
loader: {
|
|
3840
|
+
loader: {
|
|
3841
|
+
".module.css": "local-css",
|
|
3842
|
+
// Images and fonts imported by the plugin (or its libraries) are inlined, so dist/ stays self-contained.
|
|
3843
|
+
".png": "dataurl",
|
|
3844
|
+
".jpg": "dataurl",
|
|
3845
|
+
".jpeg": "dataurl",
|
|
3846
|
+
".gif": "dataurl",
|
|
3847
|
+
".webp": "dataurl",
|
|
3848
|
+
".svg": "dataurl",
|
|
3849
|
+
".woff": "dataurl",
|
|
3850
|
+
".woff2": "dataurl",
|
|
3851
|
+
".ttf": "dataurl"
|
|
3852
|
+
}
|
|
1140
3853
|
};
|
|
1141
3854
|
if (m.main) {
|
|
1142
3855
|
const entry = await firstExisting(root, ["src/main.ts", "src/main.js"]);
|
|
1143
3856
|
if (!entry) throw new Error("manifest declares `main` but src/main.ts was not found");
|
|
1144
|
-
out.push({ ...common, entryPoints: [entry], outfile:
|
|
3857
|
+
out.push({ ...common, entryPoints: [entry], outfile: path10.join(root, m.main), platform: "node", format: "esm", target: "node20", external: ["node:*", "electron"] });
|
|
1145
3858
|
}
|
|
1146
3859
|
if (m.renderer) {
|
|
1147
3860
|
const entry = await firstExisting(root, ["src/renderer.tsx", "src/renderer.ts", "src/renderer.jsx", "src/renderer.js"]);
|
|
1148
3861
|
if (!entry) throw new Error("manifest declares `renderer` but src/renderer.tsx was not found");
|
|
1149
|
-
out.push({ ...common, entryPoints: [entry], outfile:
|
|
3862
|
+
out.push({ ...common, entryPoints: [entry], outfile: path10.join(root, m.renderer), platform: "browser", format: "esm", target: "chrome120" });
|
|
1150
3863
|
}
|
|
1151
3864
|
for (const css of m.styles ?? []) {
|
|
1152
|
-
if (
|
|
1153
|
-
const entry =
|
|
1154
|
-
if (await exists(entry)) out.push({ ...common, plugins: [], entryPoints: [entry], outfile:
|
|
3865
|
+
if (path10.basename(css) === "tailwind.css") continue;
|
|
3866
|
+
const entry = path10.join(root, "src/styles", path10.basename(css));
|
|
3867
|
+
if (await exists(entry)) out.push({ ...common, plugins: [], entryPoints: [entry], outfile: path10.join(root, css) });
|
|
1155
3868
|
}
|
|
1156
3869
|
return out;
|
|
1157
3870
|
}
|
|
1158
|
-
async function buildPlugin(root,
|
|
1159
|
-
const opts = await buildOptions(root,
|
|
1160
|
-
const twIn =
|
|
1161
|
-
const stopTw = await exists(twIn) ? compileTailwind({ cwd: root, input: twIn, output:
|
|
3871
|
+
async function buildPlugin(root, watch2 = false, log2 = console.log) {
|
|
3872
|
+
const opts = await buildOptions(root, watch2);
|
|
3873
|
+
const twIn = path10.join(root, "src/styles/tailwind.css");
|
|
3874
|
+
const stopTw = await exists(twIn) ? compileTailwind({ cwd: root, input: twIn, output: path10.join(root, "dist/tailwind.css"), watch: watch2, searchFrom: [process.cwd(), path10.dirname(path10.dirname(root))] }, log2) : () => {
|
|
1162
3875
|
};
|
|
1163
|
-
if (!
|
|
3876
|
+
if (!watch2) {
|
|
1164
3877
|
for (const o of opts) await build(o);
|
|
3878
|
+
await ensureRendererCss(root);
|
|
3879
|
+
await hintMissingDeps(root, log2);
|
|
1165
3880
|
log2(`built ${opts.length} bundle(s) in ${root}`);
|
|
1166
3881
|
return async () => stopTw();
|
|
1167
3882
|
}
|
|
1168
3883
|
const ctxs = await Promise.all(opts.map((o) => context(o)));
|
|
3884
|
+
await Promise.all(ctxs.map((c) => c.rebuild()));
|
|
3885
|
+
await ensureRendererCss(root);
|
|
1169
3886
|
await Promise.all(ctxs.map((c) => c.watch()));
|
|
1170
3887
|
log2(`watching ${root} (${ctxs.length} bundles)`);
|
|
1171
3888
|
return async () => (stopTw(), void await Promise.all(ctxs.map((c) => c.dispose())));
|
|
1172
3889
|
}
|
|
3890
|
+
async function ensureRendererCss(root) {
|
|
3891
|
+
if (!await exists(path10.join(root, "dist/renderer.css"))) return;
|
|
3892
|
+
const file = path10.join(root, "plugin.json");
|
|
3893
|
+
const raw = JSON.parse(await fs6.readFile(file, "utf8"));
|
|
3894
|
+
const styles2 = raw.styles ?? [];
|
|
3895
|
+
if (styles2.some((s2) => s2.replace(/^\.\//, "") === "dist/renderer.css")) return;
|
|
3896
|
+
raw.styles = [...styles2, "./dist/renderer.css"];
|
|
3897
|
+
await fs6.writeFile(file, JSON.stringify(raw, null, 2));
|
|
3898
|
+
}
|
|
3899
|
+
async function hintMissingDeps(root, log2) {
|
|
3900
|
+
try {
|
|
3901
|
+
const pkg2 = JSON.parse(await fs6.readFile(path10.join(root, "package.json"), "utf8"));
|
|
3902
|
+
if (Object.keys(pkg2.dependencies ?? {}).length && !await exists(path10.join(root, "node_modules"))) log2(`! ${path10.basename(root)} lists dependencies but has no node_modules: run "npm install" in ${root}`);
|
|
3903
|
+
} catch {
|
|
3904
|
+
}
|
|
3905
|
+
}
|
|
3906
|
+
async function linkPluginToHost(root, hostDir, log2 = console.log) {
|
|
3907
|
+
const manifest = JSON.parse(await fs6.readFile(path10.join(root, "plugin.json"), "utf8"));
|
|
3908
|
+
const target = path10.join(path10.resolve(hostDir), "plugins", manifest.id);
|
|
3909
|
+
const sync = async () => {
|
|
3910
|
+
try {
|
|
3911
|
+
const copied = await copyPluginRuntime(root, target);
|
|
3912
|
+
const keep = new Set(copied);
|
|
3913
|
+
const walk2 = async (rel) => {
|
|
3914
|
+
for (const e of await fs6.readdir(path10.join(target, rel), { withFileTypes: true })) {
|
|
3915
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
3916
|
+
if (e.isDirectory()) await walk2(r);
|
|
3917
|
+
else if (!keep.has(r)) await fs6.rm(path10.join(target, r), { force: true });
|
|
3918
|
+
}
|
|
3919
|
+
};
|
|
3920
|
+
await walk2("");
|
|
3921
|
+
log2(`\u21AA synced ${copied.length} files to ${path10.relative(process.cwd(), target) || target}`);
|
|
3922
|
+
} catch (e) {
|
|
3923
|
+
log2(`! sync failed: ${e.message}`);
|
|
3924
|
+
}
|
|
3925
|
+
};
|
|
3926
|
+
await sync();
|
|
3927
|
+
let timer;
|
|
3928
|
+
const watchers = [path10.join(root, "dist"), path10.join(root, "assets"), root].filter((d) => existsSync2(d)).map(
|
|
3929
|
+
(d) => fsWatch(d, { recursive: d !== root }, (_e, name) => {
|
|
3930
|
+
if (d === root && name !== "plugin.json" && name !== "README.md") return;
|
|
3931
|
+
clearTimeout(timer);
|
|
3932
|
+
timer = setTimeout(() => void sync(), 250);
|
|
3933
|
+
})
|
|
3934
|
+
);
|
|
3935
|
+
return () => watchers.forEach((w) => w.close());
|
|
3936
|
+
}
|
|
1173
3937
|
|
|
1174
3938
|
// src/cli/host.ts
|
|
1175
3939
|
import { spawn as spawn2, spawnSync as spawnSync2 } from "node:child_process";
|
|
1176
|
-
import { existsSync as
|
|
3940
|
+
import { existsSync as existsSync3, promises as fs8, statSync } from "node:fs";
|
|
1177
3941
|
import { createRequire as createRequire2 } from "node:module";
|
|
1178
|
-
import
|
|
3942
|
+
import path12 from "node:path";
|
|
1179
3943
|
import { build as build2, context as context2 } from "esbuild";
|
|
1180
3944
|
init_tailwind();
|
|
1181
3945
|
|
|
1182
3946
|
// src/cli/config.ts
|
|
1183
|
-
import { promises as
|
|
1184
|
-
import
|
|
3947
|
+
import { promises as fs7 } from "node:fs";
|
|
3948
|
+
import path11 from "node:path";
|
|
1185
3949
|
async function loadConfig(flags, cwd = process.cwd()) {
|
|
1186
3950
|
let file = {};
|
|
1187
3951
|
try {
|
|
1188
|
-
file = JSON.parse(await
|
|
3952
|
+
file = JSON.parse(await fs7.readFile(path11.join(cwd, "fluxplugin.config.json"), "utf8"));
|
|
1189
3953
|
} catch {
|
|
1190
3954
|
}
|
|
1191
3955
|
return {
|
|
1192
|
-
pluginsDir:
|
|
1193
|
-
dataDir:
|
|
3956
|
+
pluginsDir: path11.resolve(cwd, flags.dir ?? file.pluginsDir ?? "plugins"),
|
|
3957
|
+
dataDir: path11.resolve(cwd, flags.data ?? file.dataDir ?? ".flux-data"),
|
|
1194
3958
|
stores: Array.isArray(file.stores) && file.stores.length ? file.stores : [DEFAULT_STORE]
|
|
1195
3959
|
};
|
|
1196
3960
|
}
|
|
1197
3961
|
|
|
1198
3962
|
// src/cli/host.ts
|
|
1199
|
-
var exists2 = (f) =>
|
|
3963
|
+
var exists2 = (f) => fs8.access(f).then(() => true, () => false);
|
|
1200
3964
|
async function isHostProject(cwd) {
|
|
1201
|
-
if (await exists2(
|
|
1202
|
-
return await exists2(
|
|
3965
|
+
if (await exists2(path12.join(cwd, "fluxplugin.config.json"))) return true;
|
|
3966
|
+
return await exists2(path12.join(cwd, "src/app/renderer/index.tsx")) || await exists2(path12.join(cwd, "src/renderer.tsx"));
|
|
1203
3967
|
}
|
|
1204
3968
|
var nodeBanner = { js: "import { createRequire as __r } from 'node:module'; const require = __r(import.meta.url);" };
|
|
1205
3969
|
var pick = (cwd, ...rels) => {
|
|
1206
|
-
for (const r of rels) if (
|
|
1207
|
-
return
|
|
3970
|
+
for (const r of rels) if (existsSync3(path12.join(cwd, r))) return path12.join(cwd, r);
|
|
3971
|
+
return path12.join(cwd, rels[0]);
|
|
1208
3972
|
};
|
|
1209
3973
|
function hostBuilds(cwd, outDir, dev) {
|
|
1210
3974
|
const common = { absWorkingDir: cwd, bundle: true, sourcemap: dev, logLevel: "warning", jsx: "automatic", define: { "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production") } };
|
|
@@ -1213,30 +3977,30 @@ function hostBuilds(cwd, outDir, dev) {
|
|
|
1213
3977
|
const preload = pick(cwd, "src/app/preload/index.ts", "src/preload.ts");
|
|
1214
3978
|
const web = pick(cwd, "src/app/main/web.ts", "src/web.ts");
|
|
1215
3979
|
return [
|
|
1216
|
-
{ ...common, entryPoints: [renderer], outfile:
|
|
1217
|
-
{ ...common, entryPoints: [main], outfile:
|
|
1218
|
-
{ ...common, entryPoints: [preload], outfile:
|
|
1219
|
-
{ ...common, entryPoints: [web], outfile:
|
|
3980
|
+
{ ...common, entryPoints: [renderer], outfile: path12.join(outDir, "renderer.js"), platform: "browser", format: "esm", target: "chrome120" },
|
|
3981
|
+
{ ...common, entryPoints: [main], outfile: path12.join(outDir, "main.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
|
|
3982
|
+
{ ...common, entryPoints: [preload], outfile: path12.join(outDir, "preload.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
|
|
3983
|
+
{ ...common, entryPoints: [web], outfile: path12.join(outDir, "web.mjs"), platform: "node", format: "esm", target: "node20", external: ["electron"], banner: nodeBanner }
|
|
1220
3984
|
];
|
|
1221
3985
|
}
|
|
1222
3986
|
async function pluginDirs(cwd, pluginsDir) {
|
|
1223
|
-
const names = await
|
|
3987
|
+
const names = await fs8.readdir(pluginsDir).catch(() => []);
|
|
1224
3988
|
const out = [];
|
|
1225
|
-
for (const n of names.sort()) if (await exists2(
|
|
3989
|
+
for (const n of names.sort()) if (await exists2(path12.join(pluginsDir, n, "plugin.json"))) out.push(path12.join(pluginsDir, n));
|
|
1226
3990
|
void cwd;
|
|
1227
3991
|
return out;
|
|
1228
3992
|
}
|
|
1229
3993
|
async function buildHost(cwd, opts = {}) {
|
|
1230
3994
|
const log2 = opts.log ?? console.log;
|
|
1231
|
-
const cfgFile = JSON.parse(await
|
|
3995
|
+
const cfgFile = JSON.parse(await fs8.readFile(path12.join(cwd, "fluxplugin.config.json"), "utf8").catch(() => "{}"));
|
|
1232
3996
|
const cfg = await loadConfig({}, cwd);
|
|
1233
|
-
const outDir =
|
|
1234
|
-
await
|
|
1235
|
-
await
|
|
3997
|
+
const outDir = path12.resolve(cwd, cfgFile.outDir ?? "dist");
|
|
3998
|
+
await fs8.mkdir(outDir, { recursive: true });
|
|
3999
|
+
await fs8.copyFile(pick(cwd, "src/app/renderer/index.html", "src/index.html"), path12.join(outDir, "index.html"));
|
|
1236
4000
|
const disposers = [];
|
|
1237
|
-
const twIn =
|
|
4001
|
+
const twIn = path12.join(cwd, "src/app/renderer/styles/tailwind.css");
|
|
1238
4002
|
if (await exists2(twIn)) {
|
|
1239
|
-
const stop = compileTailwind({ cwd, input: twIn, output:
|
|
4003
|
+
const stop = compileTailwind({ cwd, input: twIn, output: path12.join(outDir, "tailwind.css"), watch: opts.watch }, log2);
|
|
1240
4004
|
disposers.push(async () => stop());
|
|
1241
4005
|
}
|
|
1242
4006
|
const opts2 = hostBuilds(cwd, outDir, !!opts.watch);
|
|
@@ -1250,16 +4014,16 @@ async function buildHost(cwd, opts = {}) {
|
|
|
1250
4014
|
try {
|
|
1251
4015
|
disposers.push(await buildPlugin(dir, !!opts.watch, log2));
|
|
1252
4016
|
} catch (e) {
|
|
1253
|
-
log2(`! plugin ${
|
|
4017
|
+
log2(`! plugin ${path12.basename(dir)} not built: ${e.message}`);
|
|
1254
4018
|
}
|
|
1255
4019
|
}
|
|
1256
|
-
log2(`host built \u2192 ${
|
|
4020
|
+
log2(`host built \u2192 ${path12.relative(cwd, outDir) || "."}`);
|
|
1257
4021
|
return async () => void await Promise.all(disposers.map((d) => d()));
|
|
1258
4022
|
}
|
|
1259
4023
|
function electronBinary(cwd) {
|
|
1260
4024
|
try {
|
|
1261
|
-
const p = createRequire2(
|
|
1262
|
-
if (typeof p === "string" &&
|
|
4025
|
+
const p = createRequire2(path12.join(cwd, "package.json"))("electron");
|
|
4026
|
+
if (typeof p === "string" && existsSync3(p)) return { bin: p };
|
|
1263
4027
|
return { error: "the Electron binary was not found" };
|
|
1264
4028
|
} catch (e) {
|
|
1265
4029
|
return { error: e.message.split("\n")[0] };
|
|
@@ -1270,12 +4034,12 @@ function ensureElectron(cwd, log2 = console.log) {
|
|
|
1270
4034
|
if (first.bin) return first;
|
|
1271
4035
|
let pkgDir;
|
|
1272
4036
|
try {
|
|
1273
|
-
pkgDir =
|
|
4037
|
+
pkgDir = path12.dirname(createRequire2(path12.join(cwd, "package.json")).resolve("electron/package.json"));
|
|
1274
4038
|
} catch {
|
|
1275
4039
|
return { error: "Electron is not installed in this project" };
|
|
1276
4040
|
}
|
|
1277
|
-
const installJs =
|
|
1278
|
-
if (!
|
|
4041
|
+
const installJs = path12.join(pkgDir, "install.js");
|
|
4042
|
+
if (!existsSync3(installJs)) return first;
|
|
1279
4043
|
log2("Electron's binary is missing (your package manager blocked its install script) \u2192 downloading it now\u2026");
|
|
1280
4044
|
const r = spawnSync2(process.execPath, [installJs], { cwd: pkgDir, stdio: "inherit" });
|
|
1281
4045
|
if (r.status !== 0) return { error: "the Electron download failed (network or proxy?). Retry, or set ELECTRON_MIRROR" };
|
|
@@ -1285,7 +4049,7 @@ function needsNoSandbox(bin) {
|
|
|
1285
4049
|
if (process.platform !== "linux") return false;
|
|
1286
4050
|
if (process.getuid?.() === 0) return true;
|
|
1287
4051
|
try {
|
|
1288
|
-
const st = statSync(
|
|
4052
|
+
const st = statSync(path12.join(path12.dirname(bin), "chrome-sandbox"));
|
|
1289
4053
|
return !(st.uid === 0 && (st.mode & 2048) !== 0);
|
|
1290
4054
|
} catch {
|
|
1291
4055
|
return false;
|
|
@@ -1294,12 +4058,12 @@ function needsNoSandbox(bin) {
|
|
|
1294
4058
|
function startApp(cwd, opts = {}) {
|
|
1295
4059
|
const log2 = opts.log ?? console.log;
|
|
1296
4060
|
const env = { ...process.env, FLUXPLUGIN_PROJECT: cwd, ...opts.dev ? { FLUXPLUGIN_DEV: "1" } : {}, ...opts.port ? { PORT: String(opts.port) } : {} };
|
|
1297
|
-
if (opts.web) return { child: spawn2(process.execPath, [
|
|
4061
|
+
if (opts.web) return { child: spawn2(process.execPath, [path12.join(cwd, "dist/web.mjs")], { cwd, env, stdio: "inherit" }), mode: "web" };
|
|
1298
4062
|
const { bin, error } = ensureElectron(cwd, log2);
|
|
1299
4063
|
if (!bin) throw new Error(`cannot start Electron: ${error}.
|
|
1300
4064
|
Try: npm install -D electron then fluxplugin doctor
|
|
1301
4065
|
Or run in a browser: fluxplugin ${opts.dev ? "dev" : "start"} --web`);
|
|
1302
|
-
const args = [...process.env["FLUXPLUGIN_ELECTRON_ARGS"]?.split(" ").filter(Boolean) ?? [],
|
|
4066
|
+
const args = [...process.env["FLUXPLUGIN_ELECTRON_ARGS"]?.split(" ").filter(Boolean) ?? [], path12.join(cwd, "dist/main.cjs")];
|
|
1303
4067
|
if (needsNoSandbox(bin)) {
|
|
1304
4068
|
args.unshift("--no-sandbox");
|
|
1305
4069
|
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");
|
|
@@ -1314,10 +4078,398 @@ function startApp(cwd, opts = {}) {
|
|
|
1314
4078
|
|
|
1315
4079
|
// src/cli/host-files.generated.ts
|
|
1316
4080
|
var HOST_APP_FILES = {
|
|
1317
|
-
"
|
|
1318
|
-
"
|
|
4081
|
+
"electron-builder.yml": '# Produces the installer/executable: `npm run dist` (current OS) or `npm run dist -- --win --mac --linux`.\nappId: com.example.__APP_ID__\nproductName: __APP_NAME__\ndirectories:\n output: release\nfiles:\n - dist/**/*\n - package.json\n - "!**/*.map"\n# Plugins are copied by `fluxplugin dist` into .flux-build/plugins with runtime files only (no src, no .ts, no sourcemaps).\nextraResources:\n - from: .flux-build/plugins\n to: plugins\nasar: true\nwin:\n target: [nsis]\nnsis:\n oneClick: false\n allowToChangeInstallationDirectory: true\n # Plugins the user installed live in the app\'s data folder; this removes them with the app when it is uninstalled.\n deleteAppDataOnUninstall: true\nmac:\n target: [dmg, zip]\n category: public.app-category.productivity\nlinux:\n target: [AppImage, deb]\n category: Utility\n',
|
|
4082
|
+
"plugins/appearance/README.md": "# Appearance\n\nMake the app yours. This plugin adds an **Appearance** page to *Settings* and four extra themes.\n\n## What you can change\n\n- **Mode** \u2014 System, Light or Dark.\n- **Theme** \u2014 Midnight, Sunset, Forest or high-contrast Graphite.\n- **Accent color** \u2014 seven colors, or the theme's own.\n- **Text** \u2014 font family, any font installed on your computer, size and line spacing.\n- **Layout** \u2014 compact or comfortable spacing, corner roundness, translucency and reduced motion.\n\n> [!TIP]\n> Type the name of any font installed on your computer in *Your own font*. If it is missing, the next font in the list is used.\n\n## How it works\n\n| Setting | Stored as |\n| --- | --- |\n| Theme and mode | the app's theme variables |\n| Fonts, size, spacing | CSS variables on the page |\n\nYour choices are saved on this device and apply to every plugin, because plugins use the same variables.\n\n## For developers\n\nPlugins can add their own **themes** and **fonts** to the same lists \u2014 see the `theme` and `font` extension points in the documentation.\n",
|
|
4083
|
+
"plugins/appearance/assets/icon.svg": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" rx="22" fill="hsl(280 72% 50%)"/><text x="48" y="64" font-family="system-ui,sans-serif" font-size="52" font-weight="700" text-anchor="middle" fill="#fff">A</text></svg>\n',
|
|
4084
|
+
"plugins/appearance/plugin.json": '{\n "id": "com.fluxplugin.appearance",\n "name": "Appearance",\n "version": "1.0.0",\n "description": "Themes, accent colors, fonts, text size, density and more \\u2014 everything about how the app looks.",\n "author": "fluxplugin",\n "license": "MIT",\n "icon": "./assets/icon.svg",\n "readme": "./README.md",\n "categories": [\n "Personalization"\n ],\n "keywords": [\n "theme",\n "font",\n "accent",\n "dark mode"\n ],\n "links": {},\n "renderer": "./dist/renderer.js",\n "activationEvents": [\n "onStartup"\n ],\n "permissions": [\n "ui"\n ],\n "engines": {\n "fluxplugin": "^1.0.0"\n }\n}',
|
|
4085
|
+
"plugins/appearance/src/renderer.tsx": `import { definePlugin } from "fluxplugin";
|
|
4086
|
+
import { Button, Group, Row, Segmented, Switch, useAppearance, useT, useTheme, type ThemeMode } from "fluxplugin/react";
|
|
4087
|
+
import { useState } from "react";
|
|
4088
|
+
|
|
4089
|
+
const ACCENTS = ["#007aff", "#34c759", "#ff9f0a", "#af52de", "#ff375f", "#5ac8fa", "#8e8e93"];
|
|
4090
|
+
|
|
4091
|
+
function AppearanceSection() {
|
|
4092
|
+
const t = useT();
|
|
4093
|
+
const { mode, setMode, themeId, setThemeId, themes } = useTheme();
|
|
4094
|
+
const { appearance: a, setAppearance, resetAppearance, fonts } = useAppearance();
|
|
4095
|
+
const [custom, setCustom] = useState(a.customFont);
|
|
4096
|
+
return (
|
|
4097
|
+
<>
|
|
4098
|
+
<Group title={t("appearance.mode", "Mode")}>
|
|
4099
|
+
<Row title={t("appearance.mode", "Mode")} right={
|
|
4100
|
+
<Segmented<ThemeMode> value={(themeId ? "" : mode) as ThemeMode} onChange={setMode} label="Mode"
|
|
4101
|
+
options={[{ value: "system", label: t("theme.system", "System") }, { value: "light", label: t("theme.light", "Light") }, { value: "dark", label: t("theme.dark", "Dark") }]} />} />
|
|
4102
|
+
</Group>
|
|
4103
|
+
|
|
4104
|
+
<Group title={t("appearance.theme", "Theme")}>
|
|
4105
|
+
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", padding: 14 }}>
|
|
4106
|
+
{[{ id: "", label: t("appearance.themeDefault", "Default"), tokens: { "--flux-bg": "#ffffff", "--flux-surface": "#f5f5f7", "--flux-accent": "#007aff" } }, ...themes].map((th) => (
|
|
4107
|
+
<button key={th.id || "default"} type="button" className="flux-theme-card" data-on={(themeId ?? "") === th.id || undefined} data-testid={"theme-" + (th.id || "default")} onClick={() => (th.id ? setThemeId(th.id) : setMode(mode))}>
|
|
4108
|
+
<span className="flux-theme-prev" style={{ display: "flex" }}>
|
|
4109
|
+
<i style={{ flex: 2, background: th.tokens["--flux-bg"] }} /><i style={{ flex: 1, background: th.tokens["--flux-surface"] }} /><i style={{ flex: 1, background: th.tokens["--flux-accent"] }} />
|
|
4110
|
+
</span>
|
|
4111
|
+
<span className="cap">{th.label}</span>
|
|
4112
|
+
</button>
|
|
4113
|
+
))}
|
|
4114
|
+
</div>
|
|
4115
|
+
</Group>
|
|
4116
|
+
|
|
4117
|
+
<Group title={t("appearance.accent", "Accent color")}>
|
|
4118
|
+
<div className="flux-swatches" style={{ padding: 14 }}>
|
|
4119
|
+
<button type="button" className="flux-swatch" title={t("appearance.accentDefault", "Theme default")} style={{ background: "conic-gradient(#ff3b30,#ff9f0a,#34c759,#0a84ff,#af52de,#ff3b30)" }} data-on={!a.accent || undefined} onClick={() => setAppearance({ accent: undefined })} />
|
|
4120
|
+
{ACCENTS.map((c) => <button key={c} type="button" aria-label={c} className="flux-swatch" style={{ background: c }} data-on={a.accent === c || undefined} data-testid={"accent-" + c.slice(1)} onClick={() => setAppearance({ accent: c })} />)}
|
|
4121
|
+
</div>
|
|
4122
|
+
</Group>
|
|
4123
|
+
|
|
4124
|
+
<Group title={t("appearance.text", "Text")}>
|
|
4125
|
+
<Row title={t("appearance.font", "Font")} right={
|
|
4126
|
+
<select className="flux-select" value={a.fontId} data-testid="font-select" onChange={(e) => setAppearance({ fontId: e.target.value })}>{fonts.map((f) => <option key={f.id} value={f.id}>{f.label}</option>)}</select>} />
|
|
4127
|
+
<Row title={t("appearance.customFont", "Your own font")} subtitle={t("appearance.customFontHint", "Any font installed on your computer")} right={
|
|
4128
|
+
<input className="flux-input" style={{ width: 170 }} placeholder="e.g. Avenir Next" value={custom} data-testid="custom-font" onChange={(e) => setCustom(e.target.value)} onBlur={() => setAppearance({ customFont: custom })} onKeyDown={(e) => e.key === "Enter" && setAppearance({ customFont: custom })} />} />
|
|
4129
|
+
<Row title={t("appearance.fontSize", "Text size")} right={<><input className="flux-range" type="range" min={12} max={20} step={1} value={a.fontSize} data-testid="font-size" onChange={(e) => setAppearance({ fontSize: Number(e.target.value) })} /><span style={{ width: 34, textAlign: "right" }}>{a.fontSize}px</span></>} />
|
|
4130
|
+
<Row title={t("appearance.lineHeight", "Line spacing")} right={<input className="flux-range" type="range" min={1.2} max={2} step={0.05} value={a.lineHeight} onChange={(e) => setAppearance({ lineHeight: Number(e.target.value) })} />} />
|
|
4131
|
+
<div style={{ padding: "4px 14px 14px" }}><p style={{ margin: 0 }}>{t("appearance.preview", "The quick brown fox jumps over the lazy dog.")}</p></div>
|
|
4132
|
+
</Group>
|
|
4133
|
+
|
|
4134
|
+
<Group title={t("appearance.layout", "Layout")}>
|
|
4135
|
+
<Row title={t("appearance.density", "Spacing")} right={
|
|
4136
|
+
<Segmented value={a.density} onChange={(density) => setAppearance({ density })} label="Spacing" options={[{ value: "comfortable", label: t("appearance.comfortable", "Comfortable") }, { value: "compact", label: t("appearance.compact", "Compact") }]} />} />
|
|
4137
|
+
<Row title={t("appearance.radius", "Corners")} right={
|
|
4138
|
+
<Segmented value={a.radius} onChange={(radius) => setAppearance({ radius })} label="Corners" options={[{ value: "sharp", label: t("appearance.sharp", "Sharp") }, { value: "default", label: t("appearance.default", "Default") }, { value: "round", label: t("appearance.round", "Round") }]} />} />
|
|
4139
|
+
<Row title={t("appearance.translucency", "Translucency")} right={<Switch checked={a.translucency} onChange={(translucency) => setAppearance({ translucency })} label="Translucency" />} />
|
|
4140
|
+
<Row title={t("appearance.motion", "Reduce motion")} right={<Switch checked={a.reduceMotion} onChange={(reduceMotion) => setAppearance({ reduceMotion })} label="Reduce motion" />} />
|
|
4141
|
+
</Group>
|
|
4142
|
+
<Button variant="destructive" onClick={() => (resetAppearance(), setCustom(""))}>{t("appearance.reset", "Reset appearance")}</Button>
|
|
4143
|
+
</>
|
|
4144
|
+
);
|
|
4145
|
+
}
|
|
4146
|
+
|
|
4147
|
+
export default definePlugin({
|
|
4148
|
+
extensions: [
|
|
4149
|
+
{ point: "settings", contribution: { sections: [{ id: "appearance", title: "Appearance", icon: "palette", order: 10, component: AppearanceSection }] } },
|
|
4150
|
+
{ point: "theme", contribution: { themes: [
|
|
4151
|
+
{ id: "midnight", label: "Midnight", dark: true, tokens: { "--flux-bg": "#0b1020", "--flux-surface": "#111936", "--flux-surface-2": "#1a2447", "--flux-fg": "#e6ebff", "--flux-muted": "#8f9bc7", "--flux-border": "rgba(150,170,255,.18)", "--flux-accent": "#7c9cff", "--flux-material": "rgba(17,25,54,.72)" } },
|
|
4152
|
+
{ id: "sunset", label: "Sunset", tokens: { "--flux-bg": "#fff8f1", "--flux-surface": "#ffefe0", "--flux-surface-2": "#ffe0c7", "--flux-fg": "#3b1f12", "--flux-muted": "#9a6b53", "--flux-border": "rgba(120,60,20,.16)", "--flux-accent": "#ff6a3d", "--flux-material": "rgba(255,239,224,.8)" } },
|
|
4153
|
+
{ id: "forest", label: "Forest", dark: true, tokens: { "--flux-bg": "#0f1a14", "--flux-surface": "#162720", "--flux-surface-2": "#1f3a2e", "--flux-fg": "#e4f3ea", "--flux-muted": "#8fb3a0", "--flux-border": "rgba(150,230,190,.16)", "--flux-accent": "#34d399", "--flux-material": "rgba(22,39,32,.74)" } },
|
|
4154
|
+
{ id: "graphite", label: "Graphite (high contrast)", tokens: { "--flux-bg": "#ffffff", "--flux-surface": "#f0f0f0", "--flux-surface-2": "#dcdcdc", "--flux-fg": "#000000", "--flux-muted": "#3a3a3a", "--flux-border": "#000000", "--flux-accent": "#0040dd", "--flux-material": "rgba(240,240,240,.95)" } },
|
|
4155
|
+
] } },
|
|
4156
|
+
{ point: "font", contribution: { fonts: [
|
|
4157
|
+
{ id: "editorial", label: "Editorial", stack: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif' },
|
|
4158
|
+
{ id: "geometric", label: "Geometric", stack: '"Avenir Next", Avenir, "Century Gothic", Futura, "Trebuchet MS", sans-serif' },
|
|
4159
|
+
{ id: "readable", label: "Easy reading", stack: '"Atkinson Hyperlegible", Verdana, Tahoma, sans-serif' },
|
|
4160
|
+
] } },
|
|
4161
|
+
],
|
|
4162
|
+
});
|
|
4163
|
+
`,
|
|
4164
|
+
"plugins/language/README.md": "# Language\n\nChoose the language of the whole interface: menus, search, notifications, settings and the plugin manager.\n\n## Languages included\n\n| Language | Code |\n| --- | --- |\n| English | `en` |\n| Fran\xE7ais | `fr` |\n| Espa\xF1ol | `es` |\n| Deutsch | `de` |\n\nOpen **Settings \u2192 Language** and pick one. The change is instant and remembered.\n\n## Add your own language\n\n1. Copy `src/messages.ts` and translate the values.\n2. Add the language to the list in `src/renderer.tsx`.\n3. Build with `fluxplugin build`.\n\n> [!NOTE]\n> Any plugin can ship translations \u2014 the app merges them. Missing words fall back to English, so a partial translation is fine.\n\n## Right-to-left languages\n\nSet `rtl: true` on a language (Arabic, Hebrew\u2026) and the whole interface mirrors automatically.\n",
|
|
4165
|
+
"plugins/language/assets/icon.svg": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" rx="22" fill="hsl(205 72% 50%)"/><text x="48" y="64" font-family="system-ui,sans-serif" font-size="52" font-weight="700" text-anchor="middle" fill="#fff">L</text></svg>\n',
|
|
4166
|
+
"plugins/language/plugin.json": '{\n "id": "com.fluxplugin.language",\n "name": "Language",\n "version": "1.0.0",\n "description": "Use the app in English, French, Spanish or German \\u2014 and add your own language.",\n "author": "fluxplugin",\n "license": "MIT",\n "icon": "./assets/icon.svg",\n "readme": "./README.md",\n "categories": [\n "Personalization"\n ],\n "keywords": [\n "language",\n "translation",\n "i18n",\n "fran\\u00e7ais",\n "espa\\u00f1ol",\n "deutsch"\n ],\n "links": {},\n "renderer": "./dist/renderer.js",\n "activationEvents": [\n "onStartup"\n ],\n "permissions": [\n "ui"\n ],\n "engines": {\n "fluxplugin": "^1.0.0"\n }\n}',
|
|
4167
|
+
"plugins/language/src/messages.ts": `/** Translations. Keys are the ones the app and its plugins pass to useT(); English is the built-in fallback. */
|
|
4168
|
+
export const messages: Record<string, Record<string, string>> = {
|
|
4169
|
+
"fr": {
|
|
4170
|
+
"menu.dashboard": "Tableau de bord",
|
|
4171
|
+
"menu.plugins": "Extensions",
|
|
4172
|
+
"menu.settings": "R\xE9glages",
|
|
4173
|
+
"theme.toggle": "Changer d'apparence",
|
|
4174
|
+
"theme.system": "Syst\xE8me",
|
|
4175
|
+
"theme.light": "Clair",
|
|
4176
|
+
"theme.dark": "Sombre",
|
|
4177
|
+
"search.placeholder": "Rechercher commandes, pages\u2026",
|
|
4178
|
+
"search.label": "Rechercher",
|
|
4179
|
+
"search.commands": "Commandes",
|
|
4180
|
+
"search.pages": "Pages",
|
|
4181
|
+
"search.noResults": "Aucun r\xE9sultat",
|
|
4182
|
+
"notifications.title": "Notifications",
|
|
4183
|
+
"notifications.clear": "Tout effacer",
|
|
4184
|
+
"notifications.empty": "Tout est \xE0 jour",
|
|
4185
|
+
"palette.placeholder": "Saisir une commande\u2026",
|
|
4186
|
+
"palette.empty": "Aucune commande correspondante",
|
|
4187
|
+
"sidebar.expand": "D\xE9velopper la barre lat\xE9rale",
|
|
4188
|
+
"sidebar.collapse": "R\xE9duire la barre lat\xE9rale",
|
|
4189
|
+
"settings.title": "R\xE9glages",
|
|
4190
|
+
"settings.empty": "Aucun r\xE9glage",
|
|
4191
|
+
"settings.general": "G\xE9n\xE9ral",
|
|
4192
|
+
"settings.shortcuts": "Raccourcis",
|
|
4193
|
+
"settings.appearance": "Apparence",
|
|
4194
|
+
"settings.language": "Langue",
|
|
4195
|
+
"settings.about": "\xC0 propos",
|
|
4196
|
+
"settings.app": "Application",
|
|
4197
|
+
"settings.plugins": "Extensions",
|
|
4198
|
+
"settings.engine": "Moteur",
|
|
4199
|
+
"settings.sidebar": "Barre lat\xE9rale",
|
|
4200
|
+
"settings.sidebarHint": "R\xE9duit la barre lat\xE9rale en ic\xF4nes. Raccourci : Ctrl/Cmd+B.",
|
|
4201
|
+
"settings.compactSidebar": "Barre lat\xE9rale compacte",
|
|
4202
|
+
"plugins.title": "Extensions",
|
|
4203
|
+
"plugins.installed": "Install\xE9es",
|
|
4204
|
+
"plugins.discover": "D\xE9couvrir",
|
|
4205
|
+
"plugins.updates": "Mises \xE0 jour",
|
|
4206
|
+
"plugins.search": "Rechercher des extensions",
|
|
4207
|
+
"plugins.none": "Aucune extension install\xE9e",
|
|
4208
|
+
"plugins.searching": "Recherche dans les catalogues\u2026",
|
|
4209
|
+
"plugins.upToDate": "Tout est \xE0 jour.",
|
|
4210
|
+
"plugins.storeEmpty": "Aucune extension trouv\xE9e dans vos catalogues.",
|
|
4211
|
+
"plugins.installedTag": "Install\xE9e",
|
|
4212
|
+
"plugins.get": "Obtenir",
|
|
4213
|
+
"plugins.update": "Mettre \xE0 jour",
|
|
4214
|
+
"plugins.open": "Ouvrir",
|
|
4215
|
+
"plugins.back": "Extensions",
|
|
4216
|
+
"plugins.notFound": "Cette extension n'est pas install\xE9e.",
|
|
4217
|
+
"plugins.enable": "Activer",
|
|
4218
|
+
"plugins.enabled": "Activ\xE9e",
|
|
4219
|
+
"plugins.disabled": "D\xE9sactiv\xE9e",
|
|
4220
|
+
"plugins.reload": "Recharger",
|
|
4221
|
+
"plugins.uninstall": "D\xE9sinstaller",
|
|
4222
|
+
"plugins.video": "Vid\xE9o",
|
|
4223
|
+
"plugins.about": "\xC0 propos",
|
|
4224
|
+
"plugins.permissions": "Autorisations",
|
|
4225
|
+
"plugins.permissionsFooter": "Une extension n'obtient que ce qu'elle d\xE9clare. Les acc\xE8s sensibles sont signal\xE9s.",
|
|
4226
|
+
"plugins.noPermissions": "Aucun acc\xE8s particulier",
|
|
4227
|
+
"plugins.sensitive": "Sensible",
|
|
4228
|
+
"plugins.information": "Informations",
|
|
4229
|
+
"plugins.developer": "D\xE9veloppeur",
|
|
4230
|
+
"plugins.version": "Version",
|
|
4231
|
+
"plugins.license": "Licence",
|
|
4232
|
+
"plugins.dependencies": "N\xE9cessite",
|
|
4233
|
+
"plugins.keywords": "Mots-cl\xE9s",
|
|
4234
|
+
"plugins.settings": "R\xE9glages",
|
|
4235
|
+
"plugins.saved": "Enregistr\xE9",
|
|
4236
|
+
"plugins.reset": "R\xE9tablir les valeurs par d\xE9faut",
|
|
4237
|
+
"plugins.logs": "Journal",
|
|
4238
|
+
"plugins.noLogs": "Aucun journal",
|
|
4239
|
+
"plugins.installedVersion": "Install\xE9e : {v}",
|
|
4240
|
+
"state.ready": "Active",
|
|
4241
|
+
"state.error": "Erreur",
|
|
4242
|
+
"state.disabled": "D\xE9sactiv\xE9e",
|
|
4243
|
+
"appearance.mode": "Mode",
|
|
4244
|
+
"appearance.theme": "Th\xE8me",
|
|
4245
|
+
"appearance.themeDefault": "Par d\xE9faut",
|
|
4246
|
+
"appearance.accent": "Couleur d'accentuation",
|
|
4247
|
+
"appearance.accentDefault": "Selon le th\xE8me",
|
|
4248
|
+
"appearance.text": "Texte",
|
|
4249
|
+
"appearance.font": "Police",
|
|
4250
|
+
"appearance.customFont": "Votre propre police",
|
|
4251
|
+
"appearance.customFontHint": "Toute police install\xE9e sur votre ordinateur",
|
|
4252
|
+
"appearance.fontSize": "Taille du texte",
|
|
4253
|
+
"appearance.lineHeight": "Interligne",
|
|
4254
|
+
"appearance.preview": "Portez ce vieux whisky au juge blond qui fume.",
|
|
4255
|
+
"appearance.layout": "Disposition",
|
|
4256
|
+
"appearance.density": "Espacement",
|
|
4257
|
+
"appearance.comfortable": "Confortable",
|
|
4258
|
+
"appearance.compact": "Compact",
|
|
4259
|
+
"appearance.radius": "Coins",
|
|
4260
|
+
"appearance.sharp": "Droits",
|
|
4261
|
+
"appearance.default": "Par d\xE9faut",
|
|
4262
|
+
"appearance.round": "Arrondis",
|
|
4263
|
+
"appearance.translucency": "Transparence",
|
|
4264
|
+
"appearance.motion": "R\xE9duire les animations",
|
|
4265
|
+
"appearance.reset": "R\xE9initialiser l'apparence",
|
|
4266
|
+
"language.follow": "Suivre la langue du syst\xE8me"
|
|
4267
|
+
},
|
|
4268
|
+
"es": {
|
|
4269
|
+
"menu.dashboard": "Panel",
|
|
4270
|
+
"menu.plugins": "Extensiones",
|
|
4271
|
+
"menu.settings": "Ajustes",
|
|
4272
|
+
"theme.toggle": "Cambiar apariencia",
|
|
4273
|
+
"theme.system": "Sistema",
|
|
4274
|
+
"theme.light": "Claro",
|
|
4275
|
+
"theme.dark": "Oscuro",
|
|
4276
|
+
"search.placeholder": "Buscar comandos, p\xE1ginas\u2026",
|
|
4277
|
+
"search.label": "Buscar",
|
|
4278
|
+
"search.commands": "Comandos",
|
|
4279
|
+
"search.pages": "P\xE1ginas",
|
|
4280
|
+
"search.noResults": "Sin resultados",
|
|
4281
|
+
"notifications.title": "Notificaciones",
|
|
4282
|
+
"notifications.clear": "Borrar todo",
|
|
4283
|
+
"notifications.empty": "Todo al d\xEDa",
|
|
4284
|
+
"palette.placeholder": "Escribe un comando\u2026",
|
|
4285
|
+
"palette.empty": "Ning\xFAn comando coincide",
|
|
4286
|
+
"sidebar.expand": "Expandir barra lateral",
|
|
4287
|
+
"sidebar.collapse": "Contraer barra lateral",
|
|
4288
|
+
"settings.title": "Ajustes",
|
|
4289
|
+
"settings.empty": "Sin ajustes",
|
|
4290
|
+
"settings.general": "General",
|
|
4291
|
+
"settings.shortcuts": "Atajos",
|
|
4292
|
+
"settings.appearance": "Apariencia",
|
|
4293
|
+
"settings.language": "Idioma",
|
|
4294
|
+
"settings.about": "Acerca de",
|
|
4295
|
+
"settings.app": "Aplicaci\xF3n",
|
|
4296
|
+
"settings.plugins": "Extensiones",
|
|
4297
|
+
"settings.engine": "Motor",
|
|
4298
|
+
"settings.sidebar": "Barra lateral",
|
|
4299
|
+
"settings.sidebarHint": "Reduce la barra lateral a iconos. Atajo: Ctrl/Cmd+B.",
|
|
4300
|
+
"settings.compactSidebar": "Barra lateral compacta",
|
|
4301
|
+
"plugins.title": "Extensiones",
|
|
4302
|
+
"plugins.installed": "Instaladas",
|
|
4303
|
+
"plugins.discover": "Descubrir",
|
|
4304
|
+
"plugins.updates": "Actualizaciones",
|
|
4305
|
+
"plugins.search": "Buscar extensiones",
|
|
4306
|
+
"plugins.none": "No hay extensiones instaladas",
|
|
4307
|
+
"plugins.searching": "Buscando en los cat\xE1logos\u2026",
|
|
4308
|
+
"plugins.upToDate": "Todo est\xE1 actualizado.",
|
|
4309
|
+
"plugins.storeEmpty": "No se encontraron extensiones en tus cat\xE1logos.",
|
|
4310
|
+
"plugins.installedTag": "Instalada",
|
|
4311
|
+
"plugins.get": "Obtener",
|
|
4312
|
+
"plugins.update": "Actualizar",
|
|
4313
|
+
"plugins.open": "Abrir",
|
|
4314
|
+
"plugins.back": "Extensiones",
|
|
4315
|
+
"plugins.notFound": "Esta extensi\xF3n no est\xE1 instalada.",
|
|
4316
|
+
"plugins.enable": "Activar",
|
|
4317
|
+
"plugins.enabled": "Activada",
|
|
4318
|
+
"plugins.disabled": "Desactivada",
|
|
4319
|
+
"plugins.reload": "Recargar",
|
|
4320
|
+
"plugins.uninstall": "Desinstalar",
|
|
4321
|
+
"plugins.video": "V\xEDdeo",
|
|
4322
|
+
"plugins.about": "Descripci\xF3n",
|
|
4323
|
+
"plugins.permissions": "Permisos",
|
|
4324
|
+
"plugins.permissionsFooter": "Las extensiones solo obtienen lo que declaran. Los accesos sensibles se destacan.",
|
|
4325
|
+
"plugins.noPermissions": "Sin accesos especiales",
|
|
4326
|
+
"plugins.sensitive": "Sensible",
|
|
4327
|
+
"plugins.information": "Informaci\xF3n",
|
|
4328
|
+
"plugins.developer": "Desarrollador",
|
|
4329
|
+
"plugins.version": "Versi\xF3n",
|
|
4330
|
+
"plugins.license": "Licencia",
|
|
4331
|
+
"plugins.dependencies": "Requiere",
|
|
4332
|
+
"plugins.keywords": "Palabras clave",
|
|
4333
|
+
"plugins.settings": "Ajustes",
|
|
4334
|
+
"plugins.saved": "Guardado",
|
|
4335
|
+
"plugins.reset": "Restablecer valores",
|
|
4336
|
+
"plugins.logs": "Registro",
|
|
4337
|
+
"plugins.noLogs": "Sin registros",
|
|
4338
|
+
"plugins.installedVersion": "Instalada: {v}",
|
|
4339
|
+
"state.ready": "Activa",
|
|
4340
|
+
"state.error": "Error",
|
|
4341
|
+
"state.disabled": "Desactivada",
|
|
4342
|
+
"appearance.mode": "Modo",
|
|
4343
|
+
"appearance.theme": "Tema",
|
|
4344
|
+
"appearance.themeDefault": "Predeterminado",
|
|
4345
|
+
"appearance.accent": "Color de \xE9nfasis",
|
|
4346
|
+
"appearance.accentDefault": "Seg\xFAn el tema",
|
|
4347
|
+
"appearance.text": "Texto",
|
|
4348
|
+
"appearance.font": "Fuente",
|
|
4349
|
+
"appearance.customFont": "Tu propia fuente",
|
|
4350
|
+
"appearance.customFontHint": "Cualquier fuente instalada en tu equipo",
|
|
4351
|
+
"appearance.fontSize": "Tama\xF1o del texto",
|
|
4352
|
+
"appearance.lineHeight": "Interlineado",
|
|
4353
|
+
"appearance.preview": "El veloz murci\xE9lago hind\xFA com\xEDa feliz cardillo y kiwi.",
|
|
4354
|
+
"appearance.layout": "Disposici\xF3n",
|
|
4355
|
+
"appearance.density": "Espaciado",
|
|
4356
|
+
"appearance.comfortable": "C\xF3modo",
|
|
4357
|
+
"appearance.compact": "Compacto",
|
|
4358
|
+
"appearance.radius": "Esquinas",
|
|
4359
|
+
"appearance.sharp": "Rectas",
|
|
4360
|
+
"appearance.default": "Predeterminadas",
|
|
4361
|
+
"appearance.round": "Redondeadas",
|
|
4362
|
+
"appearance.translucency": "Transparencia",
|
|
4363
|
+
"appearance.motion": "Reducir movimiento",
|
|
4364
|
+
"appearance.reset": "Restablecer apariencia",
|
|
4365
|
+
"language.follow": "Seguir el idioma del sistema"
|
|
4366
|
+
},
|
|
4367
|
+
"de": {
|
|
4368
|
+
"menu.dashboard": "\xDCbersicht",
|
|
4369
|
+
"menu.plugins": "Erweiterungen",
|
|
4370
|
+
"menu.settings": "Einstellungen",
|
|
4371
|
+
"theme.toggle": "Darstellung wechseln",
|
|
4372
|
+
"theme.system": "System",
|
|
4373
|
+
"theme.light": "Hell",
|
|
4374
|
+
"theme.dark": "Dunkel",
|
|
4375
|
+
"search.placeholder": "Befehle, Seiten suchen\u2026",
|
|
4376
|
+
"search.label": "Suchen",
|
|
4377
|
+
"search.commands": "Befehle",
|
|
4378
|
+
"search.pages": "Seiten",
|
|
4379
|
+
"search.noResults": "Keine Ergebnisse",
|
|
4380
|
+
"notifications.title": "Mitteilungen",
|
|
4381
|
+
"notifications.clear": "Alle l\xF6schen",
|
|
4382
|
+
"notifications.empty": "Alles erledigt",
|
|
4383
|
+
"palette.placeholder": "Befehl eingeben\u2026",
|
|
4384
|
+
"palette.empty": "Kein passender Befehl",
|
|
4385
|
+
"sidebar.expand": "Seitenleiste erweitern",
|
|
4386
|
+
"sidebar.collapse": "Seitenleiste einklappen",
|
|
4387
|
+
"settings.title": "Einstellungen",
|
|
4388
|
+
"settings.empty": "Keine Einstellungen",
|
|
4389
|
+
"settings.general": "Allgemein",
|
|
4390
|
+
"settings.shortcuts": "Tastenk\xFCrzel",
|
|
4391
|
+
"settings.appearance": "Darstellung",
|
|
4392
|
+
"settings.language": "Sprache",
|
|
4393
|
+
"settings.about": "Info",
|
|
4394
|
+
"settings.app": "Anwendung",
|
|
4395
|
+
"settings.plugins": "Erweiterungen",
|
|
4396
|
+
"settings.engine": "Engine",
|
|
4397
|
+
"settings.sidebar": "Seitenleiste",
|
|
4398
|
+
"settings.sidebarHint": "Reduziert die Seitenleiste auf Symbole. K\xFCrzel: Strg/Cmd+B.",
|
|
4399
|
+
"settings.compactSidebar": "Kompakte Seitenleiste",
|
|
4400
|
+
"plugins.title": "Erweiterungen",
|
|
4401
|
+
"plugins.installed": "Installiert",
|
|
4402
|
+
"plugins.discover": "Entdecken",
|
|
4403
|
+
"plugins.updates": "Updates",
|
|
4404
|
+
"plugins.search": "Erweiterungen suchen",
|
|
4405
|
+
"plugins.none": "Keine Erweiterungen installiert",
|
|
4406
|
+
"plugins.searching": "Kataloge werden durchsucht\u2026",
|
|
4407
|
+
"plugins.upToDate": "Alles ist aktuell.",
|
|
4408
|
+
"plugins.storeEmpty": "Keine Erweiterungen in Ihren Katalogen gefunden.",
|
|
4409
|
+
"plugins.installedTag": "Installiert",
|
|
4410
|
+
"plugins.get": "Laden",
|
|
4411
|
+
"plugins.update": "Aktualisieren",
|
|
4412
|
+
"plugins.open": "\xD6ffnen",
|
|
4413
|
+
"plugins.back": "Erweiterungen",
|
|
4414
|
+
"plugins.notFound": "Diese Erweiterung ist nicht installiert.",
|
|
4415
|
+
"plugins.enable": "Aktivieren",
|
|
4416
|
+
"plugins.enabled": "Aktiviert",
|
|
4417
|
+
"plugins.disabled": "Deaktiviert",
|
|
4418
|
+
"plugins.reload": "Neu laden",
|
|
4419
|
+
"plugins.uninstall": "Deinstallieren",
|
|
4420
|
+
"plugins.video": "Video",
|
|
4421
|
+
"plugins.about": "Beschreibung",
|
|
4422
|
+
"plugins.permissions": "Berechtigungen",
|
|
4423
|
+
"plugins.permissionsFooter": "Erweiterungen erhalten nur, was sie angeben. Sensible Zugriffe sind markiert.",
|
|
4424
|
+
"plugins.noPermissions": "Keine besonderen Zugriffe",
|
|
4425
|
+
"plugins.sensitive": "Sensibel",
|
|
4426
|
+
"plugins.information": "Informationen",
|
|
4427
|
+
"plugins.developer": "Entwickler",
|
|
4428
|
+
"plugins.version": "Version",
|
|
4429
|
+
"plugins.license": "Lizenz",
|
|
4430
|
+
"plugins.dependencies": "Ben\xF6tigt",
|
|
4431
|
+
"plugins.keywords": "Stichw\xF6rter",
|
|
4432
|
+
"plugins.settings": "Einstellungen",
|
|
4433
|
+
"plugins.saved": "Gespeichert",
|
|
4434
|
+
"plugins.reset": "Auf Standard zur\xFCcksetzen",
|
|
4435
|
+
"plugins.logs": "Protokoll",
|
|
4436
|
+
"plugins.noLogs": "Kein Protokoll",
|
|
4437
|
+
"plugins.installedVersion": "Installiert: {v}",
|
|
4438
|
+
"state.ready": "Aktiv",
|
|
4439
|
+
"state.error": "Fehler",
|
|
4440
|
+
"state.disabled": "Deaktiviert",
|
|
4441
|
+
"appearance.mode": "Modus",
|
|
4442
|
+
"appearance.theme": "Thema",
|
|
4443
|
+
"appearance.themeDefault": "Standard",
|
|
4444
|
+
"appearance.accent": "Akzentfarbe",
|
|
4445
|
+
"appearance.accentDefault": "Vom Thema",
|
|
4446
|
+
"appearance.text": "Text",
|
|
4447
|
+
"appearance.font": "Schrift",
|
|
4448
|
+
"appearance.customFont": "Eigene Schrift",
|
|
4449
|
+
"appearance.customFontHint": "Jede auf Ihrem Computer installierte Schrift",
|
|
4450
|
+
"appearance.fontSize": "Textgr\xF6\xDFe",
|
|
4451
|
+
"appearance.lineHeight": "Zeilenabstand",
|
|
4452
|
+
"appearance.preview": "Zw\xF6lf Boxk\xE4mpfer jagen Viktor quer \xFCber den gro\xDFen Sylter Deich.",
|
|
4453
|
+
"appearance.layout": "Layout",
|
|
4454
|
+
"appearance.density": "Abst\xE4nde",
|
|
4455
|
+
"appearance.comfortable": "Komfortabel",
|
|
4456
|
+
"appearance.compact": "Kompakt",
|
|
4457
|
+
"appearance.radius": "Ecken",
|
|
4458
|
+
"appearance.sharp": "Eckig",
|
|
4459
|
+
"appearance.default": "Standard",
|
|
4460
|
+
"appearance.round": "Rund",
|
|
4461
|
+
"appearance.translucency": "Transparenz",
|
|
4462
|
+
"appearance.motion": "Bewegung reduzieren",
|
|
4463
|
+
"appearance.reset": "Darstellung zur\xFCcksetzen",
|
|
4464
|
+
"language.follow": "Systemsprache verwenden"
|
|
4465
|
+
}
|
|
4466
|
+
};
|
|
4467
|
+
`,
|
|
4468
|
+
"plugins/language/src/renderer.tsx": 'import { definePlugin } from "fluxplugin";\nimport { Group, Icon, Row, useI18n } from "fluxplugin/react";\nimport { messages } from "./messages";\n\n// To add a language: translate src/messages.ts, then add it here.\nconst languages = [\n { code: "fr", label: "French", nativeLabel: "Fran\xE7ais" },\n { code: "es", label: "Spanish", nativeLabel: "Espa\xF1ol" },\n { code: "de", label: "German", nativeLabel: "Deutsch" },\n];\n\nfunction LanguageSection() {\n const { locale, setLocale, languages: all, t } = useI18n();\n return (\n <>\n <Group title={t("settings.language", "Language")}>\n {all.map((l) => (\n <Row key={l.code} data-testid={"lang-" + l.code} title={l.nativeLabel ?? l.label} subtitle={l.label} onClick={() => setLocale(l.code)} right={locale === l.code ? <Icon name="check" size={18} /> : undefined} />\n ))}\n </Group>\n <Group>\n <Row title={t("language.follow", "Follow the system language")} onClick={() => setLocale(navigator.language)} />\n </Group>\n </>\n );\n}\n\nexport default definePlugin({\n extensions: [\n { point: "settings", contribution: { sections: [{ id: "language", title: "Language", icon: "language", order: 20, component: LanguageSection }] } },\n { point: "i18n", contribution: { languages } },\n ...Object.entries(messages).map(([locale, m]) => ({ point: "i18n", contribution: { messages: [{ locale, messages: m }] } })),\n ],\n});\n',
|
|
4469
|
+
"src/app/main/framework.ts": 'import { MainFramework, type StoreSpec } from "fluxplugin/main";\nimport config from "../../../fluxplugin.config.json"; // bundled into the app at build time\nimport { CHANNELS } from "../shared/constants";\nimport { registerOperations } from "./operations";\n\nexport interface FrameworkOptions {\n pluginDirs: string[];\n /** Where "Get" installs plugins (default: the last of pluginDirs). */\n installDir?: string;\n dataDir: string;\n dev?: boolean;\n assetUrl?: (id: string, rel: string) => string;\n}\n\n/** Creates the plugin framework. Shared by the Electron entry (index.ts) and the browser entry (web.ts). */\nexport function createFramework(opts: FrameworkOptions): MainFramework {\n const fw = new MainFramework({\n pluginDirs: opts.pluginDirs,\n installDir: opts.installDir ?? opts.pluginDirs[opts.pluginDirs.length - 1],\n dataDir: opts.dataDir,\n hotReload: opts.dev,\n assetUrl: opts.assetUrl,\n // Where "Plugins \u2192 Available" looks. Default: GitHub (every repo that published with `fluxplugin publish`).\n // Edit "stores" in fluxplugin.config.json to add your own store (github catalog, http, local) \u2014 see docs/STORE.md.\n stores: config.stores as StoreSpec[],\n requireApproval: false, // true \u2192 users must approve each plugin\'s permissions\n allowedRendererChannels: [CHANNELS.createItem, CHANNELS.setTitleBarOverlay],\n });\n registerOperations(fw);\n return fw;\n}\n',
|
|
4470
|
+
"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, Menu, protocol, webContents } from "electron";\nimport path from "node:path";\nimport { PLUGIN_SCHEME, attachElectron, registerPluginProtocol } from "fluxplugin/main";\nimport { APP_NAME } from "../shared/constants";\nimport { createFramework } from "./framework";\nimport { pluginPaths, projectRoot } from "./paths";\nimport { createMainWindow } from "./window";\nimport { registerWindowApi } from "./window-api";\n\n// Own data folder per app. In dev Electron is started as `electron dist/main.cjs` and would otherwise be called "Electron",\n// sharing plugin state (enabled/disabled, permissions, settings) with every other app run that way.\napp.setName(APP_NAME);\n\nprotocol.registerSchemesAsPrivileged([PLUGIN_SCHEME]); // must run before the app is ready\n\napp.whenReady().then(async () => {\n const dev = !!process.env["FLUXPLUGIN_DEV"];\n // The menu bar is drawn by the renderer (<TitleBar />). Windows/Linux: no native menu. macOS keeps a minimal one\n // so the system shortcuts (copy/paste/quit/hide) keep working.\n Menu.setApplicationMenu(process.platform === "darwin" ? Menu.buildFromTemplate([{ role: "appMenu" }, { role: "editMenu" }, { role: "windowMenu" }]) : null);\n\n const paths = pluginPaths(projectRoot(__dirname), app.getPath("userData"), app.isPackaged, process.resourcesPath);\n const fw = createFramework({\n pluginDirs: [paths.bundled, paths.user],\n installDir: paths.user,\n dataDir: path.join(app.getPath("userData"), "plugins-data"),\n dev,\n });\n registerPluginProtocol(fw, protocol);\n attachElectron(fw, { ipcMain: ipcMain as never, webContents: webContents as never });\n await fw.start();\n\n const win = createMainWindow(__dirname);\n registerWindowApi(fw, win);\n if (dev) win.webContents.on("before-input-event", (_e, input) => input.key === "F12" && input.type === "keyDown" && win.webContents.toggleDevTools());\n app.on("before-quit", () => void fw.stop());\n});\n\napp.on("window-all-closed", () => app.quit());\n',
|
|
1319
4471
|
"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',
|
|
1320
|
-
"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
|
|
4472
|
+
"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\nexport interface PluginPaths {\n /** Plugins shipped with the app: <project>/plugins in dev, <resources>/plugins in the packaged app (read-only). */\n bundled: string;\n /** Plugins installed by the user (writable): inside the app\'s data folder, removed with it when the app is uninstalled. */\n user: string;\n}\n\nexport function pluginPaths(root: string, userData: string, packaged: boolean, resourcesPath: string): PluginPaths {\n return { bundled: packaged ? path.join(resourcesPath, "plugins") : path.join(root, "plugins"), user: path.join(userData, "plugins") };\n}\n',
|
|
1321
4473
|
"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',
|
|
1322
4474
|
"src/app/main/window-api.ts": 'import type { BrowserWindow } from "electron";\nimport { s } from "fluxplugin";\nimport type { MainFramework } from "fluxplugin/main";\nimport { CHANNELS, TITLEBAR_HEIGHT } from "../shared/constants";\n\n/** Host operations that need the BrowserWindow. Add your own (maximize, always-on-top, \u2026) the same way. */\nexport function registerWindowApi(fw: MainFramework, win: BrowserWindow): void {\n fw.manager.ipc.handle(\n CHANNELS.setTitleBarOverlay,\n (i: { color: string; symbolColor: string }) => {\n // Only Windows/Linux have the native-controls overlay; on macOS the traffic lights need no colouring.\n if (process.platform !== "darwin" && !win.isDestroyed()) {\n try {\n win.setTitleBarOverlay({ color: i.color, symbolColor: i.symbolColor, height: TITLEBAR_HEIGHT });\n } catch {\n /* colour not parseable: keep the previous one */\n }\n }\n return true;\n },\n "host",\n { input: s.object({ color: s.string(), symbolColor: s.string() }) },\n );\n}\n',
|
|
1323
4475
|
"src/app/main/window.ts": `import { BrowserWindow, shell } from "electron";
|
|
@@ -1359,7 +4511,7 @@ export function createMainWindow(distDir: string): BrowserWindow {
|
|
|
1359
4511
|
`,
|
|
1360
4512
|
"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',
|
|
1361
4513
|
"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',
|
|
1362
|
-
"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, declareHostPoints, installSharedModules } from "fluxplugin/react";\nimport { declareExtensionPoints } from "./extension-points";\nimport { registerHostMenus } from "./menus";\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
|
|
4514
|
+
"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, createStaticBridge, declareHostPoints, installSharedModules } from "fluxplugin/react";\nimport { declareExtensionPoints } from "./extension-points";\nimport { registerHostMenus } from "./menus";\nimport { registerHostSettings } from "./settings";\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 const bridge = await pickBridge();\n const framework = new RendererFramework({ bridge });\n\n declareHostPoints(framework); // built-in "theme" and "titlebar" (search providers) extension points\n declareExtensionPoints(framework); // BEFORE plugins start\n registerHostRoutes(framework);\n registerHostMenus(framework);\n registerHostSettings(framework);\n await framework.start();\n return { framework, bridge };\n}\n\n/** Electron: the preload bridge. Browser dev mode: HTTP bridge to `fluxplugin dev --web`. Static website export: bootstrap.json only. */\nasync function pickBridge(): Promise<FluxBridge> {\n const injected = (window as unknown as { __fluxplugin?: FluxBridge }).__fluxplugin;\n if (injected) return injected;\n try {\n const r = await fetch("/flux/bootstrap");\n if (r.ok && (r.headers.get("content-type") ?? "").includes("json")) return createHttpBridge();\n } catch {\n /* no server behind this page */\n }\n return createStaticBridge("./bootstrap.json");\n}\n',
|
|
1363
4515
|
"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',
|
|
1364
4516
|
"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',
|
|
1365
4517
|
"src/app/renderer/index.html": `<!doctype html>
|
|
@@ -1367,7 +4519,7 @@ export function createMainWindow(distDir: string): BrowserWindow {
|
|
|
1367
4519
|
<head>
|
|
1368
4520
|
<meta charset="utf-8" />
|
|
1369
4521
|
<title>__APP_NAME__</title>
|
|
1370
|
-
<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: fluxplugin:; frame-src https://www.youtube-nocookie.com https://player.vimeo.com" />
|
|
4522
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self' fluxplugin:; script-src 'self' fluxplugin:; style-src 'self' 'unsafe-inline' fluxplugin:; font-src 'self' data: https: fluxplugin:; connect-src 'self'; img-src 'self' data: blob: https: http: file: fluxplugin:; media-src 'self' blob: data: https: file: fluxplugin:; frame-src https://www.youtube-nocookie.com https://player.vimeo.com https://www.dailymotion.com https://www.loom.com https://streamable.com" />
|
|
1371
4523
|
<link rel="stylesheet" href="./renderer.css" />
|
|
1372
4524
|
</head>
|
|
1373
4525
|
<body>
|
|
@@ -1389,14 +4541,18 @@ export function registerHostMenus(framework: RendererFramework): void {
|
|
|
1389
4541
|
commands.register({ id: "host.reload", title: "Reload window", category: "View", keybinding: "Mod+R", execute: () => location.reload() }, "host");
|
|
1390
4542
|
commands.register({ id: "host.goHome", title: "Go to dashboard", category: "Go", execute: () => (location.hash = "#/") }, "host");
|
|
1391
4543
|
commands.register({ id: "host.goPlugins", title: "Manage plugins", category: "Go", execute: () => (location.hash = "#/plugins") }, "host");
|
|
4544
|
+
commands.register({ id: "host.sidebar", title: "Toggle sidebar", category: "View", keybinding: "Mod+B", execute: () => window.dispatchEvent(new Event("flux:toggle-sidebar")) }, "host");
|
|
4545
|
+
commands.register({ id: "host.goSettings", title: "Settings", category: "Go", keybinding: "Mod+,", execute: () => (location.hash = "#/settings") }, "host");
|
|
1392
4546
|
commands.register({ id: "host.docs", title: "Documentation", category: "Help", execute: () => void window.open("https://www.npmjs.com/package/fluxplugin", "_blank") }, "host");
|
|
1393
4547
|
|
|
1394
4548
|
menus.register({ id: "app.go", location: "application", label: "Go", order: 10, children: [
|
|
1395
4549
|
{ id: "app.go.home", label: "Dashboard", command: "host.goHome" },
|
|
1396
4550
|
{ id: "app.go.plugins", label: "Plugins", command: "host.goPlugins" },
|
|
4551
|
+
{ id: "app.go.settings", label: "Settings", command: "host.goSettings" },
|
|
1397
4552
|
] }, "host");
|
|
1398
4553
|
menus.register({ id: "app.view", location: "application", label: "View", order: 20, children: [
|
|
1399
4554
|
{ id: "app.view.palette", label: "Command palette", command: "host.palette" },
|
|
4555
|
+
{ id: "app.view.sidebar", label: "Toggle sidebar", command: "host.sidebar" },
|
|
1400
4556
|
{ id: "app.view.sep", label: "", separator: true },
|
|
1401
4557
|
{ id: "app.view.reload", label: "Reload", command: "host.reload" },
|
|
1402
4558
|
] }, "host");
|
|
@@ -1404,23 +4560,28 @@ export function registerHostMenus(framework: RendererFramework): void {
|
|
|
1404
4560
|
}
|
|
1405
4561
|
`,
|
|
1406
4562
|
"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 <div className="row">\n <button className="btn" data-testid="notify" onClick={() => framework.manager.notifications.push({ title: "Hello from the host", body: "Plugins can do this too.", level: "success" })}>Show a notification</button>\n </div>\n <pre data-testid="hook-result" className="hint">{result}</pre>\n <PluginSlot name={SLOTS.dashboardAfter} />\n </div>\n );\n}\n',
|
|
1407
|
-
"src/app/renderer/
|
|
1408
|
-
"src/app/renderer/
|
|
1409
|
-
|
|
4563
|
+
"src/app/renderer/providers/AppProviders.tsx": 'import { createContext, useCallback, useContext, type ReactNode } from "react";\nimport type { FluxBridge } from "fluxplugin";\nimport { I18nProvider, PluginProvider, SidebarProvider, StyleHost, ThemeProvider, ToastHost, baseCss, type RendererFramework } from "fluxplugin/react";\nimport { setTitleBarOverlay } from "../services/window";\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 // Keep the native window buttons in sync with the theme (light / dark / plugin themes).\n const onResolved = useCallback((r: { surface: string; fg: string }) => r.surface && setTitleBarOverlay(bridge, r.surface, r.fg), [bridge]);\n return (\n <PluginProvider framework={framework}>\n <BridgeContext.Provider value={bridge}>\n <style>{baseCss}</style>\n <StyleHost />\n <I18nProvider>\n <ThemeProvider onResolved={onResolved}>\n <SidebarProvider>\n {children}\n <ToastHost />\n </SidebarProvider>\n </ThemeProvider>\n </I18nProvider>\n </BridgeContext.Provider>\n </PluginProvider>\n );\n}\n',
|
|
4564
|
+
"src/app/renderer/routes.ts": `import { SettingsPage, type RendererFramework } from "fluxplugin/react";
|
|
4565
|
+
import { registerPluginManagerRoutes } from "fluxplugin/manager-ui";
|
|
1410
4566
|
import { DashboardPage } from "./pages/DashboardPage";
|
|
1411
|
-
import { PluginsPage } from "./pages/PluginsPage";
|
|
1412
4567
|
|
|
1413
4568
|
/** The host's own pages (owner "host"). Plugins add theirs at runtime through the same registry. */
|
|
1414
4569
|
export function registerHostRoutes(framework: RendererFramework): void {
|
|
1415
|
-
framework.manager.routes
|
|
1416
|
-
|
|
4570
|
+
const routes = framework.manager.routes;
|
|
4571
|
+
routes.register({ path: "/", component: DashboardPage, meta: { title: "Dashboard" } }, "host");
|
|
4572
|
+
// Plugin manager: list \u2192 /plugins/:id (installed) \u2192 /plugins/store/:id (store listing). Each opens in its own screen.
|
|
4573
|
+
registerPluginManagerRoutes(framework);
|
|
4574
|
+
// Settings: sections come from the host and from plugins (Appearance, Language\u2026).
|
|
4575
|
+
routes.register({ path: "/settings", component: SettingsPage, meta: { title: "Settings", breadcrumb: "Settings" } }, "host");
|
|
4576
|
+
routes.register({ path: "/settings/:section", component: SettingsPage, meta: { breadcrumb: (p) => p["section"] ?? "" } }, "host");
|
|
1417
4577
|
}
|
|
1418
4578
|
`,
|
|
1419
4579
|
"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',
|
|
1420
4580
|
"src/app/renderer/services/window.ts": 'import type { FluxBridge } from "fluxplugin";\nimport { CHANNELS } from "../../shared/constants";\n\n/** Colours the native window buttons (Windows/Linux). A no-op error in browser mode is ignored on purpose. */\nexport function setTitleBarOverlay(bridge: FluxBridge, color: string, symbolColor: string): void {\n void bridge.invoke(CHANNELS.setTitleBarOverlay, { color, symbolColor }).catch(() => undefined);\n}\n',
|
|
1421
|
-
"src/app/renderer/
|
|
1422
|
-
"src/app/renderer/shell/
|
|
1423
|
-
"src/app/renderer/
|
|
4581
|
+
"src/app/renderer/settings/index.tsx": 'import type { ComponentType } from "react";\nimport { Group, Row, Switch, useSidebar, useT, usePluginFramework, usePlugins, formatShortcut, useOS, type RendererFramework } from "fluxplugin/react";\nimport { APP_NAME } from "../../shared/constants";\n\nfunction GeneralSection() {\n const t = useT();\n const { collapsed, setCollapsed } = useSidebar();\n const plugins = usePlugins();\n return (\n <>\n <Group title={t("settings.about", "About")}>\n <Row title={t("settings.app", "Application")} right={APP_NAME} />\n <Row title={t("settings.plugins", "Plugins")} right={`${plugins.filter((p) => p.state === "ready").length} / ${plugins.length}`} />\n <Row title={t("settings.engine", "Engine")} right={navigator.userAgent.match(/(Electron|Chrome|Firefox|Safari)\\/[\\d.]+/)?.[0] ?? "\u2014"} />\n </Group>\n <Group title={t("settings.sidebar", "Sidebar")} footer={t("settings.sidebarHint", "Collapse the sidebar to icons. Shortcut: Ctrl/Cmd+B.")}>\n <Row title={t("settings.compactSidebar", "Compact sidebar")} right={<Switch checked={collapsed} onChange={setCollapsed} label="Compact sidebar" />} />\n </Group>\n </>\n );\n}\n\nfunction ShortcutsSection() {\n const fw = usePluginFramework();\n const os = useOS();\n const t = useT();\n const cmds = fw.manager.commands.list().filter((c) => c.keybinding);\n return (\n <Group title={t("settings.shortcuts", "Keyboard shortcuts")}>\n {cmds.length === 0 && <Row title="\u2014" />}\n {cmds.map((c) => <Row key={c.id} title={c.title} subtitle={c.category} right={<kbd>{formatShortcut(c.keybinding!, os)}</kbd>} />)}\n </Group>\n );\n}\n\n/** Host settings sections. Plugins contribute theirs to the same "settings" extension point. */\nexport function registerHostSettings(framework: RendererFramework): void {\n framework.manager.extensionPoints.contribute(\n "settings",\n { sections: [\n { id: "general", title: "General", icon: "settings", order: 0, component: GeneralSection as ComponentType },\n { id: "shortcuts", title: "Shortcuts", icon: "command", order: 90, component: ShortcutsSection as ComponentType },\n ] },\n "host",\n );\n}\n',
|
|
4582
|
+
"src/app/renderer/shell/AppShell.tsx": 'import { Breadcrumbs, CommandPalette, PluginRoutes, PluginSlot, TitleBar, useKeybindings } from "fluxplugin/react";\nimport { APP_NAME, SLOTS } from "../../shared/constants";\nimport { Sidebar } from "./Sidebar";\n\n/** App shell with a fixed sidebar and independently scrollable content. */\nexport function AppShell() {\n useKeybindings();\n\n return (\n <div className="app">\n {/* Title bar */}\n <header className="app-titlebar">\n <TitleBar title={APP_NAME} logo={<PluginSlot name={SLOTS.brandBadges} />} searchPlaceholder="Search commands, pages\u2026" />\n </header>\n\n {/* Application body */}\n <div className="body">\n {/* Fixed sidebar: never scrolls with the main content (collapses to an icon rail) */}\n <aside className="app-side">\n <Sidebar />\n </aside>\n\n {/* Only this area scrolls */}\n <main className="main">\n <div className="main-inner">\n <Breadcrumbs />\n <PluginRoutes />\n </div>\n </main>\n </div>\n\n {/* Command palette */}\n <CommandPalette />\n </div>\n );\n}\n',
|
|
4583
|
+
"src/app/renderer/shell/Sidebar.tsx": 'import { SidebarNav, type SidebarEntry } from "fluxplugin/react";\n\n// Host entries. Plugins add theirs between the two groups (routes with meta.menu, or menus.register({ location: "sidebar" })),\n// including sub-menus: `meta.menu.parent` or `children`. Labels are translated through `menu.<id>`.\nconst top: SidebarEntry[] = [{ id: "dashboard", label: "Dashboard", path: "/", icon: "home" }];\nconst bottom: SidebarEntry[] = [\n { id: "plugins", label: "Plugins", path: "/plugins", icon: "plug" },\n { id: "settings", label: "Settings", path: "/settings", icon: "settings" },\n];\n\nexport function Sidebar() {\n return <SidebarNav top={top} bottom={bottom} footer={<span>Ctrl/Cmd+K: commands</span>} />;\n}\n',
|
|
4584
|
+
"src/app/renderer/styles/app.css": "body { margin: 0; }\n.app { display: grid; height: 100vh; grid-template-rows: 40px minmax(0, 1fr); overflow: hidden; }\n.app-titlebar { z-index: 50; min-width: 0; }\n.body { display: grid; min-height: 0; min-width: 0; grid-template-columns: var(--flux-sidebar-w, 232px) minmax(0, 1fr); overflow: hidden; transition: grid-template-columns .18s ease; }\n.app-side { height: 100%; min-height: 0; overflow: hidden; }\n.main { min-height: 0; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 24px 28px; }\n.main-inner { max-width: 900px; margin: 0 auto; }\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 14px; cursor: pointer; font-weight: 500; }\n.btn:hover { background: var(--flux-surface); }\n.hint { color: var(--flux-muted); font-size: .86em; }\n",
|
|
1424
4585
|
"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/** Height of the custom title bar in px (must match --flux-titlebar-h in CSS). */\nexport const TITLEBAR_HEIGHT = 40;\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 /** Colours the native window buttons (Windows/Linux overlay) to match the current theme. */\n setTitleBarOverlay: "host.window.setTitleBarOverlay",\n} as const;\n\n/** Declarative extension points the host offers to plugins (see renderer/extension-points.ts).\n * The built-in "theme" and "titlebar" points are declared by declareHostPoints(). */\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 brandBadges: "header.badges",\n titlebarLeft: "titlebar.left",\n titlebarRight: "titlebar.right",\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',
|
|
1425
4586
|
"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'
|
|
1426
4587
|
};
|
|
@@ -1430,7 +4591,7 @@ var HOST_TAILWIND_FILES = {
|
|
|
1430
4591
|
<head>
|
|
1431
4592
|
<meta charset="utf-8" />
|
|
1432
4593
|
<title>__APP_NAME__</title>
|
|
1433
|
-
<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: fluxplugin:; frame-src https://www.youtube-nocookie.com https://player.vimeo.com" />
|
|
4594
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self' fluxplugin:; script-src 'self' fluxplugin:; style-src 'self' 'unsafe-inline' fluxplugin:; font-src 'self' data: https: fluxplugin:; connect-src 'self'; img-src 'self' data: blob: https: http: file: fluxplugin:; media-src 'self' blob: data: https: file: fluxplugin:; frame-src https://www.youtube-nocookie.com https://player.vimeo.com https://www.dailymotion.com https://www.loom.com https://streamable.com" />
|
|
1434
4595
|
<link rel="stylesheet" href="./renderer.css" />
|
|
1435
4596
|
<link rel="stylesheet" href="./tailwind.css" />
|
|
1436
4597
|
</head>
|
|
@@ -1441,9 +4602,8 @@ var HOST_TAILWIND_FILES = {
|
|
|
1441
4602
|
</html>
|
|
1442
4603
|
`,
|
|
1443
4604
|
"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="text-xs text-muted">These buttons are contributed by plugins through a host-declared extension point.</p>\n <div className="my-3 flex flex-wrap gap-2" data-testid="toolbar">\n {actions.map((a) => (\n <button key={a.id} className="cursor-pointer rounded-lg border border-border bg-bg px-3 py-1.5 hover:bg-surface" onClick={() => a.command && void framework.manager.commands.execute(a.command)}>\n {a.label}\n </button>\n ))}\n </div>\n <div className="my-3 flex flex-wrap gap-2">\n <button className="cursor-pointer rounded-lg border border-border bg-bg px-3 py-1.5 hover:bg-surface" onClick={() => void create(" Ada ")}>Create item \u201C Ada \u201D (plugin hooks may rewrite it)</button>\n <button className="cursor-pointer rounded-lg border border-border bg-bg px-3 py-1.5 hover:bg-surface" onClick={() => void create(" ")}>Create empty item (a plugin may veto)</button>\n </div>\n <div className="my-3 flex flex-wrap gap-2">\n <button className="cursor-pointer rounded-lg border border-border bg-bg px-3 py-1.5 hover:bg-surface" data-testid="notify" onClick={() => framework.manager.notifications.push({ title: "Hello from the host", body: "Plugins can do this too.", level: "success" })}>Show a notification</button>\n </div>\n <pre data-testid="hook-result" className="text-xs text-muted">{result}</pre>\n <PluginSlot name={SLOTS.dashboardAfter} />\n </div>\n );\n}\n',
|
|
1444
|
-
"src/app/renderer/shell/AppShell.tsx": 'import {\n Breadcrumbs,\n CommandPalette,\n PluginRoutes,\n PluginSlot,\n TitleBar,\n useKeybindings,\n} from "fluxplugin/react";\n\nimport { APP_NAME, SLOTS } from "../../shared/constants";\nimport { Sidebar } from "./Sidebar";\n\n/** App shell with a fixed sidebar and independently scrollable content. */\nexport function AppShell() {\n useKeybindings();\n\n return (\n <div className="app grid h-screen grid-rows-[40px_minmax(0,1fr)] overflow-hidden">\n {/* Title bar */}\n <header className="z-50 min-w-0">\n <TitleBar\n title={APP_NAME}\n logo={<PluginSlot name={SLOTS.brandBadges} />}\n searchPlaceholder="Search commands, pages
|
|
1445
|
-
"src/app/renderer/
|
|
1446
|
-
"src/app/renderer/styles/app.css": "body { margin: 0; font: 14px system-ui; background: var(--flux-bg); color: var(--flux-fg); }\n",
|
|
4605
|
+
"src/app/renderer/shell/AppShell.tsx": 'import {\n Breadcrumbs,\n CommandPalette,\n PluginRoutes,\n PluginSlot,\n TitleBar,\n useKeybindings,\n} from "fluxplugin/react";\n\nimport { APP_NAME, SLOTS } from "../../shared/constants";\nimport { Sidebar } from "./Sidebar";\n\n/** App shell with a fixed sidebar and independently scrollable content. */\nexport function AppShell() {\n useKeybindings();\n\n return (\n <div className="app grid h-screen grid-rows-[40px_minmax(0,1fr)] overflow-hidden">\n {/* Title bar */}\n <header className="z-50 min-w-0">\n <TitleBar\n title={APP_NAME}\n logo={<PluginSlot name={SLOTS.brandBadges} />}\n searchPlaceholder="Search commands, pages..."\n />\n </header>\n\n {/* Application body (the sidebar width follows --flux-sidebar-w: it collapses to an icon rail) */}\n <div className="body grid min-h-0 min-w-0 grid-cols-[var(--flux-sidebar-w,232px)_minmax(0,1fr)] overflow-hidden transition-[grid-template-columns] duration-200">\n {/* Fixed sidebar: never scrolls with the main content */}\n <aside className="h-full min-h-0 overflow-hidden border-r border-transparent">\n <Sidebar />\n </aside>\n\n {/* Only this area scrolls */}\n <main className="main min-h-0 min-w-0 overflow-y-auto overflow-x-hidden p-6">\n <div className="mx-auto max-w-3xl">\n <Breadcrumbs />\n\n <PluginRoutes />\n </div>\n </main>\n </div>\n\n {/* Command palette */}\n <CommandPalette />\n </div>\n );\n}\n',
|
|
4606
|
+
"src/app/renderer/styles/app.css": "body { margin: 0; }\n",
|
|
1447
4607
|
"src/app/renderer/styles/tailwind.css": '/* Tailwind v4, compiled by `fluxplugin dev|build` to dist/tailwind.css.\n Colors come from the theme variables, so Tailwind utilities follow light / dark / plugin themes automatically:\n use bg-surface, text-fg, border-border, text-muted, bg-accent \u2026 */\n@import "tailwindcss";\n\n/* Plugins are shipped as built JS: scan their dist so the classes they use are generated. Plugins that users install later\n should ship their own precompiled CSS (fluxplugin plugin new my-plugin --tailwindcss). */\n@source "../../../../plugins/*/dist";\n\n@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));\n\n@theme inline {\n --color-bg: var(--flux-bg);\n --color-fg: var(--flux-fg);\n --color-surface: var(--flux-surface);\n --color-border: var(--flux-border);\n --color-accent: var(--flux-accent);\n --color-muted: var(--flux-muted);\n}\n'
|
|
1448
4608
|
};
|
|
1449
4609
|
|
|
@@ -1454,7 +4614,16 @@ var pkg = (v) => JSON.stringify(
|
|
|
1454
4614
|
version: "1.0.0",
|
|
1455
4615
|
private: true,
|
|
1456
4616
|
type: "module",
|
|
1457
|
-
scripts: {
|
|
4617
|
+
scripts: {
|
|
4618
|
+
dev: "fluxplugin dev",
|
|
4619
|
+
"dev:host": "fluxplugin dev --host ../my-app # run it inside your app while you work (edit the path)",
|
|
4620
|
+
build: "fluxplugin build",
|
|
4621
|
+
package: "fluxplugin package",
|
|
4622
|
+
validate: "fluxplugin validate",
|
|
4623
|
+
release: "fluxplugin publish"
|
|
4624
|
+
},
|
|
4625
|
+
// Add any library here (react-markdown, chart.js, a UI kit…): it is bundled into dist/, the host app does not need it.
|
|
4626
|
+
dependencies: {},
|
|
1458
4627
|
devDependencies: { fluxplugin: "^1.0.0", "@types/react": "^19.0.0", react: "^19.0.0", typescript: "^5.6.0" }
|
|
1459
4628
|
},
|
|
1460
4629
|
null,
|
|
@@ -1722,6 +4891,28 @@ function withTailwind(files, v) {
|
|
|
1722
4891
|
files["src/renderer.tsx"] = files["src/renderer.tsx"].split(`className="${v.slug}-card"`).join('className="rounded-xl border border-border bg-surface p-4"');
|
|
1723
4892
|
return files;
|
|
1724
4893
|
}
|
|
4894
|
+
function standaloneExtras(v) {
|
|
4895
|
+
return {
|
|
4896
|
+
".gitignore": "node_modules\ndist\n*.tgz\n.DS_Store\n",
|
|
4897
|
+
"CONTRIBUTING.md": `# Developing ${v.name}
|
|
4898
|
+
|
|
4899
|
+
This is a normal React + TypeScript project. It has its own \`package.json\` and its own libraries.
|
|
4900
|
+
|
|
4901
|
+
\`\`\`bash
|
|
4902
|
+
npm install # your libraries + fluxplugin tooling
|
|
4903
|
+
npm install chart.js # add anything you need: it is bundled into dist/, the app never needs it
|
|
4904
|
+
npm run dev # rebuilds on save
|
|
4905
|
+
npm run dev:host # same, and copies the result into a host app (edit the path) which hot-reloads it
|
|
4906
|
+
npm run build # dist/ \u2014 this is all that ships (no src/, no .ts, no sourcemaps)
|
|
4907
|
+
npm run package # ${v.id}-1.0.0.tgz
|
|
4908
|
+
npm run release # publish to the plugin store (GitHub)
|
|
4909
|
+
\`\`\`
|
|
4910
|
+
|
|
4911
|
+
**Shared with the host** (never bundled twice): \`react\`, \`react-dom\`, \`fluxplugin\`, \`fluxplugin/react\`. Everything else you import is yours.
|
|
4912
|
+
CSS imported in the renderer (including from libraries) is emitted as \`dist/renderer.css\` and loaded automatically; images and fonts are inlined.
|
|
4913
|
+
`
|
|
4914
|
+
};
|
|
4915
|
+
}
|
|
1725
4916
|
|
|
1726
4917
|
// src/cli/host-templates.ts
|
|
1727
4918
|
function hostFiles(v, withSample = true) {
|
|
@@ -1733,16 +4924,21 @@ function hostFiles(v, withSample = true) {
|
|
|
1733
4924
|
private: true,
|
|
1734
4925
|
type: "module",
|
|
1735
4926
|
main: "dist/main.cjs",
|
|
4927
|
+
description: `${v.name} \u2014 built with fluxplugin`,
|
|
4928
|
+
author: "Your name",
|
|
1736
4929
|
scripts: {
|
|
1737
4930
|
dev: "fluxplugin dev",
|
|
1738
4931
|
"dev:web": "fluxplugin dev --web",
|
|
1739
4932
|
build: "fluxplugin build",
|
|
1740
4933
|
start: "fluxplugin start",
|
|
4934
|
+
dist: "fluxplugin dist",
|
|
4935
|
+
"export:web": "fluxplugin export",
|
|
4936
|
+
"plugins:package": "fluxplugin package --all",
|
|
1741
4937
|
"plugin:new": "fluxplugin plugin new",
|
|
1742
4938
|
typecheck: "tsc --noEmit"
|
|
1743
4939
|
},
|
|
1744
4940
|
dependencies: { fluxplugin: v.fluxSpec, react: "^19.0.0", "react-dom": "^19.0.0" },
|
|
1745
|
-
devDependencies: { electron: v.electron ?? "^44.5.1", typescript: "~5.9.3", "@types/node": "^22.10.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", ...v.tailwind ? { tailwindcss: "^4.0.0", "@tailwindcss/cli": "^4.0.0" } : {} },
|
|
4941
|
+
devDependencies: { electron: v.electron ?? "^44.5.1", "electron-builder": "^26.0.0", typescript: "~5.9.3", "@types/node": "^22.10.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", ...v.tailwind ? { tailwindcss: "^4.0.0", "@tailwindcss/cli": "^4.0.0" } : {} },
|
|
1746
4942
|
// npm 11.16+/12 and pnpm 10 block dependency install scripts unless allowed; Electron needs its postinstall to download the binary.
|
|
1747
4943
|
allowScripts: { electron: true, esbuild: true },
|
|
1748
4944
|
pnpm: { onlyBuiltDependencies: ["electron", "esbuild"] }
|
|
@@ -1751,7 +4947,7 @@ function hostFiles(v, withSample = true) {
|
|
|
1751
4947
|
2
|
|
1752
4948
|
),
|
|
1753
4949
|
// "stores" is used by the CLI (fluxplugin search/install) AND by your app (Plugins → Available). Add your own store here.
|
|
1754
|
-
"fluxplugin.config.json": JSON.stringify({ pluginsDir: "plugins", outDir: "dist", stores: [{ type: "github", topic: "fluxplugin-plugin" }] }, null, 2),
|
|
4950
|
+
"fluxplugin.config.json": JSON.stringify({ pluginsDir: "plugins", outDir: "dist", stores: v.stores ?? [{ type: "github", topic: "fluxplugin-plugin" }] }, null, 2),
|
|
1755
4951
|
"tsconfig.json": JSON.stringify(
|
|
1756
4952
|
{ compilerOptions: { target: "ES2022", module: "ESNext", moduleResolution: "Bundler", jsx: "react-jsx", strict: true, noEmit: true, skipLibCheck: true, resolveJsonModule: true, lib: ["ES2022", "DOM", "DOM.Iterable"], types: ["node"] }, include: ["src"] },
|
|
1757
4953
|
null,
|
|
@@ -1760,7 +4956,7 @@ function hostFiles(v, withSample = true) {
|
|
|
1760
4956
|
".gitignore": "node_modules\ndist\n.flux-data\n*.tgz\n",
|
|
1761
4957
|
"README.md": readme2(v.name)
|
|
1762
4958
|
};
|
|
1763
|
-
for (const [rel, content] of Object.entries(HOST_APP_FILES)) files[rel] = content.split("__APP_NAME__").join(v.name);
|
|
4959
|
+
for (const [rel, content] of Object.entries(HOST_APP_FILES)) files[rel] = content.split("__APP_NAME__").join(v.name).split("__APP_ID__").join(v.name.replace(/[^a-z0-9]/gi, ""));
|
|
1764
4960
|
if (v.tailwind) for (const [rel, content] of Object.entries(HOST_TAILWIND_FILES)) files[rel] = content.split("__APP_NAME__").join(v.name);
|
|
1765
4961
|
if (withSample) {
|
|
1766
4962
|
const sample = TEMPLATES.dashboard(varsFromName("hello"));
|
|
@@ -1803,29 +4999,29 @@ plugins/ plugins loaded at startup (users can also install .tgz from the P
|
|
|
1803
4999
|
|
|
1804
5000
|
// src/cli/package.ts
|
|
1805
5001
|
import crypto4 from "node:crypto";
|
|
1806
|
-
import { promises as
|
|
5002
|
+
import { promises as fs9 } from "node:fs";
|
|
1807
5003
|
import os from "node:os";
|
|
1808
|
-
import
|
|
5004
|
+
import path13 from "node:path";
|
|
1809
5005
|
async function packagePlugin(root, opts = {}) {
|
|
1810
5006
|
const log2 = opts.log ?? console.log;
|
|
1811
5007
|
await buildPlugin(root, false, log2);
|
|
1812
|
-
let raw = JSON.parse(await
|
|
5008
|
+
let raw = JSON.parse(await fs9.readFile(path13.join(root, "plugin.json"), "utf8"));
|
|
1813
5009
|
const v = validateManifest(raw);
|
|
1814
5010
|
if (!v.manifest) throw new Error("invalid plugin.json: " + v.issues.map((i) => `${i.path}: ${i.message}`).join("; "));
|
|
1815
5011
|
const m = v.manifest;
|
|
1816
5012
|
raw["integrity"] = await computeIntegrity(root, m);
|
|
1817
|
-
if (opts.signKey) raw = signManifest(raw, (await
|
|
1818
|
-
const stage = await
|
|
5013
|
+
if (opts.signKey) raw = signManifest(raw, (await fs9.readFile(opts.signKey, "utf8")).trim());
|
|
5014
|
+
const stage = await fs9.mkdtemp(path13.join(os.tmpdir(), "flux-pack-"));
|
|
1819
5015
|
try {
|
|
1820
5016
|
const shipped = await copyPluginRuntime(root, stage);
|
|
1821
|
-
await
|
|
5017
|
+
await fs9.writeFile(path13.join(stage, "plugin.json"), JSON.stringify(raw, null, 2));
|
|
1822
5018
|
const bad = shipped.filter((f) => /\.(map|tsx?)$/i.test(f) || f.startsWith("src/"));
|
|
1823
5019
|
if (bad.length) throw new Error(`refusing to package sources/sourcemaps: ${bad.join(", ")}`);
|
|
1824
5020
|
const buf = await createTarGz(stage);
|
|
1825
|
-
const out =
|
|
1826
|
-
await
|
|
1827
|
-
const file =
|
|
1828
|
-
await
|
|
5021
|
+
const out = path13.resolve(opts.out ?? ".");
|
|
5022
|
+
await fs9.mkdir(out, { recursive: true });
|
|
5023
|
+
const file = path13.join(out, `${m.id}-${m.version}.tgz`);
|
|
5024
|
+
await fs9.writeFile(file, buf);
|
|
1829
5025
|
const sha256 = crypto4.createHash("sha256").update(buf).digest("hex");
|
|
1830
5026
|
const descriptor = {
|
|
1831
5027
|
id: m.id,
|
|
@@ -1834,23 +5030,23 @@ async function packagePlugin(root, opts = {}) {
|
|
|
1834
5030
|
description: m.description,
|
|
1835
5031
|
author: typeof m.author === "string" ? m.author : m.author?.name,
|
|
1836
5032
|
permissions: m.permissions ?? [],
|
|
1837
|
-
file:
|
|
5033
|
+
file: path13.basename(file),
|
|
1838
5034
|
sha256
|
|
1839
5035
|
};
|
|
1840
|
-
const descriptorFile =
|
|
1841
|
-
await
|
|
1842
|
-
log2(`packaged ${
|
|
5036
|
+
const descriptorFile = path13.join(out, `${m.id}-${m.version}.fluxplugin.json`);
|
|
5037
|
+
await fs9.writeFile(descriptorFile, JSON.stringify(descriptor, null, 2));
|
|
5038
|
+
log2(`packaged ${path13.relative(process.cwd(), file) || file} (${shipped.length} files: ${shipped.filter((x) => x !== "plugin.json").slice(0, 6).join(", ")}${shipped.length > 7 ? ", \u2026" : ""})
|
|
1843
5039
|
sha256 ${sha256}${opts.signKey ? "\n signed" : ""}`);
|
|
1844
5040
|
return { file, descriptorFile, sha256, manifest: m, shipped, signed: !!opts.signKey };
|
|
1845
5041
|
} finally {
|
|
1846
|
-
await
|
|
5042
|
+
await fs9.rm(stage, { recursive: true, force: true });
|
|
1847
5043
|
}
|
|
1848
5044
|
}
|
|
1849
5045
|
|
|
1850
5046
|
// src/cli/publish.ts
|
|
1851
|
-
import { promises as
|
|
5047
|
+
import { promises as fs10 } from "node:fs";
|
|
1852
5048
|
import os2 from "node:os";
|
|
1853
|
-
import
|
|
5049
|
+
import path14 from "node:path";
|
|
1854
5050
|
function parseGitHubRepo(input) {
|
|
1855
5051
|
if (!input) return void 0;
|
|
1856
5052
|
const m = /^(?:https?:\/\/github\.com\/|git@github\.com:|github:)?([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(input.trim());
|
|
@@ -1858,7 +5054,7 @@ function parseGitHubRepo(input) {
|
|
|
1858
5054
|
}
|
|
1859
5055
|
async function publishPlugin(root, o = {}) {
|
|
1860
5056
|
const log2 = o.log ?? console.log;
|
|
1861
|
-
const manifest = JSON.parse(await
|
|
5057
|
+
const manifest = JSON.parse(await fs10.readFile(path14.join(root, "plugin.json"), "utf8"));
|
|
1862
5058
|
const repo = parseGitHubRepo(o.repo) ?? parseGitHubRepo(typeof manifest["repository"] === "string" ? manifest["repository"] : manifest["repository"]?.url);
|
|
1863
5059
|
if (!repo || /YOUR_NAME/i.test(JSON.stringify(manifest["repository"] ?? ""))) {
|
|
1864
5060
|
throw new Error('no GitHub repository: set "repository": "https://github.com/<you>/<repo>" in plugin.json (or pass --repo owner/name)');
|
|
@@ -1869,15 +5065,15 @@ async function publishPlugin(root, o = {}) {
|
|
|
1869
5065
|
if (!manifest["license"]) problems.push('"license" is missing');
|
|
1870
5066
|
if (problems.length) throw new Error("not ready for the store: " + problems.join("; "));
|
|
1871
5067
|
for (const rec of ["screenshots", "video", "banner"]) if (!manifest[rec] || Array.isArray(manifest[rec]) && !manifest[rec].length) log2(` tip: add "${rec}" to plugin.json for a better store page`);
|
|
1872
|
-
const out = await
|
|
5068
|
+
const out = await fs10.mkdtemp(path14.join(os2.tmpdir(), "flux-publish-"));
|
|
1873
5069
|
try {
|
|
1874
5070
|
const pkg2 = await packagePlugin(root, { out, signKey: o.signKey, log: log2 });
|
|
1875
5071
|
const tag = `v${pkg2.manifest.version}`;
|
|
1876
5072
|
if (o.dryRun) {
|
|
1877
5073
|
log2(`
|
|
1878
5074
|
[dry run] would create release ${tag} in github.com/${repo} with:
|
|
1879
|
-
- ${
|
|
1880
|
-
- ${
|
|
5075
|
+
- ${path14.basename(pkg2.file)}
|
|
5076
|
+
- ${path14.basename(pkg2.descriptorFile)}
|
|
1881
5077
|
and add the topic "${o.topic ?? DEFAULT_TOPIC}"`);
|
|
1882
5078
|
return { repo, version: pkg2.manifest.version, id: pkg2.manifest.id };
|
|
1883
5079
|
}
|
|
@@ -1910,8 +5106,8 @@ Install: \`fluxplugin install ${pkg2.manifest.id}\` or from the Plugins page of
|
|
|
1910
5106
|
if (rel.status >= 300) throw new Error(`could not create the release (${rel.status}): ${rel.json?.message ?? "unknown error"}`);
|
|
1911
5107
|
const upload = String(rel.json.upload_url).replace(/\{\?[^}]*\}$/, "");
|
|
1912
5108
|
for (const [file, type] of [[pkg2.file, "application/gzip"], [pkg2.descriptorFile, "application/json"]]) {
|
|
1913
|
-
const up = await gh("POST", `${upload}?name=${encodeURIComponent(
|
|
1914
|
-
if (up.status >= 300) throw new Error(`upload of ${
|
|
5109
|
+
const up = await gh("POST", `${upload}?name=${encodeURIComponent(path14.basename(file))}`, void 0, { "content-type": type }, new Uint8Array(await fs10.readFile(file)));
|
|
5110
|
+
if (up.status >= 300) throw new Error(`upload of ${path14.basename(file)} failed (${up.status}): ${up.json?.message ?? ""}`);
|
|
1915
5111
|
}
|
|
1916
5112
|
const topics = await gh("GET", `${api}/repos/${repo}/topics`);
|
|
1917
5113
|
const names = topics.json?.names ?? [];
|
|
@@ -1927,21 +5123,21 @@ Install: \`fluxplugin install ${pkg2.manifest.id}\` or from the Plugins page of
|
|
|
1927
5123
|
(new GitHub topics can take a few minutes to show up in search)`);
|
|
1928
5124
|
return { release: rel.json.html_url, repo, version: pkg2.manifest.version, id: pkg2.manifest.id };
|
|
1929
5125
|
} finally {
|
|
1930
|
-
await
|
|
5126
|
+
await fs10.rm(out, { recursive: true, force: true });
|
|
1931
5127
|
}
|
|
1932
5128
|
}
|
|
1933
5129
|
|
|
1934
5130
|
// src/cli/store.ts
|
|
1935
5131
|
import crypto5 from "node:crypto";
|
|
1936
|
-
import { promises as
|
|
1937
|
-
import
|
|
5132
|
+
import { promises as fs11 } from "node:fs";
|
|
5133
|
+
import path15 from "node:path";
|
|
1938
5134
|
var INDEX = "index.json";
|
|
1939
5135
|
async function storeInit(dir) {
|
|
1940
|
-
await
|
|
1941
|
-
const idx =
|
|
1942
|
-
if (await
|
|
1943
|
-
await
|
|
1944
|
-
await
|
|
5136
|
+
await fs11.mkdir(dir, { recursive: true });
|
|
5137
|
+
const idx = path15.join(dir, INDEX);
|
|
5138
|
+
if (await fs11.stat(idx).catch(() => null)) throw new Error(`${idx} already exists`);
|
|
5139
|
+
await fs11.writeFile(idx, JSON.stringify({ plugins: [] }, null, 2));
|
|
5140
|
+
await fs11.writeFile(path15.join(dir, "README.md"), `# My plugin store
|
|
1945
5141
|
|
|
1946
5142
|
A store is a folder with \`index.json\` and the plugin packages (\`.tgz\`).
|
|
1947
5143
|
|
|
@@ -1957,7 +5153,7 @@ Host this folder anywhere that serves static files:
|
|
|
1957
5153
|
`);
|
|
1958
5154
|
}
|
|
1959
5155
|
async function storeAdd(pkgFile, dir, baseUrl) {
|
|
1960
|
-
const buf = await
|
|
5156
|
+
const buf = await fs11.readFile(pkgFile);
|
|
1961
5157
|
const entries = await readTarGz(buf);
|
|
1962
5158
|
const pj = entries.find((e) => e.type === "file" && /^[^/]+\/plugin\.json$/.test(e.name));
|
|
1963
5159
|
if (!pj) throw new Error("not a fluxplugin package (no plugin.json)");
|
|
@@ -1967,17 +5163,147 @@ async function storeAdd(pkgFile, dir, baseUrl) {
|
|
|
1967
5163
|
if (bad.length) throw new Error(`the package contains sources/sourcemaps (${bad[0].name}); rebuild it with "fluxplugin package"`);
|
|
1968
5164
|
const m = v.manifest;
|
|
1969
5165
|
const sha256 = crypto5.createHash("sha256").update(buf).digest("hex");
|
|
1970
|
-
await
|
|
1971
|
-
const fileName =
|
|
1972
|
-
if (
|
|
1973
|
-
const idxFile =
|
|
1974
|
-
const idx = JSON.parse(await
|
|
5166
|
+
await fs11.mkdir(dir, { recursive: true });
|
|
5167
|
+
const fileName = path15.basename(pkgFile);
|
|
5168
|
+
if (path15.resolve(pkgFile) !== path15.resolve(dir, fileName)) await fs11.copyFile(pkgFile, path15.join(dir, fileName));
|
|
5169
|
+
const idxFile = path15.join(dir, INDEX);
|
|
5170
|
+
const idx = JSON.parse(await fs11.readFile(idxFile, "utf8").catch(() => '{"plugins":[]}'));
|
|
1975
5171
|
const url = baseUrl ? new URL(fileName, baseUrl.endsWith("/") ? baseUrl : baseUrl + "/").href : fileName;
|
|
1976
5172
|
const entry = { id: m.id, name: m.name, version: m.version, description: m.description, author: typeof m.author === "string" ? m.author : m.author?.name, permissions: m.permissions ?? [], url, sha256, homepage: m.homepage };
|
|
1977
5173
|
idx.plugins = [...idx.plugins.filter((p) => !(p.id === m.id && p.version === m.version)), entry].sort((a, b) => a.id.localeCompare(b.id) || a.version.localeCompare(b.version, void 0, { numeric: true }));
|
|
1978
|
-
await
|
|
5174
|
+
await fs11.writeFile(idxFile, JSON.stringify(idx, null, 2));
|
|
1979
5175
|
return { id: m.id, version: m.version, sha256 };
|
|
1980
5176
|
}
|
|
5177
|
+
async function storeBuild(dir, opts = {}) {
|
|
5178
|
+
const log2 = opts.log ?? console.log;
|
|
5179
|
+
const root = path15.resolve(dir);
|
|
5180
|
+
const pluginsRel = opts.pluginsDir ?? (await fs11.stat(path15.join(root, "plugins")).catch(() => null) ? "plugins" : ".");
|
|
5181
|
+
const base = path15.join(root, pluginsRel);
|
|
5182
|
+
const out = path15.resolve(opts.out ?? path15.join(root, INDEX));
|
|
5183
|
+
const plugins = [];
|
|
5184
|
+
const tmpRoot = await fs11.mkdtemp(path15.join((await import("node:os")).tmpdir(), "flux-store-"));
|
|
5185
|
+
try {
|
|
5186
|
+
for (const name of (await fs11.readdir(base)).sort()) {
|
|
5187
|
+
const folder = path15.join(base, name);
|
|
5188
|
+
let raw;
|
|
5189
|
+
try {
|
|
5190
|
+
raw = JSON.parse(await fs11.readFile(path15.join(folder, "plugin.json"), "utf8"));
|
|
5191
|
+
} catch {
|
|
5192
|
+
continue;
|
|
5193
|
+
}
|
|
5194
|
+
const v = validateManifest(raw);
|
|
5195
|
+
if (!v.manifest) {
|
|
5196
|
+
log2(`! skipped ${name}: ${v.issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`);
|
|
5197
|
+
continue;
|
|
5198
|
+
}
|
|
5199
|
+
const m = v.manifest;
|
|
5200
|
+
const stage = path15.join(tmpRoot, name);
|
|
5201
|
+
const files = (await copyPluginRuntime(folder, stage)).sort();
|
|
5202
|
+
const hashes = {};
|
|
5203
|
+
for (const f of files) hashes[f] = crypto5.createHash("sha256").update(await fs11.readFile(path15.join(stage, f))).digest("hex");
|
|
5204
|
+
const rel = (p) => p ? p.replace(/^\.\//, "") : void 0;
|
|
5205
|
+
const relFolder = path15.relative(root, folder).split(path15.sep).join("/") + "/";
|
|
5206
|
+
const readme3 = await fs11.readFile(path15.join(folder, rel(m.readme) ?? "README.md"), "utf8").then((s2) => s2.slice(0, 2e4), () => void 0);
|
|
5207
|
+
plugins.push({
|
|
5208
|
+
id: m.id,
|
|
5209
|
+
name: m.name,
|
|
5210
|
+
version: m.version,
|
|
5211
|
+
description: m.description,
|
|
5212
|
+
author: typeof m.author === "string" ? m.author : m.author?.name,
|
|
5213
|
+
permissions: m.permissions ?? [],
|
|
5214
|
+
folder: relFolder,
|
|
5215
|
+
files,
|
|
5216
|
+
hashes,
|
|
5217
|
+
homepage: m.homepage,
|
|
5218
|
+
license: m.license,
|
|
5219
|
+
icon: m.icon ? relFolder + rel(m.icon) : void 0,
|
|
5220
|
+
banner: m.banner ? relFolder + rel(m.banner) : void 0,
|
|
5221
|
+
screenshots: (m.screenshots ?? []).map((s2) => relFolder + rel(s2)),
|
|
5222
|
+
video: m.video?.startsWith("./") ? relFolder + rel(m.video) : m.video,
|
|
5223
|
+
categories: m.categories,
|
|
5224
|
+
keywords: m.keywords,
|
|
5225
|
+
links: m.links,
|
|
5226
|
+
readme: readme3
|
|
5227
|
+
});
|
|
5228
|
+
log2(` ${m.id}@${m.version} (${files.length} files)`);
|
|
5229
|
+
}
|
|
5230
|
+
} finally {
|
|
5231
|
+
await fs11.rm(tmpRoot, { recursive: true, force: true });
|
|
5232
|
+
}
|
|
5233
|
+
await fs11.writeFile(out, JSON.stringify({ plugins }, null, 2));
|
|
5234
|
+
return { count: plugins.length, file: out };
|
|
5235
|
+
}
|
|
5236
|
+
|
|
5237
|
+
// src/cli/dist.ts
|
|
5238
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
5239
|
+
import { existsSync as existsSync4, promises as fs12 } from "node:fs";
|
|
5240
|
+
import { createRequire as createRequire3 } from "node:module";
|
|
5241
|
+
import path16 from "node:path";
|
|
5242
|
+
async function pluginFolders(dir) {
|
|
5243
|
+
const out = [];
|
|
5244
|
+
for (const n of (await fs12.readdir(dir).catch(() => [])).sort()) if (existsSync4(path16.join(dir, n, "plugin.json"))) out.push(path16.join(dir, n));
|
|
5245
|
+
return out;
|
|
5246
|
+
}
|
|
5247
|
+
async function stagePlugins(cwd, outDir, log2 = console.log) {
|
|
5248
|
+
const cfg = await loadConfig({}, cwd);
|
|
5249
|
+
await fs12.rm(outDir, { recursive: true, force: true });
|
|
5250
|
+
const dirs = await pluginFolders(cfg.pluginsDir);
|
|
5251
|
+
for (const d of dirs) {
|
|
5252
|
+
const files = await copyPluginRuntime(d, path16.join(outDir, path16.basename(d)));
|
|
5253
|
+
log2(` ${path16.basename(d)}: ${files.length} runtime files`);
|
|
5254
|
+
}
|
|
5255
|
+
await fs12.mkdir(outDir, { recursive: true });
|
|
5256
|
+
return dirs.length;
|
|
5257
|
+
}
|
|
5258
|
+
async function distApp(cwd, opts = {}) {
|
|
5259
|
+
const log2 = opts.log ?? console.log;
|
|
5260
|
+
log2("1/3 building the app\u2026");
|
|
5261
|
+
await buildHost(cwd, { log: log2 });
|
|
5262
|
+
log2("2/3 staging plugins (runtime files only)\u2026");
|
|
5263
|
+
const n = await stagePlugins(cwd, path16.join(cwd, ".flux-build/plugins"), log2);
|
|
5264
|
+
log2(`3/3 packaging (${n} plugin(s) bundled)\u2026`);
|
|
5265
|
+
let cli;
|
|
5266
|
+
try {
|
|
5267
|
+
const pj = createRequire3(path16.join(cwd, "package.json")).resolve("electron-builder/package.json");
|
|
5268
|
+
const bin = JSON.parse(await fs12.readFile(pj, "utf8")).bin;
|
|
5269
|
+
cli = path16.join(path16.dirname(pj), typeof bin === "string" ? bin : bin?.["electron-builder"] ?? "cli.js");
|
|
5270
|
+
} catch {
|
|
5271
|
+
throw new Error('electron-builder is not installed: run "npm install -D electron-builder"');
|
|
5272
|
+
}
|
|
5273
|
+
const args = [cli, "--config", existsSync4(path16.join(cwd, "electron-builder.yml")) ? "electron-builder.yml" : "package.json"];
|
|
5274
|
+
if (opts.dir) args.push("--dir");
|
|
5275
|
+
for (const t of ["win", "mac", "linux"]) if (opts[t]) args.push(`--${t}`);
|
|
5276
|
+
return await new Promise((resolve) => {
|
|
5277
|
+
const child = spawn3(process.execPath, args, { cwd, stdio: "inherit", env: { ...process.env, CSC_IDENTITY_AUTO_DISCOVERY: process.env["CSC_IDENTITY_AUTO_DISCOVERY"] ?? "false" } });
|
|
5278
|
+
child.on("exit", (c) => resolve(c ?? 1));
|
|
5279
|
+
});
|
|
5280
|
+
}
|
|
5281
|
+
async function exportWebsite(cwd, outDir, log2 = console.log) {
|
|
5282
|
+
await buildHost(cwd, { log: log2 });
|
|
5283
|
+
const out = path16.resolve(outDir);
|
|
5284
|
+
await fs12.rm(out, { recursive: true, force: true });
|
|
5285
|
+
await fs12.mkdir(out, { recursive: true });
|
|
5286
|
+
for (const f of ["index.html", "renderer.js", "renderer.css", "tailwind.css"]) if (existsSync4(path16.join(cwd, "dist", f))) await fs12.copyFile(path16.join(cwd, "dist", f), path16.join(out, f));
|
|
5287
|
+
const cfg = await loadConfig({}, cwd);
|
|
5288
|
+
let count = 0;
|
|
5289
|
+
for (const d of await pluginFolders(cfg.pluginsDir)) {
|
|
5290
|
+
const id = JSON.parse(await fs12.readFile(path16.join(d, "plugin.json"), "utf8")).id;
|
|
5291
|
+
await copyPluginRuntime(d, path16.join(out, "plugins", id));
|
|
5292
|
+
count++;
|
|
5293
|
+
}
|
|
5294
|
+
const tmp = await fs12.mkdtemp(path16.join((await import("node:os")).tmpdir(), "flux-export-"));
|
|
5295
|
+
try {
|
|
5296
|
+
const fw = new MainFramework({ pluginDirs: [path16.join(out, "plugins")], dataDir: tmp, assetUrl: (id, rel) => `./plugins/${id}/${rel.replace(/^\.\//, "")}` });
|
|
5297
|
+
await fw.manager.init();
|
|
5298
|
+
await fw.scan();
|
|
5299
|
+
await fs12.writeFile(path16.join(out, "bootstrap.json"), JSON.stringify(fw.bootstrap(), null, 2));
|
|
5300
|
+
} finally {
|
|
5301
|
+
await fs12.rm(tmp, { recursive: true, force: true });
|
|
5302
|
+
}
|
|
5303
|
+
await fs12.writeFile(path16.join(out, "README.txt"), "Static export. Serve this folder over HTTP(S) (e.g. `npx serve .`); opening index.html from disk will not work.\nOnly the browser side runs here: plugins that need main-process code are listed but their IPC calls fail.\n");
|
|
5304
|
+
log2(`exported ${count} plugin(s) \u2192 ${out}`);
|
|
5305
|
+
return { plugins: count, out };
|
|
5306
|
+
}
|
|
1981
5307
|
|
|
1982
5308
|
// src/cli/cli.ts
|
|
1983
5309
|
var HELP = `fluxplugin <command>
|
|
@@ -1987,26 +5313,31 @@ var HELP = `fluxplugin <command>
|
|
|
1987
5313
|
dev [--web] run your app with plugin hot reload (Electron, or a browser with --web)
|
|
1988
5314
|
build bundle your app (in an app folder) or a plugin (in a plugin folder)
|
|
1989
5315
|
start [--web] run the built app
|
|
1990
|
-
|
|
5316
|
+
dist [--win|--mac|--linux] [--unpacked] build the app into an installer/executable (electron-builder)
|
|
5317
|
+
export [--out website] extract the app as a static website
|
|
5318
|
+
plugin new <name> [-t basic|dashboard|backend] [--tailwindcss] [--standalone] scaffold a plugin (its own project when outside an app)
|
|
5319
|
+
dev --host <app> (in a plugin project) rebuild on save and sync into a host app that hot-reloads it
|
|
1991
5320
|
validate [dir] check a plugin manifest, entries and integrity
|
|
1992
5321
|
publish [dir] [--repo o/r] [--dry-run] publish a plugin to the store (GitHub release + topic)
|
|
1993
5322
|
search [query] search the configured stores
|
|
1994
5323
|
install <id[@version]|dir|.tgz> install a plugin from a store, a folder or a package
|
|
1995
5324
|
store init <dir> create your own store (index.json) \u2014 host it anywhere
|
|
1996
5325
|
store add <pkg.tgz> [--dir d] [--base-url url] add a package to your store
|
|
5326
|
+
store build [dir] [--out index.json] build the catalog JSON from plugin folders (repo layout)
|
|
5327
|
+
package --all [--out release/plugins] package every plugin of the app
|
|
1997
5328
|
package [dir] [--out d] [--sign-key file] build + integrity (+ signature) + .tgz
|
|
1998
5329
|
install <dir|.tgz> [--dir plugins] uninstall <id> enable <id> disable <id> list
|
|
1999
5330
|
keygen [--out name] create an ed25519 signing key pair
|
|
2000
5331
|
tailwind [--dir plugins] [--out file] generate @source lines for Tailwind v4`;
|
|
2001
5332
|
var log = (...a) => console.log(...a);
|
|
2002
5333
|
async function readManifest(root) {
|
|
2003
|
-
return JSON.parse(await
|
|
5334
|
+
return JSON.parse(await fs13.readFile(path17.join(root, "plugin.json"), "utf8"));
|
|
2004
5335
|
}
|
|
2005
5336
|
async function run(argv) {
|
|
2006
5337
|
const { values: f, positionals: pos } = parseArgs({
|
|
2007
5338
|
args: argv,
|
|
2008
5339
|
allowPositionals: true,
|
|
2009
|
-
options: { template: { type: "string", short: "t" }, out: { type: "string" }, dir: { type: "string" }, data: { type: "string" }, "sign-key": { type: "string" }, help: { type: "boolean", short: "h" }, web: { type: "boolean" }, port: { type: "string" }, name: { type: "string" }, "no-sample": { type: "boolean" }, tailwindcss: { type: "boolean" }, repo: { type: "string" }, token: { type: "string" }, "dry-run": { type: "boolean" }, "base-url": { type: "string" } }
|
|
5340
|
+
options: { template: { type: "string", short: "t" }, out: { type: "string" }, dir: { type: "string" }, data: { type: "string" }, "sign-key": { type: "string" }, help: { type: "boolean", short: "h" }, web: { type: "boolean" }, port: { type: "string" }, name: { type: "string" }, "no-sample": { type: "boolean" }, tailwindcss: { type: "boolean" }, repo: { type: "string" }, token: { type: "string" }, "dry-run": { type: "boolean" }, "base-url": { type: "string" }, all: { type: "boolean" }, win: { type: "boolean" }, mac: { type: "boolean" }, linux: { type: "boolean" }, unpacked: { type: "boolean" }, store: { type: "string" }, host: { type: "string" }, standalone: { type: "boolean" } }
|
|
2010
5341
|
});
|
|
2011
5342
|
let [cmd, arg] = pos;
|
|
2012
5343
|
if (cmd === "store") {
|
|
@@ -2020,20 +5351,20 @@ async function run(argv) {
|
|
|
2020
5351
|
arg = pos[2];
|
|
2021
5352
|
}
|
|
2022
5353
|
if (!cmd || f.help) return log(HELP), cmd ? 0 : 1;
|
|
2023
|
-
const root =
|
|
5354
|
+
const root = path17.resolve(arg ?? ".");
|
|
2024
5355
|
try {
|
|
2025
5356
|
switch (cmd) {
|
|
2026
5357
|
case "init": {
|
|
2027
|
-
const target =
|
|
2028
|
-
const entries = await
|
|
5358
|
+
const target = path17.resolve(arg ?? ".");
|
|
5359
|
+
const entries = await fs13.readdir(target).catch(() => []);
|
|
2029
5360
|
if (entries.length) throw new Error(`${target} is not empty`);
|
|
2030
|
-
const name = (f.name ??
|
|
2031
|
-
const files = hostFiles({ name, fluxSpec: process.env["FLUXPLUGIN_SPEC"] ?? `^${await ownVersion()}`, electron: await latestElectron(), tailwind: !!f.tailwindcss }, !f["no-sample"]);
|
|
5361
|
+
const name = (f.name ?? path17.basename(target)).toLowerCase().replace(/[^a-z0-9-_.]+/g, "-");
|
|
5362
|
+
const files = hostFiles({ name, fluxSpec: process.env["FLUXPLUGIN_SPEC"] ?? `^${await ownVersion()}`, electron: await latestElectron(), tailwind: !!f.tailwindcss, stores: parseStoreSpec(f.store) }, !f["no-sample"]);
|
|
2032
5363
|
for (const [rel2, content] of Object.entries(files)) {
|
|
2033
|
-
await
|
|
2034
|
-
await
|
|
5364
|
+
await fs13.mkdir(path17.dirname(path17.join(target, rel2)), { recursive: true });
|
|
5365
|
+
await fs13.writeFile(path17.join(target, rel2), content);
|
|
2035
5366
|
}
|
|
2036
|
-
const rel =
|
|
5367
|
+
const rel = path17.relative(process.cwd(), target) || ".";
|
|
2037
5368
|
log(`\u2714 created ${name} in ${target}
|
|
2038
5369
|
|
|
2039
5370
|
Next steps:
|
|
@@ -2049,17 +5380,19 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
|
|
|
2049
5380
|
if (!arg) throw new Error("usage: fluxplugin plugin new <name> [-t basic|dashboard|backend]");
|
|
2050
5381
|
const tpl = f.template ?? "basic";
|
|
2051
5382
|
if (!(tpl in TEMPLATES)) throw new Error(`unknown template "${tpl}"`);
|
|
2052
|
-
const
|
|
2053
|
-
|
|
2054
|
-
const
|
|
5383
|
+
const inHost = await isHostProject(process.cwd());
|
|
5384
|
+
const standalone = !!f.standalone || !inHost;
|
|
5385
|
+
const base = inHost && !f.standalone && !path17.isAbsolute(arg) && !arg.includes("/") && !arg.includes("\\") ? path17.join((await loadConfig({}, process.cwd())).pluginsDir, arg) : path17.resolve(arg);
|
|
5386
|
+
if (await fs13.stat(base).catch(() => null)) throw new Error(`${base} already exists`);
|
|
5387
|
+
const vars = varsFromName(path17.basename(base));
|
|
2055
5388
|
let files = TEMPLATES[tpl](vars);
|
|
2056
5389
|
if (f.tailwindcss) files = withTailwind(files, vars);
|
|
5390
|
+
if (standalone) files = { ...files, ...standaloneExtras(vars) };
|
|
2057
5391
|
for (const [rel, content] of Object.entries(files)) {
|
|
2058
|
-
await
|
|
2059
|
-
await
|
|
5392
|
+
await fs13.mkdir(path17.dirname(path17.join(base, rel)), { recursive: true });
|
|
5393
|
+
await fs13.writeFile(path17.join(base, rel), content);
|
|
2060
5394
|
}
|
|
2061
|
-
log(`\u2714 created ${tpl} plugin in ${base}
|
|
2062
|
-
next: fluxplugin build ${path14.relative(process.cwd(), base)}`);
|
|
5395
|
+
log(`\u2714 created ${tpl} plugin in ${base}${standalone ? "\n\n cd " + path17.relative(process.cwd(), base) + "\n npm install\n npm run dev:host # run it inside your app while you work (edit the path in package.json)" : "\n next: fluxplugin build " + path17.relative(process.cwd(), base)}`);
|
|
2063
5396
|
return 0;
|
|
2064
5397
|
}
|
|
2065
5398
|
case "build": {
|
|
@@ -2074,6 +5407,7 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
|
|
|
2074
5407
|
const cwd = process.cwd();
|
|
2075
5408
|
if (arg || !await isHostProject(cwd)) {
|
|
2076
5409
|
await buildPlugin(root, true);
|
|
5410
|
+
if (f.host) await linkPluginToHost(root, path17.resolve(f.host));
|
|
2077
5411
|
await new Promise(() => {
|
|
2078
5412
|
});
|
|
2079
5413
|
}
|
|
@@ -2091,7 +5425,7 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
|
|
|
2091
5425
|
}
|
|
2092
5426
|
case "start": {
|
|
2093
5427
|
const cwd = process.cwd();
|
|
2094
|
-
if (!await
|
|
5428
|
+
if (!await fs13.stat(path17.join(cwd, "dist/index.html")).catch(() => null)) throw new Error("nothing built yet: run `fluxplugin build` first");
|
|
2095
5429
|
const { child } = startApp(cwd, { web: f.web, port: f.port ? Number(f.port) : void 0 });
|
|
2096
5430
|
return await new Promise((r) => child.on("exit", (c) => r(c ?? 0)));
|
|
2097
5431
|
}
|
|
@@ -2099,8 +5433,32 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
|
|
|
2099
5433
|
return await doctor(process.cwd());
|
|
2100
5434
|
case "validate":
|
|
2101
5435
|
return await validate(root);
|
|
2102
|
-
case "package":
|
|
2103
|
-
|
|
5436
|
+
case "package": {
|
|
5437
|
+
if (f.all) {
|
|
5438
|
+
const cfg = await loadConfig(f);
|
|
5439
|
+
let n = 0;
|
|
5440
|
+
for (const d of (await fs13.readdir(cfg.pluginsDir).catch(() => [])).sort()) {
|
|
5441
|
+
if (!await fs13.stat(path17.join(cfg.pluginsDir, d, "plugin.json")).catch(() => null)) continue;
|
|
5442
|
+
await packagePlugin(path17.join(cfg.pluginsDir, d), { out: f.out ?? "release/plugins", signKey: f["sign-key"] });
|
|
5443
|
+
n++;
|
|
5444
|
+
}
|
|
5445
|
+
log(`\u2714 packaged ${n} plugin(s) in ${f.out ?? "release/plugins"}`);
|
|
5446
|
+
return 0;
|
|
5447
|
+
}
|
|
5448
|
+
await packagePlugin(root, { out: f.out, signKey: f["sign-key"] });
|
|
5449
|
+
return 0;
|
|
5450
|
+
}
|
|
5451
|
+
case "dist":
|
|
5452
|
+
return await distApp(process.cwd(), { win: f.win, mac: f.mac, linux: f.linux, dir: f.unpacked });
|
|
5453
|
+
case "export":
|
|
5454
|
+
await exportWebsite(process.cwd(), f.out ?? "website");
|
|
5455
|
+
return 0;
|
|
5456
|
+
case "store:build": {
|
|
5457
|
+
const r = await storeBuild(path17.resolve(arg ?? "."), { out: f.out });
|
|
5458
|
+
log(`\u2714 catalog with ${r.count} plugin(s) written to ${r.file}
|
|
5459
|
+
commit it with the plugin folders; point a store at the repo: { "type": "github", "repo": "owner/name" }`);
|
|
5460
|
+
return 0;
|
|
5461
|
+
}
|
|
2104
5462
|
case "publish":
|
|
2105
5463
|
await publishPlugin(root, { repo: f.repo, token: f.token, signKey: f["sign-key"], dryRun: f["dry-run"] });
|
|
2106
5464
|
return 0;
|
|
@@ -2117,31 +5475,31 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
|
|
|
2117
5475
|
return 0;
|
|
2118
5476
|
}
|
|
2119
5477
|
case "store:init":
|
|
2120
|
-
await storeInit(
|
|
2121
|
-
log(`\u2714 store created in ${
|
|
5478
|
+
await storeInit(path17.resolve(arg ?? "store"));
|
|
5479
|
+
log(`\u2714 store created in ${path17.resolve(arg ?? "store")} (see its README.md)`);
|
|
2122
5480
|
return 0;
|
|
2123
5481
|
case "store:add": {
|
|
2124
5482
|
if (!arg) throw new Error("usage: store add <package.tgz> [--dir store] [--base-url https://\u2026]");
|
|
2125
|
-
const r = await storeAdd(
|
|
2126
|
-
log(`\u2714 added ${r.id}@${r.version} to ${
|
|
5483
|
+
const r = await storeAdd(path17.resolve(arg), path17.resolve(f.dir ?? "store"), f["base-url"]);
|
|
5484
|
+
log(`\u2714 added ${r.id}@${r.version} to ${path17.resolve(f.dir ?? "store")}/index.json`);
|
|
2127
5485
|
return 0;
|
|
2128
5486
|
}
|
|
2129
5487
|
case "keygen": {
|
|
2130
5488
|
const k = generateSigningKeyPair();
|
|
2131
5489
|
const base = f.out ?? "flux-signing";
|
|
2132
|
-
await
|
|
2133
|
-
await
|
|
5490
|
+
await fs13.writeFile(`${base}.private.key`, k.privateKey, { mode: 384 });
|
|
5491
|
+
await fs13.writeFile(`${base}.public.key`, k.publicKey);
|
|
2134
5492
|
log(`wrote ${base}.private.key (keep secret) and ${base}.public.key (distribute to hosts as a trusted key)`);
|
|
2135
5493
|
return 0;
|
|
2136
5494
|
}
|
|
2137
5495
|
case "tailwind": {
|
|
2138
5496
|
const cfg = await loadConfig(f);
|
|
2139
|
-
const names = (await
|
|
2140
|
-
const lines = names.map((n) => `@source "${
|
|
5497
|
+
const names = (await fs13.readdir(cfg.pluginsDir).catch(() => [])).sort();
|
|
5498
|
+
const lines = names.map((n) => `@source "${path17.join(cfg.pluginsDir, n, "src").replace(/\\/g, "/")}";`);
|
|
2141
5499
|
const css = `/* generated by fluxplugin tailwind */
|
|
2142
5500
|
${lines.join("\n")}
|
|
2143
5501
|
`;
|
|
2144
|
-
if (f.out) await
|
|
5502
|
+
if (f.out) await fs13.writeFile(f.out, css);
|
|
2145
5503
|
else log(css);
|
|
2146
5504
|
return 0;
|
|
2147
5505
|
}
|
|
@@ -2168,7 +5526,7 @@ async function validate(root) {
|
|
|
2168
5526
|
v.warnings.forEach((w) => log(`! ${w}`));
|
|
2169
5527
|
if (v.manifest) {
|
|
2170
5528
|
for (const rel of [v.manifest.main, v.manifest.renderer, ...v.manifest.styles ?? []].filter(Boolean))
|
|
2171
|
-
if (!await
|
|
5529
|
+
if (!await fs13.stat(path17.join(root, rel)).catch(() => null)) log(`\u2716 missing file ${rel} (run build)`), bad = true;
|
|
2172
5530
|
try {
|
|
2173
5531
|
await verifyPlugin(root, raw, v.manifest);
|
|
2174
5532
|
} catch (e) {
|
|
@@ -2181,17 +5539,17 @@ async function validate(root) {
|
|
|
2181
5539
|
}
|
|
2182
5540
|
async function manage(cmd, arg, f) {
|
|
2183
5541
|
const cfg = await loadConfig(f);
|
|
2184
|
-
const state = new JsonStateFile(
|
|
2185
|
-
const inst = new PluginInstaller(cfg.pluginsDir,
|
|
5542
|
+
const state = new JsonStateFile(path17.join(cfg.dataDir, "plugins-state.json"));
|
|
5543
|
+
const inst = new PluginInstaller(cfg.pluginsDir, path17.join(cfg.dataDir, "backups"));
|
|
2186
5544
|
if (cmd === "list") {
|
|
2187
5545
|
const { disabled } = await state.readAll();
|
|
2188
5546
|
for (const m of await inst.installed()) log(`${disabled.includes(m.id) ? "\u25CB" : "\u25CF"} ${m.id}@${m.version} ${m.name}`);
|
|
2189
5547
|
return 0;
|
|
2190
5548
|
}
|
|
2191
5549
|
if (!arg) throw new Error(`usage: ${cmd} <${cmd === "install" ? "dir|.tgz" : "id"}>`);
|
|
2192
|
-
if (cmd === "install" && arg && !arg.endsWith(".tgz") && !await
|
|
5550
|
+
if (cmd === "install" && arg && !arg.endsWith(".tgz") && !await fs13.stat(arg).catch(() => null)) {
|
|
2193
5551
|
const [id, version] = arg.split("@");
|
|
2194
|
-
const store = new PluginInstaller(cfg.pluginsDir,
|
|
5552
|
+
const store = new PluginInstaller(cfg.pluginsDir, path17.join(cfg.dataDir, "backups"), createRegistries(cfg.stores));
|
|
2195
5553
|
const found = (await store.search(id)).filter((e) => e.id === id && (!version || e.version === version));
|
|
2196
5554
|
if (!found.length) throw new Error(`"${arg}" was not found in your stores (${cfg.stores.map((s2) => s2.type).join(", ")}). Try: fluxplugin search ${id}`);
|
|
2197
5555
|
const best = found.sort((a, b) => b.version.localeCompare(a.version, void 0, { numeric: true }))[0];
|
|
@@ -2201,16 +5559,16 @@ async function manage(cmd, arg, f) {
|
|
|
2201
5559
|
return 0;
|
|
2202
5560
|
}
|
|
2203
5561
|
if (cmd === "install") {
|
|
2204
|
-
const tmp =
|
|
2205
|
-
if (arg.endsWith(".tgz")) await extractTarGz(await
|
|
2206
|
-
else await copyDir(
|
|
5562
|
+
const tmp = path17.join(cfg.dataDir, ".install", String(Date.now()));
|
|
5563
|
+
if (arg.endsWith(".tgz")) await extractTarGz(await fs13.readFile(arg), tmp);
|
|
5564
|
+
else await copyDir(path17.resolve(arg), tmp);
|
|
2207
5565
|
const v = validateManifest(await readManifest(tmp));
|
|
2208
5566
|
if (!v.manifest) throw new Error("invalid plugin: " + v.issues.map((i) => i.message).join("; "));
|
|
2209
|
-
const reg = new LocalRegistry(
|
|
5567
|
+
const reg = new LocalRegistry(path17.dirname(tmp), "cli");
|
|
2210
5568
|
inst.addRegistry(reg);
|
|
2211
5569
|
const [entry] = (await reg.search(v.manifest.id)).filter((e) => e.id === v.manifest.id);
|
|
2212
5570
|
const r = await inst.install({ ...entry, location: tmp });
|
|
2213
|
-
await
|
|
5571
|
+
await fs13.rm(tmp, { recursive: true, force: true });
|
|
2214
5572
|
log(`installed ${r.id}@${r.version}${r.previousVersion ? ` (was ${r.previousVersion}; rollback available)` : ""}`);
|
|
2215
5573
|
} else if (cmd === "uninstall") {
|
|
2216
5574
|
await inst.uninstall(arg);
|
|
@@ -2224,7 +5582,7 @@ async function manage(cmd, arg, f) {
|
|
|
2224
5582
|
async function ownVersion() {
|
|
2225
5583
|
for (const rel of ["../package.json", "../../package.json"]) {
|
|
2226
5584
|
try {
|
|
2227
|
-
const j = JSON.parse(await
|
|
5585
|
+
const j = JSON.parse(await fs13.readFile(new URL(rel, import.meta.url), "utf8"));
|
|
2228
5586
|
if (j.name === "fluxplugin" && j.version) return j.version;
|
|
2229
5587
|
} catch {
|
|
2230
5588
|
}
|
|
@@ -2250,23 +5608,30 @@ async function doctor(cwd) {
|
|
|
2250
5608
|
return bad ? 1 : 0;
|
|
2251
5609
|
}
|
|
2252
5610
|
const e = ensureElectron(cwd, log);
|
|
2253
|
-
e.bin ? ok2(`Electron binary: ${
|
|
5611
|
+
e.bin ? ok2(`Electron binary: ${path17.relative(cwd, e.bin) || e.bin}`) : ko(`Electron: ${e.error}
|
|
2254
5612
|
fix: npm install -D electron`);
|
|
2255
5613
|
try {
|
|
2256
5614
|
(await import("esbuild")).version ? ok2("esbuild") : ko("esbuild");
|
|
2257
5615
|
} catch {
|
|
2258
5616
|
ko("esbuild is not available");
|
|
2259
5617
|
}
|
|
2260
|
-
const hasTw = await
|
|
5618
|
+
const hasTw = await fs13.stat(path17.join(cwd, "src/app/renderer/styles/tailwind.css")).then(() => true, () => false);
|
|
2261
5619
|
if (hasTw) {
|
|
2262
5620
|
const { resolveTailwindCli: resolveTailwindCli2 } = await Promise.resolve().then(() => (init_tailwind(), tailwind_exports));
|
|
2263
5621
|
resolveTailwindCli2([cwd]) ? ok2("Tailwind CLI") : ko("Tailwind CLI missing: npm install -D tailwindcss @tailwindcss/cli");
|
|
2264
5622
|
}
|
|
2265
|
-
const pkg2 = JSON.parse(await
|
|
5623
|
+
const pkg2 = JSON.parse(await fs13.readFile(path17.join(cwd, "package.json"), "utf8").catch(() => "{}"));
|
|
2266
5624
|
if (!pkg2.allowScripts?.["electron"]) log(`! package.json has no "allowScripts": {"electron": true}. npm 12 blocks Electron's postinstall without it (fluxplugin downloads the binary for you, but add it for clean installs).`);
|
|
2267
5625
|
log(bad ? "\nSome checks failed." : "\nAll good.");
|
|
2268
5626
|
return bad ? 1 : 0;
|
|
2269
5627
|
}
|
|
5628
|
+
function parseStoreSpec(spec) {
|
|
5629
|
+
if (!spec) return void 0;
|
|
5630
|
+
const gh = /^github:([\w.-]+\/[\w.-]+)$/.exec(spec);
|
|
5631
|
+
if (gh) return [{ type: "github", repo: gh[1] }];
|
|
5632
|
+
if (/^https:\/\/\S+$/.test(spec)) return [spec.toLowerCase().split("?")[0].endsWith(".json") ? { type: "json", url: spec } : { type: "http", url: spec }];
|
|
5633
|
+
throw new Error('--store must be "github:owner/repo" or an https URL (a .json link, or a folder URL ending with /)');
|
|
5634
|
+
}
|
|
2270
5635
|
|
|
2271
5636
|
// src/cli/bin.ts
|
|
2272
5637
|
process.exit(await run(process.argv.slice(2)));
|