From d8b303714e22c373f557d67dcab986d6f6867a19 Mon Sep 17 00:00:00 2001 From: Colton Franklin Date: Sat, 10 Oct 2026 22:29:25 +0000 Subject: [PATCH] web: a refused plugin shows why in the Installed pane The web's arrival path only logged a module the page could not load, the host refused (fingerprint, SDK version, id, entry points), or whose register() failed. It now records the failure the way the desktop's recordLoadFailure does, so the store's Installed pane and Settings show a failed card with the reason. A refused update in front of which the running build stays is only noted, as a failed reload is on the desktop. A web load that lands, or a web uninstall, clears the record. pluginLoadFailureReason keys its reasons by error name so it compiles against either loader's error set, and stays exhaustive. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012tKz7NTwCwAPSmfg3DjHDX --- app/App.zig | 41 ++++++++++++++++++++++------------ app/store/PluginLoader_web.zig | 11 +++++++++ src/editor/Editor.zig | 26 ++++++++++++++++++++- 3 files changed, 63 insertions(+), 15 deletions(-) diff --git a/app/App.zig b/app/App.zig index 2550654ed..bf7501150 100644 --- a/app/App.zig +++ b/app/App.zig @@ -886,25 +886,38 @@ pub fn themeFilenameToName(trimmed: []const u8) ?[]const u8 { return null; } -/// Human-readable, actionable explanation for a `PluginLoader.LoadError`. +/// Human-readable, actionable explanation for a `PluginLoader.LoadError`. The desktop and web +/// loaders have different error sets that share a name wherever they mean the same thing, so the +/// reasons are kept by name (`load_failure_reasons`): an error either loader gains without a +/// reason there is a compile error, as a missing switch prong would be. pub fn pluginLoadFailureReason(err: PluginLoader.LoadError) []const u8 { return switch (err) { - error.AbiMismatch => "built against an incompatible Fizzy SDK — rebuild the plugin against this Fizzy build", - error.AbiBuildEnvMismatch => "SDK versions match, but optimize mode does not match", - error.SdkVersionMismatch => "requires a newer Fizzy SDK — update Fizzy or install a matching plugin build", - error.PluginIdMismatch => "plugin id in the dylib does not match its filename — rename the file or fix manifest.id", - error.DylibOpenFailed => "the plugin library could not be opened (missing file, wrong architecture, or unresolved symbols)", - error.RegisterRejected => "the plugin's register() was rejected (often a duplicate plugin id — a built-in or another plugin already claims it)", - error.AbiFingerprintSymbolMissing, - error.RegisterSymbolMissing, - error.SetGlobalsSymbolMissing, - error.SetDvuiContextSymbolMissing, - error.SetRenderBridgeSymbolMissing, - error.SdkVersionSymbolMissing, - => "the plugin is missing required entry symbols — rebuild it from a current root.zig template", + inline else => |e| @field(load_failure_reasons, @errorName(e)), }; } +const missing_symbols_reason = "the plugin is missing required entry symbols — rebuild it from a current root.zig template"; + +const load_failure_reasons = .{ + .AbiMismatch = "built against an incompatible Fizzy SDK — rebuild the plugin against this Fizzy build", + .AbiBuildEnvMismatch = "SDK versions match, but optimize mode does not match", + .SdkVersionMismatch = "requires a newer Fizzy SDK — update Fizzy or install a matching plugin build", + .PluginIdMismatch = "plugin id in the dylib does not match its filename — rename the file or fix manifest.id", + .DylibOpenFailed = "the plugin library could not be opened (missing file, wrong architecture, or unresolved symbols)", + .RegisterRejected = "the plugin's register() was rejected (often a duplicate plugin id — a built-in or another plugin already claims it)", + .AbiFingerprintSymbolMissing = missing_symbols_reason, + .RegisterSymbolMissing = missing_symbols_reason, + .SetGlobalsSymbolMissing = missing_symbols_reason, + .SetDvuiContextSymbolMissing = missing_symbols_reason, + .SetRenderBridgeSymbolMissing = missing_symbols_reason, + .SdkVersionSymbolMissing = missing_symbols_reason, + // The web loader's own: a linked module without an entry point, a build that cannot load + // plugins at all, and an allocation failing on the way. + .LoadFailed = missing_symbols_reason, + .Unsupported = "this build of Fizzy cannot load plugins", + .OutOfMemory = "ran out of memory while loading", +}; + pub fn formatPluginProbeDetail(allocator: std.mem.Allocator, info: PluginLoader.PluginVersionInfo) ![]const u8 { return std.fmt.allocPrint(allocator, "plugin {d}.{d}.{d}, min SDK {d}.{d}.{d}", .{ info.plugin_version.major, diff --git a/app/store/PluginLoader_web.zig b/app/store/PluginLoader_web.zig index 827c95f21..954afb280 100644 --- a/app/store/PluginLoader_web.zig +++ b/app/store/PluginLoader_web.zig @@ -232,6 +232,17 @@ fn readVersionTriplet(get_fn: ?dylib_api.GetSdkVersionFn) std.SemanticVersion { return .{ .major = 0, .minor = 0, .patch = 0 }; } +/// The versions a linked module declares, read whether or not `prepare` accepts it — the web's +/// `probeVersionInfo`, so a refused build is reported with the same detail as one on disk. Only +/// the triplet getters run, which `prepare` already calls on a module whose fingerprint is wrong. +pub fn versionInfo(lib: WebDynLib) PluginVersionInfo { + return .{ + .plugin_version = readVersionTriplet(lib.lookup(dylib_api.GetSdkVersionFn, dylib_api.symbol_plugin_version)), + .built_with_sdk_version = readVersionTriplet(lib.lookup(dylib_api.GetSdkVersionFn, dylib_api.symbol_sdk_version)), + .min_sdk_version = readVersionTriplet(lib.lookup(dylib_api.GetSdkVersionFn, dylib_api.symbol_min_sdk_version)), + }; +} + /// A linked module that has passed every check and has not yet been registered. Split from /// `register` so an update can find out whether the new build is acceptable *before* the running /// one is torn down: everything that can reject a module (fingerprint, SDK version, declared id, diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index fbac5cffe..449c532cd 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -1513,6 +1513,7 @@ const WebPluginRequest = struct { } const lib = arrival.lib orelse { dvui.log.err("web plugin '{s}' ({s}): the page could not load it", .{ req.id, req.url }); + req.failed("the page could not fetch or link the plugin's module (offline, a changed download, or not a plugin)", null); gpa.free(req.url); return; }; @@ -1521,6 +1522,7 @@ const WebPluginRequest = struct { // replacing is still running and still owns its documents. const ready = PluginLoader.prepare(req.url, req.id, lib) catch |err| { dvui.log.err("web plugin '{s}' ({s}): refused: {s}", .{ req.id, req.url, @errorName(err) }); + req.failed(App.pluginLoadFailureReason(err), lib); gpa.free(req.url); return; }; @@ -1543,6 +1545,7 @@ const WebPluginRequest = struct { .arg_c = null, }) catch |err| { dvui.log.err("web plugin '{s}' ({s}): register failed: {s}", .{ req.id, req.url, @errorName(err) }); + req.failed(App.pluginLoadFailureReason(err), lib); gpa.free(req.url); return; }; @@ -1551,6 +1554,8 @@ const WebPluginRequest = struct { return; }; registered = true; + // As `loadUserPluginById` does: a build that loads retires any failure an earlier one left. + editor.app.clearFailedUserPlugin(req.id); App.syncLoadedPluginDvuiContexts(&editor.app); App.syncLoadedPluginRenderBridge(&editor.app); for (editor.app.host.plugins.items) |p| { @@ -1571,6 +1576,23 @@ const WebPluginRequest = struct { dvui.log.info("web plugin '{s}' loaded from {s}", .{ req.id, req.url }); editor.app.host.refresh(); } + + /// This build did not take. With no build of this id running (a remembered build coming + /// back, a first install, a repair), the store's Installed pane shows it as failed and says + /// why, as the desktop does for a build on disk (`App.recordLoadFailure`) — before, the web + /// said so only in the console. An update the running build stays in front of is only noted, + /// as a failed reload is on the desktop; its row in the update window turns to Retry + /// (`PluginStore.webLoadFailed`). + fn failed(req: *const WebPluginRequest, reason: []const u8, lib: ?PluginLoader.WebDynLib) void { + const app = &req.editor.app; + if (app.host.pluginById(req.id) != null) return app.noteLoadFailed(req.id, reason); + const info: ?PluginLoader.PluginVersionInfo = if (lib) |l| PluginLoader.versionInfo(l) else null; + const detail: ?[]const u8 = if (info) |i| App.formatPluginProbeDetail(app.gpa, i) catch null else null; + defer if (detail) |d| app.gpa.free(d); + // `recordPluginFailure` appends: one card per id however many times it is retried. + app.clearFailedUserPlugin(req.id); + app.recordPluginFailure(req.id, reason, detail, if (info) |i| i.plugin_version else null, .{}); + } }; /// The page asks for a plugin: `?plugin=` on the URL (`plugins//.wasm` beside @@ -1934,9 +1956,11 @@ pub fn uninstallPlugin(editor: *Editor, id: []const u8, force: bool) !void { error.NotUnloadable => {}, // already gone else => return err, }; - // As on the desktop: a reinstall asks again (see `clearPluginOwnershipRecord`). + // As on the desktop: a reinstall asks again (see `clearPluginOwnershipRecord`), and a + // refused build's card goes with it. editor.app.untrackDisabledPlugin(id); editor.clearPluginOwnershipRecord(id); + editor.app.clearFailedUserPlugin(id); editor.rebuildExtensionOwnerCache(); return; }