From de72e25b4c6a5c2da881f72c4b43d986cf0af364 Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 15 Oct 2026 14:54:23 -0500 Subject: [PATCH] app: a link-only build, which loads no plugin at runtime -Ddynamic-plugins=false (a buildApp dependency option too) makes an app run exactly the plugins it links. No plugin dylib is built, and App.loads_plugins (comptime) turns off the bundled-dylib probe, the user plugins directory scan, loading a plugin by id, a web page's ?plugin= request, and the store (PluginStore.register keeps the manager, which settings read, then returns; tick returns). CI compiles it on Linux. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 ++++++ AGENTS.md | 5 ++++- app/App.zig | 9 +++++++-- app/store/PluginStore.zig | 5 +++++ build/app.zig | 13 +++++++++++-- build/exe.zig | 10 ++++++---- src/editor/Editor.zig | 6 +++--- 7 files changed, 42 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 829d1da78..2d4506360 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,6 +211,12 @@ jobs: if: matrix.name == 'macOS' run: bash scripts/ci-retry.sh zig build -Dnative-backend=sdl3 -Dno-emit --summary all + # A link-only app (`-Ddynamic-plugins=false`): its gates on loading plugins at runtime are + # comptime, so only a build with it off shows that build still compiles. + - name: Compile a link-only app + if: matrix.name == 'Linux' + run: bash scripts/ci-retry.sh zig build -Ddynamic-plugins=false -Dno-emit --summary all + # The suites for SDL's patches on macOS (FizzyGPU's Metal tests; the rest are Linux's), when # SDL's patches or pins changed: see the `sdl` job. Is there a Metal device? The probe says, # and the suite then requires Metal, so a runner with one can't skip the Metal tests. (On diff --git a/AGENTS.md b/AGENTS.md index 4b9231d66..fb191062e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,10 @@ every list must include it) and markdown (the store draws READMEs with it). An a fizzy lists every plugin it links with `defer-app` + `fizzy.buildApp(fizzy_dep, &.{ fizzy.plugins.workbench, fizzy.plugins.text, .{ .name, .module = dep.module("plugin") } })`: fizzy's own are entries with no module, built for each executable (`build/plugins.zig`'s -`link`), and only those listed register or get a dylib. See +`link`), and only those listed register or get a dylib. `.@"dynamic-plugins" = false` (or +`-Ddynamic-plugins=false`) makes an app link-only: no plugin dylib is built, and nothing loads +at runtime (fizzy's dylibs, the user plugins directory, the store, a web page's `?plugin=`), +all decided at comptime (`App.loads_plugins`). See [`fizzyedit/example-app`](https://github.com/fizzyedit/example-app), which bundles `fizzyedit/example-plugin`. diff --git a/app/App.zig b/app/App.zig index 276a6cbb5..b9b815f54 100644 --- a/app/App.zig +++ b/app/App.zig @@ -1141,12 +1141,17 @@ pub fn syncLoadedPluginRenderBridge(app: *App) void { } } +/// Whether this build loads plugins at runtime (`-Ddynamic-plugins`, default on): fizzy's own +/// dylibs, the user plugins directory, the store, and on the web a page's `?plugin=`. Off, the +/// app runs exactly the plugins it links. +pub const loads_plugins = if (@hasDecl(build_opts, "dynamic_plugins")) build_opts.dynamic_plugins else true; + /// Whether a bundled plugin should be loaded from `{exe_dir}/plugins//` rather than the -/// copy linked in: never on wasm, not when the build pinned it static +/// copy linked in: never on wasm or in a link-only build, not when the build pinned it static /// (`build_opts.static_`), and not when `FIZZY_STATIC_=1` is set — the bisection /// switch for dylib loading trouble. pub fn bundledDylibEnabled(gpa: std.mem.Allocator, comptime id: []const u8) bool { - if (comptime builtin.target.cpu.arch == .wasm32) return false; + if (comptime builtin.target.cpu.arch == .wasm32 or !loads_plugins) return false; if (comptime @hasDecl(build_opts, "static_" ++ id)) { if (comptime @field(build_opts, "static_" ++ id)) return false; } diff --git a/app/store/PluginStore.zig b/app/store/PluginStore.zig index 9410d5d96..f19bf176f 100644 --- a/app/store/PluginStore.zig +++ b/app/store/PluginStore.zig @@ -23,6 +23,7 @@ const store = @import("registry/store.zig"); const Page = @import("PluginPage.zig"); const PluginLoader = @import("PluginLoader.zig"); const PluginManager = @import("PluginManager.zig"); +const App = @import("../App.zig"); /// Pretend two installed plugins have store updates waiting, so the update flow (the app's /// offer, or the silent path's logging) can be exercised without an actually out-of-date /// plugin. A build-time flag: flip it, run, flip it back. @@ -406,6 +407,9 @@ const app = &PluginManager.current; pub fn register(manager: PluginManager) !void { PluginManager.current = manager; + // A link-only build has no store: nothing to fetch, nowhere to install to. The manager is + // still set, because settings name plugins through it (`displayName`). + if (comptime !App.loads_plugins) return; const host = manager.host; const url = resolveRegistryUrl(); const fp_hex = try std.fmt.allocPrint(manager.gpa, "0x{x}", .{dylib.abi_fingerprint}); @@ -1333,6 +1337,7 @@ fn offerPendingUpdates() void { /// before the Host-registry iterations, so a freshly-registered or unloaded plugin never /// mutates a list mid-iteration. pub fn tick() void { + if (comptime !App.loads_plugins) return; if (comptime builtin.target.cpu.arch == .wasm32) { if (catalog) |*c| c.pump(web_fetch); StoreIcon.pump(); diff --git a/build/app.zig b/build/app.zig index b3e4c9c17..b50b44caa 100644 --- a/build/app.zig +++ b/build/app.zig @@ -83,6 +83,7 @@ pub const Config = struct { static_workbench: bool, static_text: bool, static_image: bool, + dynamic_plugins: bool, workbench_opts: *std.Build.Step.Options, /// Null on the configure pass that fetches Velopack (see `readConfig`). msvcup_before_compile: ?*std.Build.Step.Run, @@ -227,6 +228,11 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options build_opts.addOption([]const u8, "app_repo_url", app_repo_url); build_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback); build_opts.addOption(bool, "velopack_enabled", velopack_enabled); + // Off, the app runs only the plugins it links (`buildApp`'s list): no plugin dylib is built, + // and nothing loads at runtime — not fizzy's own dylibs, not the user plugins directory, not + // the store, not a web page's `?plugin=`. Comptime, so none of it is checked per frame. + const dynamic_plugins = b.option(bool, "dynamic-plugins", "Load plugins at runtime: fizzy's own dylibs, the user plugins directory, the store (default: true)") orelse true; + build_opts.addOption(bool, "dynamic_plugins", dynamic_plugins); // A consumer that wants its own shape passes `-Dapp-layout=` (a LazyPath to // `pub fn layout(?*anyopaque, *Layout)`). Fizzy itself uses `src/editor/layout.zig`. @@ -303,6 +309,7 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options .static_workbench = static_workbench, .static_text = static_text, .static_image = static_image, + .dynamic_plugins = dynamic_plugins, .workbench_opts = workbench_opts, .msvcup_before_compile = msvcup_before_compile, .accesskit = accesskit, @@ -354,6 +361,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. const static_workbench = cfg.static_workbench; const static_text = cfg.static_text; const static_image = cfg.static_image; + const dynamic_plugins = cfg.dynamic_plugins; const workbench_opts = cfg.workbench_opts; const msvcup_before_compile = cfg.msvcup_before_compile; const accesskit = cfg.accesskit; @@ -377,7 +385,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. web.addSteps(b, optimize, build_opts, workbench_opts, assets_module, opts.app_plugins, opts.web_plugin_deps, opts.web_plugin_dirs); - const main_fizzy = try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, native_backend, build_opts, workbench_opts, assets_module, macos_sdl_paths, velopack_enabled, app_name, app_layout_path, opts.app_plugins); + const main_fizzy = try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, native_backend, build_opts, workbench_opts, assets_module, macos_sdl_paths, velopack_enabled, app_name, app_layout_path, opts.app_plugins, dynamic_plugins); const exe = main_fizzy.exe; const package_fizzy: FizzyExecutable = package_blk: { @@ -399,8 +407,9 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. pack_opts.addOption(bool, "static_workbench", static_workbench); pack_opts.addOption(bool, "static_text", static_text); pack_opts.addOption(bool, "static_image", static_image); + pack_opts.addOption(bool, "dynamic_plugins", dynamic_plugins); pack_opts.addOption(bool, "has_app_layout", app_layout_path != null); - break :package_blk try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, native_backend, pack_opts, workbench_opts, assets_module, macos_sdl_paths, true, app_name, app_layout_path, opts.app_plugins); + break :package_blk try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, native_backend, pack_opts, workbench_opts, assets_module, macos_sdl_paths, true, app_name, app_layout_path, opts.app_plugins, dynamic_plugins); }; const exe_for_package = package_fizzy.exe; diff --git a/build/exe.zig b/build/exe.zig index 4db12a42d..fb4a31d29 100644 --- a/build/exe.zig +++ b/build/exe.zig @@ -204,6 +204,8 @@ pub fn addFizzyExecutableForTarget( /// package (`plugin_dep.module("plugin")`). Listed in the generated `bundled_plugins` module /// under its plugin id. app_plugins: []const sdk.BundledPlugin, + /// False for a link-only app (`-Ddynamic-plugins=false`): no plugin dylib is built. + dynamic_plugins: bool, ) !FizzyExecutable { const native = nativeDvui(b, native_backend, resolved_target, optimize, accesskit, macos_sdl_paths); const dvui_dep = native.dep; @@ -341,7 +343,7 @@ pub fn addFizzyExecutableForTarget( exe.root_module.addImport("app_layout", app_layout_mod); } - const workbench_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { + const workbench_dylib: ?*std.Build.Step.Compile = if (dynamic_plugins and resolved_target.result.cpu.arch != .wasm32) blk: { break :blk workbench_plugin.addDylib(b, resolved_target, optimize, .{ .dvui = dvui_proxy_mod, .core = core_proxy_module, @@ -352,7 +354,7 @@ pub fn addFizzyExecutableForTarget( }, workbench_opts); } else null; - const text_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32 and plugins.linksFizzys(app_plugins, "text")) blk: { + const text_dylib: ?*std.Build.Step.Compile = if (dynamic_plugins and resolved_target.result.cpu.arch != .wasm32 and plugins.linksFizzys(app_plugins, "text")) blk: { break :blk text_plugin.addDylib(b, resolved_target, optimize, .{ .dvui = dvui_proxy_mod, .core = core_proxy_module, @@ -362,7 +364,7 @@ pub fn addFizzyExecutableForTarget( }); } else null; - const markdown_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32 and plugins.linksFizzys(app_plugins, "markdown")) blk: { + const markdown_dylib: ?*std.Build.Step.Compile = if (dynamic_plugins and resolved_target.result.cpu.arch != .wasm32 and plugins.linksFizzys(app_plugins, "markdown")) blk: { break :blk markdown_plugin.addDylib(b, resolved_target, optimize, .{ .dvui = dvui_proxy_mod, .core = core_proxy_module, @@ -371,7 +373,7 @@ pub fn addFizzyExecutableForTarget( }); } else null; - const image_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32 and plugins.linksFizzys(app_plugins, "image")) blk: { + const image_dylib: ?*std.Build.Step.Compile = if (dynamic_plugins and resolved_target.result.cpu.arch != .wasm32 and plugins.linksFizzys(app_plugins, "image")) blk: { break :blk image_plugin.addDylib(b, resolved_target, optimize, .{ .dvui = dvui_proxy_mod, .core = core_proxy_module, diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 21d552036..506095743 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -667,7 +667,7 @@ pub const view_settings = "fizzy.settings"; pub const workbench_files_view = workbench_mod.view_files; pub fn loadUserPlugins(editor: *Editor, config_folder: []const u8) void { - if (comptime builtin.target.cpu.arch == .wasm32) return; + if (comptime builtin.target.cpu.arch == .wasm32 or !App.loads_plugins) return; const plugins_dir = std.fs.path.join(editor.app.gpa, &.{ config_folder, "plugins" }) catch return; defer editor.app.gpa.free(plugins_dir); @@ -1424,7 +1424,7 @@ pub const UnloadError = error{ NotUnloadable, DirtyDocuments }; /// dvui/render-bridge sync path as `loadUserPlugins`. Caller ensures `id` is not already /// registered. On success the lib is appended to `loaded_plugin_libs`. pub fn loadUserPluginById(editor: *Editor, id: []const u8) !void { - if (comptime builtin.target.cpu.arch == .wasm32) return error.NotUnloadable; + if (comptime builtin.target.cpu.arch == .wasm32 or !App.loads_plugins) return error.NotUnloadable; const path = try App.userPluginPath(editor.app.gpa, &editor.app, id); const opened = PluginLoader.open(editor.app.gpa, path, id) catch |err| { editor.userPluginLoadFailed(id, path, err); @@ -1632,7 +1632,7 @@ const WebPluginRequest = struct { /// The page asks for a plugin: `?plugin=` on the URL (`plugins//.wasm` beside /// the app), or one it remembered from a store install (by its URL). export fn FizzyWebPluginRequest(id_ptr: [*]const u8, id_len: usize, url_ptr: [*]const u8, url_len: usize) void { - if (comptime builtin.target.cpu.arch != .wasm32) return; + if (comptime builtin.target.cpu.arch != .wasm32 or !App.loads_plugins) return; const editor = web_editor orelse return; const id = id_ptr[0..id_len]; // The id reaches this from the page's query string, and with no URL it is interpolated