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; }