From 15e558ac19b5f3395a7f48e314a4487baea02df8 Mon Sep 17 00:00:00 2001 From: foxnne Date: Sun, 18 Oct 2026 11:19:22 -0500 Subject: [PATCH 1/2] sdk: a plugin has a data folder of its own host.pluginDataFolder(plugin): /plugin-data/, the id in lowercase, for a registered plugin. Plugin ids are unique ignoring ASCII case: registerPlugin refuses Graphl beside graphl, and the loaders ask the new pluginByIdIgnoringCase before they open a build, putting the clash on its failure card; everything keyed by a loaded id stays exact. The app sets each plugin's entry up as it registers (EditorAPI.pluginRegistered, main thread) and marks it gone after the plugin's deinit, so asks from any thread read only that table under a lock; the folder is made on the first ask and checked again at most once a second, and a folder that can't be made is logged once and retried after a few seconds. App.deinit frees what the App owns. Apart from plugins/, which an update replaces and an uninstall deletes; uninstall leaves it, as it leaves settings. On the web, as with paletteFolder, the path is the prefix core.fs files under in the page's storage. Fingerprint recorded. Part of #530 Co-Authored-By: Claude Opus 5.5 --- app/App.zig | 160 ++++++++++++++++++++++++++++++++++++++++++ docs/PLUGINS.md | 25 +++++++ sdk/src/EditorAPI.zig | 13 ++++ sdk/src/Host.zig | 71 ++++++++++++++++++- sdk/src/version.zig | 2 +- src/editor/Editor.zig | 44 +++++++++--- tests/integration.zig | 81 +++++++++++++++++++++ 7 files changed, 383 insertions(+), 13 deletions(-) diff --git a/app/App.zig b/app/App.zig index b68c50d6a..5eebcf386 100644 --- a/app/App.zig +++ b/app/App.zig @@ -75,6 +75,13 @@ config_folder: []const u8, palette_folder: []const u8, +/// Each registered plugin's data folder (`pluginDataFolder`), by lowercased id, filled on the +/// main thread as plugins register (`pluginRegistered`). Key and path owned, and kept after the +/// plugin goes, so a path handed out stays good for the app's life. Guarded by +/// `plugin_data_lock`: a plugin may ask from a worker thread (an indexer, an LSP client). +plugin_data_folders: std.StringHashMapUnmanaged(PluginDataFolder) = .empty, +plugin_data_lock: std.Io.Mutex = .init, + /// Plugin registry + service locator exposed to plugins host: Host, @@ -759,6 +766,159 @@ pub fn releaseRetiredFolders(app: *App) void { app.folder_retired.clearRetainingCapacity(); } +pub const PluginDataFolder = struct { + /// Null for an id that names no folder (`isValidPluginId`: `fizzy.store` has a dot in it). + path: ?[]const u8, + /// Its plugin is registered now. A path stays in the table after its plugin goes, since a + /// caller may hold it, but is not handed out again until the plugin is back. + registered: bool = true, + /// When the folder was last found or made, on `std.Io.Clock.awake`: an ask within + /// `plugin_data_recheck_ns` of it trusts it is there. + made_at: ?i96 = null, + /// When making it last failed (a read-only or full disk): asks within `plugin_data_retry_ns` + /// of it get null without trying. + failed_at: ?i96 = null, + /// Something was said about this folder (an id that names none, a run of failures), so it + /// is not said again until things change. + said: bool = false, +}; + +/// How long a found or made plugin data folder is trusted to be there before an ask makes sure. +pub const plugin_data_recheck_ns: i96 = std.time.ns_per_s; +/// How long a plugin data folder that could not be made is left before it is tried again. +pub const plugin_data_retry_ns: i96 = 5 * std.time.ns_per_s; + +/// The key a plugin's data folder is kept under: its id in lowercase, in `buf`. Null for an id +/// longer than any valid one, which then has no folder and no entry. +fn pluginDataKey(buf: *[64]u8, id: []const u8) ?[]const u8 { + if (id.len > buf.len) return null; + return std.ascii.lowerString(buf, id); +} + +/// `plugin_data_lock`, which the web, single-threaded and with `std.Io`'s mutex unavailable to +/// it, goes without. +fn lockPluginData(app: *App) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + app.plugin_data_lock.lockUncancelable(dvui.io); +} + +fn unlockPluginData(app: *App) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + app.plugin_data_lock.unlock(dvui.io); +} + +/// A plugin registered (`Host.registerPlugin`, through `EditorAPI.pluginRegistered`), on the main +/// thread: its data folder's entry is made ready here, so `pluginDataFolder`, asked from any +/// thread, never walks the host's plugin list. +pub fn pluginRegistered(app: *App, id: []const u8) void { + var buf: [64]u8 = undefined; + const name = pluginDataKey(&buf, id) orelse return; + app.lockPluginData(); + defer app.unlockPluginData(); + if (app.plugin_data_folders.getPtr(name)) |f| { + f.registered = true; + return; + } + const key = app.gpa.dupe(u8, name) catch return std.log.warn("plugin '{s}' data folder: out of memory", .{id}); + const path: ?[]const u8 = if (isValidPluginId(id)) + std.fs.path.join(app.gpa, &.{ app.config_folder, "plugin-data", name }) catch { + app.gpa.free(key); + return std.log.warn("plugin '{s}' data folder: out of memory", .{id}); + } + else + null; + app.plugin_data_folders.put(app.gpa, key, .{ .path = path }) catch { + app.gpa.free(key); + if (path) |p| app.gpa.free(p); + std.log.warn("plugin '{s}' data folder: out of memory", .{id}); + }; +} + +/// A plugin went (unloaded, disabled, uninstalled, replaced by a reload), after its `deinit`, +/// which may still have written into its folder: the folder is not handed out until it is back. +/// Its entry stays, since a worker may still hold the path. +pub fn pluginGone(app: *App, id: []const u8) void { + var buf: [64]u8 = undefined; + const name = pluginDataKey(&buf, id) orelse return; + app.lockPluginData(); + defer app.unlockPluginData(); + if (app.plugin_data_folders.getPtr(name)) |f| f.registered = false; +} + +/// `/plugin-data/` for the registered plugin `id` (`Host.pluginDataFolder`), the id +/// in lowercase, which ids are unique in (`Host.registerPlugin`), so the folder is the same on a +/// case-sensitive filesystem as on macOS and Windows. The path is borrowed for the app's life. +/// +/// Made on the first ask, and made sure of again on an ask a second or more after the last: a +/// folder deleted while the app runs comes back then, and a caller whose write into a kept path +/// fails with `FileNotFound` asks again (a second later, if it asked just now). Safe from any +/// thread: the table is read under `plugin_data_lock`, the folder made outside it. +/// +/// Separate from `plugins/`, which an update replaces and an uninstall deletes. On the web +/// nothing is made, as there are no directories: the path is the prefix `core.fs` files under in +/// the page's storage, as `palette_folder` is. Null for an id no registered plugin has, for one +/// that is not a plain identifier (said once), or when the folder can't be made: said once for +/// each run of failures, and tried again on an ask `plugin_data_retry_ns` after the last. +pub fn pluginDataFolder(app: *App, id: []const u8) ?[]const u8 { + var buf: [64]u8 = undefined; + const name = pluginDataKey(&buf, id) orelse return null; + const is_wasm = comptime builtin.target.cpu.arch == .wasm32; + const path = path: { + app.lockPluginData(); + defer app.unlockPluginData(); + const f = app.plugin_data_folders.getPtr(name) orelse return null; + if (!f.registered) return null; + const path = f.path orelse { + if (!f.said) std.log.warn("plugin '{s}' has no data folder: its id is not a plain identifier (letters, digits, '-', '_')", .{id}); + f.said = true; + return null; + }; + if (is_wasm) return path; + const now = std.Io.Clock.awake.now(dvui.io).nanoseconds; + if (f.failed_at) |at| if (now - at < plugin_data_retry_ns) return null; + if (f.made_at) |at| if (now - at < plugin_data_recheck_ns) return path; + break :path path; + }; + + const made = std.Io.Dir.cwd().createDirPath(dvui.io, path); + const now = std.Io.Clock.awake.now(dvui.io).nanoseconds; + app.lockPluginData(); + defer app.unlockPluginData(); + const f = app.plugin_data_folders.getPtr(name) orelse return null; + if (made) |_| { + f.made_at = now; + f.failed_at = null; + f.said = false; + // Its plugin may have gone while the folder was made. + return if (f.registered) path else null; + } else |err| { + // Said once for a run of failures: an ask every frame would say it every frame. + if (!f.said) std.log.warn("plugin data folder {s}: {s}", .{ path, @errorName(err) }); + f.said = true; + f.made_at = null; + f.failed_at = now; + return null; + } +} + +/// What the app itself owns and frees: the plugin data folders and the per-plugin id lists. +/// Fizzy's `Editor.deinit` calls it; an app running `App` without the editor calls it at its own +/// teardown. +pub fn deinit(app: *App) void { + var it = app.plugin_data_folders.iterator(); + while (it.next()) |e| { + app.gpa.free(e.key_ptr.*); + if (e.value_ptr.path) |p| app.gpa.free(p); + } + app.plugin_data_folders.deinit(app.gpa); + for (app.disabled_plugin_ids.items) |id| app.gpa.free(id); + app.disabled_plugin_ids.deinit(app.gpa); + for (app.undecided_plugin_ids.items) |id| app.gpa.free(id); + app.undecided_plugin_ids.deinit(app.gpa); + for (app.auto_update_off_ids.items) |id| app.gpa.free(id); + app.auto_update_off_ids.deinit(app.gpa); +} + /// `/plugins//.` — where a user plugin's build lives. Caller frees. pub fn userPluginPath(gpa: std.mem.Allocator, app: *App, id: []const u8) ![]u8 { const file_name = try PluginLoader.pluginFilename(id, gpa); diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index ef2f78328..0140c913a 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -876,6 +876,31 @@ Your own state is just a variable you own — a module-level `var`, adopted into during `register` and read back via `@ptrCast` in your vtable functions. There is no separate `Globals`/storage file to write. +What a plugin keeps **between runs** that is not a setting — its recents, an index, a cache — +goes in **`host.pluginDataFolder(&plugin)`**: `/plugin-data/`, the id in lowercase. +Plugin ids are unique ignoring case (`host.registerPlugin` refuses `Graphl` beside `graphl`, and +a loader asks `host.pluginByIdIgnoringCase` before it opens a build), so the folder is the same +on every filesystem. It follows the +app's config folder, so a `FIZZY_PROFILE` sandbox gets its own, and it is not `plugins/`, the +folder the plugin is installed in, which an update replaces and an uninstall deletes. It is +always local, whether or not the open folder is on a mount (§3.12). + +- The path is borrowed for the life of the app. The folder is made on the first call and made + sure of again on a call a second or more after the last, so a plugin whose write into a kept + path fails with `FileNotFound` calls again (a second later, if it called just now). Safe from + any thread (an indexer, an LSP client): the app sets each plugin's entry up as it registers. +- Null when no app is installed (a unit test's bare `Host`), when the plugin is not registered, + when its id is not a plain identifier (letters, digits, `-`, `_`), or when the folder could not + be made: logged once, and tried again on a call a few seconds later. +- **On the web**, as with `paletteFolder`, the path names no directory: it is the prefix under + which `core.fs.read`, `readZ`, `write` and `remove` keep files in the page's storage. Those + four calls do the same on the desktop, so a plugin that uses them needs no web case; + `std.Io.Dir` calls on the path fail there. +- **Uninstall leaves it**, as it leaves the plugin's settings, so a reinstall picks up where it + was. Removing it too would be an option of the app's uninstall (fizzy's + `Editor.uninstallPlugin`), not built yet. +- A credential goes in `host.setSecret` instead, never in a file here. + ### 3.4 Commands — how a plugin contributes its *own* features Anything a plugin **invokes** rather than implements as a Fizzy callback — plugin-specific diff --git a/sdk/src/EditorAPI.zig b/sdk/src/EditorAPI.zig index ed07e9559..a711c427b 100644 --- a/sdk/src/EditorAPI.zig +++ b/sdk/src/EditorAPI.zig @@ -151,6 +151,15 @@ pub const VTable = struct { folder: *const fn (ctx: *anyopaque) ?[]const u8, /// The user palettes folder (config), or null on platforms without one (web). paletteFolder: *const fn (ctx: *anyopaque) ?[]const u8, + /// The folder kept for the registered plugin `id`'s own files — see `Host.pluginDataFolder`. + /// Called from any thread; reads only what `pluginRegistered` made ready. Null for an id no + /// registered plugin has, or when it can't be made. + pluginDataFolder: *const fn (ctx: *anyopaque, id: []const u8) ?[]const u8, + /// A plugin registered, on the main thread (`Host.registerPlugin`): what the app keeps per + /// plugin is made ready here, so asks from other threads never walk the host's plugin list. + /// Its going is the app's own to note, after the plugin's `deinit` (which may still use what + /// it kept). Null where the app keeps nothing per plugin. + pluginRegistered: ?*const fn (ctx: *anyopaque, id: []const u8) void = null, /// Mark fizzy's settings dirty so the debounced autosave persists them. markSettingsDirty: *const fn (ctx: *anyopaque) void, /// A credential kept for a plugin, by key (`.`), outside `settings.zon` — @@ -462,6 +471,10 @@ pub fn paletteFolder(self: EditorAPI) ?[]const u8 { return self.vtable.paletteFolder(self.ctx); } +pub fn pluginDataFolder(self: EditorAPI, id: []const u8) ?[]const u8 { + return self.vtable.pluginDataFolder(self.ctx, id); +} + pub fn markSettingsDirty(self: EditorAPI) void { self.vtable.markSettingsDirty(self.ctx); } diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index 0658b4f91..4b953451f 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -430,6 +430,33 @@ pub fn paletteFolder(self: *Host) ?[]const u8 { return if (self.fizzy_api) |a| a.paletteFolder() else null; } +/// A folder of `plugin`'s own for what it keeps between runs that is not a setting: its +/// recents, an index, a cache. It is `/plugin-data/`, the id in lowercase (ids are +/// unique ignoring case: `registerPlugin`). It follows the app's config folder, so a profile has +/// its own, and it is not `plugins/`, the folder the plugin is installed in, which an update +/// replaces and an uninstall deletes. +/// +/// The path is borrowed for the life of the app. The folder is made on the first call and made +/// sure of again on a call a second or more after the last, so one deleted while the app runs +/// comes back then: a plugin whose write into a kept path fails with `FileNotFound` calls again +/// (a second later, if it called just now). Safe to call from any thread. +/// +/// Null when no app is installed, when `plugin` is not registered, when its id is not a plain +/// identifier (letters, digits, `-`, `_`; logged once), or when the folder could not be made +/// (logged once, and tried again on a call a few seconds later). +/// +/// On the web, as with `paletteFolder`, the path names no directory: it is the prefix under +/// which `core.fs.read`, `readZ`, `write` and `remove` keep files in the page's storage. Those +/// four calls work on both, so a plugin that writes `/recents.zon` through them needs +/// no web case; `std.Io.Dir` calls on the path fail on the web. +/// +/// Uninstalling a plugin leaves this folder, as it leaves the plugin's settings, so a reinstall +/// picks up where it was. Removing it too would be an option of the app's uninstall (fizzy's +/// `Editor.uninstallPlugin`), not built yet. +pub fn pluginDataFolder(self: *Host, plugin: *const Plugin) ?[]const u8 { + return if (self.fizzy_api) |a| a.pluginDataFolder(plugin.id) else null; +} + /// A credential the plugin keeps on the user's behalf — a refresh token, an API key — stored /// by the app outside `settings.zon` (which is exported, diffed, watched and drawn in a pane): /// a `0600` file today, the OS keychain behind the same call later. Keys are plugin-namespaced @@ -928,9 +955,18 @@ pub fn pluginInstallDir(self: *Host, id: []const u8) ?[]u8 { /// Rejects a second plugin claiming an already-registered `id` so routing can never become /// ambiguous — the dylib loader turns this into a failed load the user is told about /// (built-in ids always win, since they register first). +/// +/// The same id in another case (`Graphl` beside `graphl`) is a duplicate too: ids name folders +/// (`pluginDataFolder`, the install folder), and macOS and Windows keep one folder for both. pub fn registerPlugin(self: *Host, plugin: *Plugin) !void { - if (self.pluginById(plugin.id) != null) return error.DuplicatePluginId; + if (self.pluginByIdIgnoringCase(plugin.id)) |p| { + // The loader reports a duplicate by its id; this one it could not explain. + if (!std.mem.eql(u8, p.id, plugin.id)) + std.log.warn("plugin '{s}' not registered: '{s}' is registered already, and ids differing only in case are one id", .{ plugin.id, p.id }); + return error.DuplicatePluginId; + } try self.plugins.append(self.allocator, plugin); + if (self.fizzy_api) |a| if (a.vtable.pluginRegistered) |registered| registered(a.ctx, plugin.id); } /// Remove every contribution, service, and registry entry owned by `plugin`, then drop @@ -1063,7 +1099,7 @@ fn removeOwnedSettingsSchemas(list: *std.ArrayListUnmanaged(SettingsSchema), plu list.items.len = w; } -/// Lookup a registered plugin by stable id (`"pixi"`, `"workbench"`, …). +/// Lookup a registered plugin by stable id (`"pixi"`, `"workbench"`, …), exactly as written. pub fn pluginById(self: *Host, id: []const u8) ?*Plugin { for (self.plugins.items) |plugin| { if (std.mem.eql(u8, plugin.id, id)) return plugin; @@ -1071,6 +1107,17 @@ pub fn pluginById(self: *Host, id: []const u8) ?*Plugin { return null; } +/// The registered plugin whose id is `id` in some case — `Graphl` finds `graphl` — which +/// `registerPlugin` would refuse `id` beside. For a loader to ask before it opens a build, so a +/// case variant fails with that said, not as a load that fails later. Everything keyed by an id +/// once loaded (the loaded builds, settings, the store's lists) uses `pluginById`. +pub fn pluginByIdIgnoringCase(self: *Host, id: []const u8) ?*Plugin { + for (self.plugins.items) |plugin| { + if (std.ascii.eqlIgnoreCase(plugin.id, id)) return plugin; + } + return null; +} + /// An open document's unsaved contents settled after an edit: announces `fizzy.document.changed` /// (`events.Path`). Called by the document's **owner** after a short lull in typing (a few hundred /// ms) and on save, never per keystroke. This is how a plugin that owns nothing (a link indexer, @@ -2676,6 +2723,23 @@ test "pluginForExtension resolves by assignment, unique claimant, then fallback" try testing.expectEqual(@as(?*Plugin, null), host.pluginForExtension(".zig")); } +test "registerPlugin refuses an id already registered, in any case" { + const log_level = testing.log_level; + testing.log_level = .err; + defer testing.log_level = log_level; + + var host = Host.init(testing.allocator); + defer host.deinit(); + const vt = Plugin.VTable{}; + var first = Plugin{ .state = undefined, .vtable = &vt, .id = "graphl", .display_name = "Graphl" }; + var same = Plugin{ .state = undefined, .vtable = &vt, .id = "graphl", .display_name = "Graphl" }; + var cased = Plugin{ .state = undefined, .vtable = &vt, .id = "GraphL", .display_name = "GraphL" }; + try host.registerPlugin(&first); + try testing.expectError(error.DuplicatePluginId, host.registerPlugin(&same)); + try testing.expectError(error.DuplicatePluginId, host.registerPlugin(&cased)); + try testing.expectEqual(@as(usize, 1), host.plugins.items.len); +} + test "pluginForExtension honors a user assignment and ignores a stale one" { const Fake = struct { var override: ?[]const u8 = null; @@ -2708,6 +2772,7 @@ test "pluginForExtension honors a user assignment and ignores a stale one" { // Only the one vtable member `pluginForExtension` reaches; the rest stay unreachable here. var api_vt: EditorAPI.VTable = undefined; + api_vt.pluginRegistered = null; api_vt.extensionOwnerOverride = Fake.extensionOwnerOverride; var ctx: u8 = 0; host.installFizzyApi(.{ .ctx = &ctx, .vtable = &api_vt }); @@ -3098,6 +3163,7 @@ test "a claim on a document holds an override of its owner's command too" { }; S.owner = &pixi; var api_vt: EditorAPI.VTable = undefined; + api_vt.pluginRegistered = null; api_vt.activeDoc = S.active; api_vt.refresh = S.refresh; var ctx: u8 = 0; @@ -3405,6 +3471,7 @@ test "commands with arguments: callCommand, runCommand's fallback, and listing" // ...and something required has the app ask for it. var api_vt: EditorAPI.VTable = undefined; + api_vt.pluginRegistered = null; api_vt.askCommandArguments = S.ask; var ctx: u8 = 0; host.installFizzyApi(.{ .ctx = &ctx, .vtable = &api_vt }); diff --git a/sdk/src/version.zig b/sdk/src/version.zig index 5ed391deb..b25748508 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0xcd68cc1a32bd51c9; +pub const recorded_sdk_shape_fingerprint: u64 = 0xe27832190e059544; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 2fe1d91e7..a0076ba23 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -740,7 +740,7 @@ pub fn loadUserPlugins(editor: *Editor, config_folder: []const u8) void { defer editor.app.gpa.free(file_name); const path = std.fs.path.join(editor.app.gpa, &.{ plugins_dir, plugin_id, file_name }) catch continue; - if (editor.app.host.pluginById(plugin_id) != null) { + if (editor.app.host.pluginByIdIgnoringCase(plugin_id)) |loaded| { // A fizzy built-in (`text`/`workbench`/`image`/`markdown`) is loaded via its own // static/dylib path (see `postInit` above) and may *also* have its dylib sitting in // this same shared plugins directory — built-ins compile "the same third-party @@ -751,7 +751,9 @@ pub fn loadUserPlugins(editor: *Editor, config_folder: []const u8) void { // plugin), which collides on the shared heading widget id. Only a genuine third-party // id clash (some other plugin trying to claim an id a built-in already owns) is // worth surfacing. - if (isBundledPluginId(plugin_id)) { + if (!std.mem.eql(u8, loaded.id, plugin_id)) { + editor.caseClash(plugin_id, loaded.id, path); + } else if (isBundledPluginId(plugin_id)) { dvui.log.info("user plugin '{s}': already loaded as a built-in; skipped", .{plugin_id}); } else { dvui.log.err("user plugin '{s}': id already registered by a built-in; skipped", .{plugin_id}); @@ -826,14 +828,7 @@ fn unloadPluginLibs(editor: *Editor) void { editor.app.failed_user_plugins.deinit(editor.app.gpa); editor.app.deinitPluginLoads(); - for (editor.app.disabled_plugin_ids.items) |id| editor.app.gpa.free(id); - editor.app.disabled_plugin_ids.deinit(editor.app.gpa); - - for (editor.app.undecided_plugin_ids.items) |id| editor.app.gpa.free(id); - editor.app.undecided_plugin_ids.deinit(editor.app.gpa); - - for (editor.app.auto_update_off_ids.items) |id| editor.app.gpa.free(id); - editor.app.auto_update_off_ids.deinit(editor.app.gpa); + // The id lists (disabled, undecided, auto-update off) are the App's: `App.deinit`. { var it = editor.app.plugin_flags_pending.iterator(); @@ -1438,6 +1433,12 @@ pub const UnloadError = error{ NotUnloadable, DirtyDocuments }; pub fn loadUserPluginById(editor: *Editor, id: []const u8) !void { 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); + // Asked before the build is opened: a case variant of a loaded id fails saying so. + if (editor.app.host.pluginByIdIgnoringCase(id)) |loaded| if (!std.mem.eql(u8, loaded.id, id)) { + editor.caseClash(id, loaded.id, path); + editor.app.gpa.free(path); + return error.DuplicatePluginId; + }; const opened = PluginLoader.open(editor.app.gpa, path, id) catch |err| { editor.userPluginLoadFailed(id, path, err); editor.app.gpa.free(path); @@ -1479,6 +1480,17 @@ pub fn finishUserPluginLoad(editor: *Editor, id: []const u8, opened: PluginLoade /// Leave the same actionable record the startup scan leaves (see `recordLoadFailure`), so a build /// that fails a live load stays visible in the store's installed pane with its Reinstall/Uninstall /// controls instead of disappearing until the next restart. +/// The build at `path` declares `id`, the id of the loaded plugin `loaded_id` in another case: +/// one folder on macOS and Windows, so one plugin (`Host.registerPlugin`). Not loaded, and the +/// store's failure card says why. +fn caseClash(editor: *Editor, id: []const u8, loaded_id: []const u8, path: []const u8) void { + const why = std.fmt.allocPrint(editor.app.arena.allocator(), "id '{s}' differs only in case from the loaded plugin '{s}'", .{ id, loaded_id }) catch "id differs only in case from a loaded plugin"; + dvui.log.warn("user plugin '{s}': {s}; skipped", .{ id, why }); + const probe = PluginLoader.probeVersionInfo(path); + editor.app.clearFailedUserPlugin(id); + editor.app.recordPluginFailure(id, why, null, if (probe) |info| info.plugin_version else null, .of(path)); +} + fn userPluginLoadFailed(editor: *Editor, id: []const u8, path: []const u8, err: PluginLoader.LoadError) void { dvui.log.err("user plugin '{s}' ({s}): load failed: {s} — {s}", .{ id, path, @errorName(err), App.pluginLoadFailureReason(err) }); editor.app.recordLoadFailure(id, path, err); @@ -1896,6 +1908,8 @@ pub fn unloadPlugin(editor: *Editor, id: []const u8, opts: UnloadOptions) Unload editor.app.host.unregisterPlugin(plugin); fizzy.backend.rebuildDynamicNativeMenus(); plugin.deinit(); + // After `deinit`, which may still write into its data folder. + editor.app.pluginGone(id); // Drop the unloaded plugin's keybinds by rebuilding from the survivors. rebuildKeybinds(editor); @@ -2361,6 +2375,8 @@ const fizzy_api_vtable: sdk.EditorAPI.VTable = .{ .extensionOwnerOverride = fizzyExtensionOwnerOverride, .folder = fizzyFolder, .paletteFolder = fizzyPaletteFolder, + .pluginDataFolder = fizzyPluginDataFolder, + .pluginRegistered = fizzyPluginRegistered, .getSecret = fizzyGetSecret, .setSecret = fizzySetSecret, .markSettingsDirty = fizzyMarkSettingsDirty, @@ -2527,6 +2543,12 @@ fn fizzyFolder(ctx: *anyopaque) ?[]const u8 { fn fizzyPaletteFolder(ctx: *anyopaque) ?[]const u8 { return fizzyCtx(ctx).app.palette_folder; } +fn fizzyPluginDataFolder(ctx: *anyopaque, id: []const u8) ?[]const u8 { + return fizzyCtx(ctx).app.pluginDataFolder(id); +} +fn fizzyPluginRegistered(ctx: *anyopaque, id: []const u8) void { + fizzyCtx(ctx).app.pluginRegistered(id); +} fn fizzyGetSecret(ctx: *anyopaque, key: []const u8) ?[]const u8 { return fizzyCtx(ctx).app.secrets.get(key); } @@ -6509,6 +6531,8 @@ pub fn rawCloseFileID(editor: *Editor, id: u64) !void { } pub fn deinit(editor: *Editor) !void { + // Freed however this ends: the fallible steps below may return early. + defer editor.app.deinit(); // A demo playing at quit leaves the user's layout and settings as `Demo.begin` flushed them: // nothing it changed is written below. const demo_was_active = editor.demo.active(); diff --git a/tests/integration.zig b/tests/integration.zig index 3ca3196a6..c51c27e99 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7348,6 +7348,87 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try editor.postInit(); try dvui.testing.settle(headlessFrame); try std.testing.expectEqualStrings(root, editor.app.config_folder); + // A plugin's data folder (`Host.pluginDataFolder`): under the profile, made on the first ask, + // the same borrowed path after, one per plugin. + { + const host = &editor.app.host; + const text_plugin = host.pluginById("text") orelse return error.TestUnexpectedResult; + const markdown_plugin = host.pluginById("markdown") orelse return error.TestUnexpectedResult; + const data = host.pluginDataFolder(text_plugin) orelse return error.TestUnexpectedResult; + const want = try std.fs.path.join(gpa, &.{ root, "plugin-data", "text" }); + defer gpa.free(want); + try std.testing.expectEqualStrings(want, data); + var dir = try std.Io.Dir.cwd().openDir(dvui.io, data, .{}); + dir.close(dvui.io); + try std.testing.expectEqual(data.ptr, (host.pluginDataFolder(text_plugin) orelse return error.TestUnexpectedResult).ptr); + const other = host.pluginDataFolder(markdown_plugin) orelse return error.TestUnexpectedResult; + try std.testing.expect(!std.mem.eql(u8, data, other)); + // An id no registered plugin has gets no folder, and no entry; ids are one ignoring case. + try std.testing.expect(editor.app.pluginDataFolder("../escape") == null); + try std.testing.expect(editor.app.plugin_data_folders.get("../escape") == null); + try std.testing.expectEqual(data.ptr, (editor.app.pluginDataFolder("Text") orelse return error.TestUnexpectedResult).ptr); + const level = std.testing.log_level; + std.testing.log_level = .err; // the warnings the cases below ask for + defer std.testing.log_level = level; + const vt = sdk.Plugin.VTable{}; + // A plugin whose id has capitals gets it in lowercase, the same folder on every + // filesystem; another in a different case of it is not registered at all. + { + var cased = sdk.Plugin{ .state = undefined, .vtable = &vt, .id = "CaseTest", .display_name = "Case Test" }; + try host.registerPlugin(&cased); + const want_cased = try std.fs.path.join(gpa, &.{ root, "plugin-data", "casetest" }); + defer gpa.free(want_cased); + try std.testing.expectEqualStrings(want_cased, host.pluginDataFolder(&cased) orelse return error.TestUnexpectedResult); + var lower = sdk.Plugin{ .state = undefined, .vtable = &vt, .id = "casetest", .display_name = "Case Test" }; + try std.testing.expectError(error.DuplicatePluginId, host.registerPlugin(&lower)); + // Gone, as `unloadPlugin` says once its `deinit` has run, its path is not handed out. + host.unregisterPlugin(&cased); + editor.app.pluginGone(cased.id); + try std.testing.expect(host.pluginDataFolder(&cased) == null); + } + // An id that is not a plain identifier names no folder. + { + var dotted = sdk.Plugin{ .state = undefined, .vtable = &vt, .id = "dotted.id", .display_name = "Dotted" }; + try host.registerPlugin(&dotted); + defer host.unregisterPlugin(&dotted); + try std.testing.expect(host.pluginDataFolder(&dotted) == null); + } + // Deleted since, it is made again by an ask once the second it is trusted for is over. + try std.Io.Dir.cwd().deleteTree(dvui.io, data); + const text_entry = editor.app.plugin_data_folders.getPtr("text") orelse return error.TestUnexpectedResult; + text_entry.made_at.? -= app.App.plugin_data_recheck_ns; + _ = host.pluginDataFolder(text_plugin) orelse return error.TestUnexpectedResult; + var again = try std.Io.Dir.cwd().openDir(dvui.io, data, .{}); + again.close(dvui.io); + // One that cannot be made (a file in its way) is null, and not tried again for a few + // seconds; once they have passed, with the way clear, it is made. + { + var blocked = sdk.Plugin{ .state = undefined, .vtable = &vt, .id = "blocked", .display_name = "Blocked" }; + try host.registerPlugin(&blocked); + defer host.unregisterPlugin(&blocked); + try tmp.dir.writeFile(dvui.io, .{ .sub_path = "plugin-data/blocked", .data = "" }); + try std.testing.expect(host.pluginDataFolder(&blocked) == null); + try tmp.dir.deleteFile(dvui.io, "plugin-data/blocked"); + try std.testing.expect(host.pluginDataFolder(&blocked) == null); + const cached = editor.app.plugin_data_folders.getPtr("blocked") orelse return error.TestUnexpectedResult; + cached.failed_at.? -= app.App.plugin_data_retry_ns; + const made = host.pluginDataFolder(&blocked) orelse return error.TestUnexpectedResult; + var dir2 = try std.Io.Dir.cwd().openDir(dvui.io, made, .{}); + dir2.close(dvui.io); + try std.testing.expect(cached.failed_at == null); + } + // A build declaring a loaded plugin's id in another case is refused before it is opened, + // its card shows it failed (not loaded) and says why, and uninstalling it goes through. + if (app.App.loads_plugins) { + try std.testing.expectError(error.DuplicatePluginId, editor.loadUserPluginById("Text")); + const status = editor.app.pluginStatus("Text"); + try std.testing.expectEqual(sdk.services.plugins.Api.State.failed, status.state); + try std.testing.expect(std.mem.indexOf(u8, status.why, "differs only in case") != null); + try editor.uninstallPlugin("Text", true); + try std.testing.expectEqual(sdk.services.plugins.Api.State.not_loaded, editor.app.pluginStatus("Text").state); + try std.testing.expect(host.pluginById("text") != null); + } + } // The app offers its plugins the `automation` service, and nothing is in flight. const automation_api = editor.app.host.getServiceTyped(sdk.services.automation.Api) orelse return error.TestUnexpectedResult; try std.testing.expect(automation_api.settled()); From 5b3dacdfa775400f602be9a121e1b67adeadd2cb Mon Sep 17 00:00:00 2001 From: foxnne Date: Sun, 18 Oct 2026 14:43:37 -0500 Subject: [PATCH 2/2] sdk: a case clash is refused before the disk is touched Fifth review of the plugin data folder. The store's install and update path, loadUserPluginById and the startup scan ask App.pluginCaseClash (a loaded plugin or a plugins/ folder whose id is this one in another case) before writing or opening anything, and say so on a failure card (App.recordCaseClash). Uninstall from such a card compares folders by inode, so it never deletes the loaded plugin's folder on a case-insensitive filesystem. Two plugin folders differing only in case load neither, with one card. Host.unregisterPlugin now tells the app (EditorAPI.pluginUnregistered); the entry is still handed out for the plugin's deinit and goes at the next frame's top. A registration that ran out of memory gets its entry on the first ask. Part of #530 Co-Authored-By: Claude Opus 5.5 --- app/App.zig | 123 ++++++++++++++++++++++++++++++++------ app/store/PluginStore.zig | 4 ++ sdk/src/EditorAPI.zig | 7 ++- sdk/src/Host.zig | 4 ++ sdk/src/version.zig | 2 +- src/editor/Editor.zig | 55 +++++++++++------ tests/integration.zig | 21 ++++++- 7 files changed, 175 insertions(+), 41 deletions(-) diff --git a/app/App.zig b/app/App.zig index 5eebcf386..cf5db34e7 100644 --- a/app/App.zig +++ b/app/App.zig @@ -81,6 +81,9 @@ palette_folder: []const u8, /// `plugin_data_lock`: a plugin may ask from a worker thread (an indexer, an LSP client). plugin_data_folders: std.StringHashMapUnmanaged(PluginDataFolder) = .empty, plugin_data_lock: std.Io.Mutex = .init, +/// Registered plugins whose entry could not be made when they registered (out of memory), by the +/// plugin's own id (its memory, good while it is registered): made on their next ask instead. +plugin_data_missed: [8]?[]const u8 = @splat(null), /// Plugin registry + service locator exposed to plugins host: Host, @@ -764,6 +767,15 @@ pub fn retireFolder(app: *App) void { pub fn releaseRetiredFolders(app: *App) void { for (app.folder_retired.items) |folder| app.gpa.free(folder); app.folder_retired.clearRetainingCapacity(); + // Plugins unregistered last frame have run their `deinit` by now: their data folders stop + // being handed out (`pluginUnregistered`). + app.lockPluginData(); + defer app.unlockPluginData(); + var it = app.plugin_data_folders.valueIterator(); + while (it.next()) |f| if (f.leaving) { + f.leaving = false; + f.registered = false; + }; } pub const PluginDataFolder = struct { @@ -772,6 +784,9 @@ pub const PluginDataFolder = struct { /// Its plugin is registered now. A path stays in the table after its plugin goes, since a /// caller may hold it, but is not handed out again until the plugin is back. registered: bool = true, + /// Its plugin was unregistered this frame. Still handed out, since its `deinit` (which runs + /// after) may write there; `releaseRetiredFolders`, at the next frame's top, makes it gone. + leaving: bool = false, /// When the folder was last found or made, on `std.Io.Clock.awake`: an ask within /// `plugin_data_recheck_ns` of it trusts it is there. made_at: ?i96 = null, @@ -809,7 +824,8 @@ fn unlockPluginData(app: *App) void { /// A plugin registered (`Host.registerPlugin`, through `EditorAPI.pluginRegistered`), on the main /// thread: its data folder's entry is made ready here, so `pluginDataFolder`, asked from any -/// thread, never walks the host's plugin list. +/// thread, never walks the host's plugin list. Out of memory, it is remembered and made on the +/// plugin's next ask. pub fn pluginRegistered(app: *App, id: []const u8) void { var buf: [64]u8 = undefined; const name = pluginDataKey(&buf, id) orelse return; @@ -817,32 +833,49 @@ pub fn pluginRegistered(app: *App, id: []const u8) void { defer app.unlockPluginData(); if (app.plugin_data_folders.getPtr(name)) |f| { f.registered = true; + f.leaving = false; return; } - const key = app.gpa.dupe(u8, name) catch return std.log.warn("plugin '{s}' data folder: out of memory", .{id}); + if (app.addPluginDataEntry(name, id) != null) return; + std.log.warn("plugin '{s}' data folder: out of memory; tried again on its first ask", .{id}); + for (&app.plugin_data_missed) |*slot| if (slot.* == null) { + slot.* = id; + return; + }; +} + +/// A plugin was unregistered (`Host.unregisterPlugin`, through `EditorAPI.pluginUnregistered`), +/// before its `deinit` runs: its folder is still handed out for that, and stops being at the next +/// frame's top (`releaseRetiredFolders`). The entry stays, since a worker may still hold the path. +pub fn pluginUnregistered(app: *App, id: []const u8) void { + var buf: [64]u8 = undefined; + const name = pluginDataKey(&buf, id) orelse return; + app.lockPluginData(); + defer app.unlockPluginData(); + for (&app.plugin_data_missed) |*slot| if (slot.*) |missed| { + if (std.mem.eql(u8, missed, id)) slot.* = null; + }; + if (app.plugin_data_folders.getPtr(name)) |f| f.leaving = true; +} + +/// The entry for plugin `id` under `name` (`pluginDataKey`), made now. Null out of memory. Under +/// `plugin_data_lock`. +fn addPluginDataEntry(app: *App, name: []const u8, id: []const u8) ?*PluginDataFolder { + const key = app.gpa.dupe(u8, name) catch return null; const path: ?[]const u8 = if (isValidPluginId(id)) std.fs.path.join(app.gpa, &.{ app.config_folder, "plugin-data", name }) catch { app.gpa.free(key); - return std.log.warn("plugin '{s}' data folder: out of memory", .{id}); + return null; } else null; - app.plugin_data_folders.put(app.gpa, key, .{ .path = path }) catch { + const entry = app.plugin_data_folders.getOrPut(app.gpa, key) catch { app.gpa.free(key); if (path) |p| app.gpa.free(p); - std.log.warn("plugin '{s}' data folder: out of memory", .{id}); + return null; }; -} - -/// A plugin went (unloaded, disabled, uninstalled, replaced by a reload), after its `deinit`, -/// which may still have written into its folder: the folder is not handed out until it is back. -/// Its entry stays, since a worker may still hold the path. -pub fn pluginGone(app: *App, id: []const u8) void { - var buf: [64]u8 = undefined; - const name = pluginDataKey(&buf, id) orelse return; - app.lockPluginData(); - defer app.unlockPluginData(); - if (app.plugin_data_folders.getPtr(name)) |f| f.registered = false; + entry.value_ptr.* = .{ .path = path }; + return entry.value_ptr; } /// `/plugin-data/` for the registered plugin `id` (`Host.pluginDataFolder`), the id @@ -866,7 +899,16 @@ pub fn pluginDataFolder(app: *App, id: []const u8) ?[]const u8 { const path = path: { app.lockPluginData(); defer app.unlockPluginData(); - const f = app.plugin_data_folders.getPtr(name) orelse return null; + const f = app.plugin_data_folders.getPtr(name) orelse missed: { + // Registered while memory ran out (`pluginRegistered`): made now. + for (&app.plugin_data_missed) |*slot| if (slot.*) |missed| { + if (!std.ascii.eqlIgnoreCase(missed, id)) continue; + const made = app.addPluginDataEntry(name, missed) orelse return null; + slot.* = null; + break :missed made; + }; + return null; + }; if (!f.registered) return null; const path = f.path orelse { if (!f.said) std.log.warn("plugin '{s}' has no data folder: its id is not a plain identifier (letters, digits, '-', '_')", .{id}); @@ -919,6 +961,53 @@ pub fn deinit(app: *App) void { app.auto_update_off_ids.deinit(app.gpa); } +/// The id another plugin goes by that `id` is in another case: a loaded plugin's +/// (`Host.pluginByIdIgnoringCase`), or a folder in `plugins/`. Asked before anything keyed by +/// `id` touches the disk (a store install or update, a load): on macOS and Windows `plugins/Graphl` +/// *is* `plugins/graphl`, so writing one would overwrite the other. Written into `buf`. +pub fn pluginCaseClash(app: *App, buf: *[64]u8, id: []const u8) ?[]const u8 { + if (app.host.pluginByIdIgnoringCase(id)) |p| if (!std.mem.eql(u8, p.id, id)) { + const n = @min(p.id.len, buf.len); + @memcpy(buf[0..n], p.id[0..n]); + return buf[0..n]; + }; + if (comptime builtin.target.cpu.arch == .wasm32) return null; + const plugins_dir = std.fs.path.join(app.gpa, &.{ app.config_folder, "plugins" }) catch return null; + defer app.gpa.free(plugins_dir); + var dir = std.Io.Dir.cwd().openDir(dvui.io, plugins_dir, .{ .iterate = true }) catch return null; + defer dir.close(dvui.io); + var it = dir.iterate(); + while (it.next(dvui.io) catch null) |entry| { + if (entry.kind != .directory) continue; + if (std.mem.eql(u8, entry.name, id) or !std.ascii.eqlIgnoreCase(entry.name, id)) continue; + const n = @min(entry.name.len, buf.len); + @memcpy(buf[0..n], entry.name[0..n]); + return buf[0..n]; + } + return null; +} + +/// The card for a build of `id` refused because `other`, the same id in another case, is loaded or +/// installed (`pluginCaseClash`, `Host.registerPlugin`): logged, and shown in the store as a failure +/// that says so. `path` is the build's, when there is one to read a version from. +pub fn recordCaseClash(app: *App, id: []const u8, other: []const u8, path: ?[]const u8) void { + const why = std.fmt.allocPrint(app.arena.allocator(), "id '{s}' differs only in case from the plugin '{s}'", .{ id, other }) catch "id differs only in case from another plugin"; + std.log.warn("plugin '{s}': {s}; not loaded", .{ id, why }); + const probe = if (path) |p| PluginLoader.probeVersionInfo(p) else null; + app.clearFailedUserPlugin(id); + app.recordPluginFailure(id, why, null, if (probe) |info| info.plugin_version else null, if (path) |p| .of(p) else .{}); +} + +/// Whether two paths name one folder: the same file on the same device, so a case-insensitive +/// filesystem's `plugins/Graphl` and `plugins/graphl` are one, and a case-sensitive one's are +/// two. False when either cannot be read. +pub fn sameFolder(a: []const u8, b: []const u8) bool { + if (comptime builtin.target.cpu.arch == .wasm32) return false; + const sa = std.Io.Dir.cwd().statFile(dvui.io, a, .{}) catch return false; + const sb = std.Io.Dir.cwd().statFile(dvui.io, b, .{}) catch return false; + return sa.inode == sb.inode; +} + /// `/plugins//.` — where a user plugin's build lives. Caller frees. pub fn userPluginPath(gpa: std.mem.Allocator, app: *App, id: []const u8) ![]u8 { const file_name = try PluginLoader.pluginFilename(id, gpa); diff --git a/app/store/PluginStore.zig b/app/store/PluginStore.zig index 4d205aec3..106a175ec 100644 --- a/app/store/PluginStore.zig +++ b/app/store/PluginStore.zig @@ -1698,6 +1698,10 @@ fn startDownload(id: []const u8, release: store.ShardRelease, opts: DownloadOpti /// wasm *compile* error rather than dead code. fn startDownloadUrl(id: []const u8, url: []const u8, sha256: []const u8, opts: DownloadOptions) void { if (comptime builtin.target.cpu.arch == .wasm32) return; + // Before anything is written into `plugins/`: on macOS and Windows a case variant's + // folder is that folder, and the download would overwrite the plugin installed there. + var clash_buf: [64]u8 = undefined; + if (app.pluginCaseClash(&clash_buf, id)) |other| return app.recordCaseClash(id, other, null); removeJob(id); // replace any prior failed job const job = buildJob(id, url, sha256, opts) catch { diff --git a/sdk/src/EditorAPI.zig b/sdk/src/EditorAPI.zig index a711c427b..4a2db8364 100644 --- a/sdk/src/EditorAPI.zig +++ b/sdk/src/EditorAPI.zig @@ -157,9 +157,12 @@ pub const VTable = struct { pluginDataFolder: *const fn (ctx: *anyopaque, id: []const u8) ?[]const u8, /// A plugin registered, on the main thread (`Host.registerPlugin`): what the app keeps per /// plugin is made ready here, so asks from other threads never walk the host's plugin list. - /// Its going is the app's own to note, after the plugin's `deinit` (which may still use what - /// it kept). Null where the app keeps nothing per plugin. + /// Null where the app keeps nothing per plugin. pluginRegistered: ?*const fn (ctx: *anyopaque, id: []const u8) void = null, + /// A plugin was unregistered (`Host.unregisterPlugin`), before its `deinit` runs: what the app + /// keeps for it must still serve that `deinit`, and goes once it has run. Null where the app + /// keeps nothing per plugin. + pluginUnregistered: ?*const fn (ctx: *anyopaque, id: []const u8) void = null, /// Mark fizzy's settings dirty so the debounced autosave persists them. markSettingsDirty: *const fn (ctx: *anyopaque) void, /// A credential kept for a plugin, by key (`.`), outside `settings.zon` — diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index 4b953451f..dbe08e9d8 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -979,6 +979,7 @@ pub fn registerPlugin(self: *Host, plugin: *Plugin) !void { /// this *before* `dlclose`, so that the active-selection ids (which may point into that /// image) are compared and reset while the memory is still mapped. pub fn unregisterPlugin(self: *Host, plugin: *Plugin) void { + if (self.fizzy_api) |a| if (a.vtable.pluginUnregistered) |gone| gone(a.ctx, plugin.id); for (self.surfaces.items) |s| if (s.owner == plugin) self.dropSurfaceTitle(s.id); removeOwned(Surface, &self.surfaces, plugin); removeOwned(MenuContribution, &self.menus, plugin); @@ -2773,6 +2774,7 @@ test "pluginForExtension honors a user assignment and ignores a stale one" { // Only the one vtable member `pluginForExtension` reaches; the rest stay unreachable here. var api_vt: EditorAPI.VTable = undefined; api_vt.pluginRegistered = null; + api_vt.pluginUnregistered = null; api_vt.extensionOwnerOverride = Fake.extensionOwnerOverride; var ctx: u8 = 0; host.installFizzyApi(.{ .ctx = &ctx, .vtable = &api_vt }); @@ -3164,6 +3166,7 @@ test "a claim on a document holds an override of its owner's command too" { S.owner = &pixi; var api_vt: EditorAPI.VTable = undefined; api_vt.pluginRegistered = null; + api_vt.pluginUnregistered = null; api_vt.activeDoc = S.active; api_vt.refresh = S.refresh; var ctx: u8 = 0; @@ -3472,6 +3475,7 @@ test "commands with arguments: callCommand, runCommand's fallback, and listing" // ...and something required has the app ask for it. var api_vt: EditorAPI.VTable = undefined; api_vt.pluginRegistered = null; + api_vt.pluginUnregistered = null; api_vt.askCommandArguments = S.ask; var ctx: u8 = 0; host.installFizzyApi(.{ .ctx = &ctx, .vtable = &api_vt }); diff --git a/sdk/src/version.zig b/sdk/src/version.zig index b25748508..94128aa5b 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0xe27832190e059544; +pub const recorded_sdk_shape_fingerprint: u64 = 0x1875db13b67abfc; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index a0076ba23..f0a2e8d16 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -730,6 +730,22 @@ pub fn loadUserPlugins(editor: *Editor, config_folder: []const u8) void { // Skip `.load-tmp` and any other dotfile/directory — not a plugin id. if (plugin_id.len == 0 or plugin_id[0] == '.') continue; + // Two folders whose names differ only in case (a case-sensitive filesystem lets both + // exist) are one id twice: neither loads. Which one was meant cannot be told from the + // folders, and loading whichever sorts first would let byte order choose. One card, on + // the first by that order, names them both. + const twin: ?[]const u8 = for (ids.items) |other| { + if (other.ptr != plugin_id.ptr and std.ascii.eqlIgnoreCase(other, plugin_id)) break other; + } else null; + if (twin) |other| { + if (std.mem.lessThan(u8, plugin_id, other)) { + const why = std.fmt.allocPrint(editor.app.arena.allocator(), "plugin folders '{s}' and '{s}' differ only in case, so neither is loaded: remove one", .{ plugin_id, other }) catch "two plugin folders differ only in case, so neither is loaded"; + dvui.log.warn("user plugin '{s}': {s}", .{ plugin_id, why }); + editor.app.recordPluginFailure(plugin_id, why, null, null, .{}); + } + continue; + } + // User-disabled plugins (store "disable") stay on disk but are not loaded. if (editor.app.isPluginDisabled(plugin_id)) { dvui.log.info("user plugin '{s}' is disabled; skipped", .{plugin_id}); @@ -752,7 +768,7 @@ pub fn loadUserPlugins(editor: *Editor, config_folder: []const u8) void { // id clash (some other plugin trying to claim an id a built-in already owns) is // worth surfacing. if (!std.mem.eql(u8, loaded.id, plugin_id)) { - editor.caseClash(plugin_id, loaded.id, path); + editor.app.recordCaseClash(plugin_id, loaded.id, path); } else if (isBundledPluginId(plugin_id)) { dvui.log.info("user plugin '{s}': already loaded as a built-in; skipped", .{plugin_id}); } else { @@ -1433,12 +1449,13 @@ pub const UnloadError = error{ NotUnloadable, DirtyDocuments }; pub fn loadUserPluginById(editor: *Editor, id: []const u8) !void { 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); - // Asked before the build is opened: a case variant of a loaded id fails saying so. - if (editor.app.host.pluginByIdIgnoringCase(id)) |loaded| if (!std.mem.eql(u8, loaded.id, id)) { - editor.caseClash(id, loaded.id, path); + // Asked before the build is opened: a case variant of a loaded or installed id fails saying so. + var clash_buf: [64]u8 = undefined; + if (editor.app.pluginCaseClash(&clash_buf, id)) |other| { + editor.app.recordCaseClash(id, other, path); editor.app.gpa.free(path); return error.DuplicatePluginId; - }; + } const opened = PluginLoader.open(editor.app.gpa, path, id) catch |err| { editor.userPluginLoadFailed(id, path, err); editor.app.gpa.free(path); @@ -1480,17 +1497,6 @@ pub fn finishUserPluginLoad(editor: *Editor, id: []const u8, opened: PluginLoade /// Leave the same actionable record the startup scan leaves (see `recordLoadFailure`), so a build /// that fails a live load stays visible in the store's installed pane with its Reinstall/Uninstall /// controls instead of disappearing until the next restart. -/// The build at `path` declares `id`, the id of the loaded plugin `loaded_id` in another case: -/// one folder on macOS and Windows, so one plugin (`Host.registerPlugin`). Not loaded, and the -/// store's failure card says why. -fn caseClash(editor: *Editor, id: []const u8, loaded_id: []const u8, path: []const u8) void { - const why = std.fmt.allocPrint(editor.app.arena.allocator(), "id '{s}' differs only in case from the loaded plugin '{s}'", .{ id, loaded_id }) catch "id differs only in case from a loaded plugin"; - dvui.log.warn("user plugin '{s}': {s}; skipped", .{ id, why }); - const probe = PluginLoader.probeVersionInfo(path); - editor.app.clearFailedUserPlugin(id); - editor.app.recordPluginFailure(id, why, null, if (probe) |info| info.plugin_version else null, .of(path)); -} - fn userPluginLoadFailed(editor: *Editor, id: []const u8, path: []const u8, err: PluginLoader.LoadError) void { dvui.log.err("user plugin '{s}' ({s}): load failed: {s} — {s}", .{ id, path, @errorName(err), App.pluginLoadFailureReason(err) }); editor.app.recordLoadFailure(id, path, err); @@ -1908,8 +1914,6 @@ pub fn unloadPlugin(editor: *Editor, id: []const u8, opts: UnloadOptions) Unload editor.app.host.unregisterPlugin(plugin); fizzy.backend.rebuildDynamicNativeMenus(); plugin.deinit(); - // After `deinit`, which may still write into its data folder. - editor.app.pluginGone(id); // Drop the unloaded plugin's keybinds by rebuilding from the survivors. rebuildKeybinds(editor); @@ -2055,7 +2059,16 @@ pub fn uninstallPlugin(editor: *Editor, id: []const u8, force: bool) !void { const plugin_dir = try std.fs.path.join(editor.app.gpa, &.{ editor.app.config_folder, "plugins", id }); defer editor.app.gpa.free(plugin_dir); - std.Io.Dir.cwd().deleteTree(dvui.io, plugin_dir) catch |err| + // A build refused for its case (`App.recordCaseClash`) names a folder that, on macOS and + // Windows, is the loaded plugin's own: its card drops, and that folder stays. Told apart by + // what the folder is on disk, not by its name. + const keep = if (editor.app.host.pluginByIdIgnoringCase(id)) |loaded| keep: { + if (std.mem.eql(u8, loaded.id, id)) break :keep false; + const loaded_dir = try std.fs.path.join(editor.app.gpa, &.{ editor.app.config_folder, "plugins", loaded.id }); + defer editor.app.gpa.free(loaded_dir); + break :keep App.sameFolder(plugin_dir, loaded_dir); + } else false; + if (!keep) std.Io.Dir.cwd().deleteTree(dvui.io, plugin_dir) catch |err| dvui.log.warn("uninstallPlugin '{s}': could not delete {s}: {s}", .{ id, plugin_dir, @errorName(err) }); // A broken (failed-to-load) build can be uninstalled too; clear its failure record so the // card disappears instead of lingering as "Failed". @@ -2377,6 +2390,7 @@ const fizzy_api_vtable: sdk.EditorAPI.VTable = .{ .paletteFolder = fizzyPaletteFolder, .pluginDataFolder = fizzyPluginDataFolder, .pluginRegistered = fizzyPluginRegistered, + .pluginUnregistered = fizzyPluginUnregistered, .getSecret = fizzyGetSecret, .setSecret = fizzySetSecret, .markSettingsDirty = fizzyMarkSettingsDirty, @@ -2549,6 +2563,9 @@ fn fizzyPluginDataFolder(ctx: *anyopaque, id: []const u8) ?[]const u8 { fn fizzyPluginRegistered(ctx: *anyopaque, id: []const u8) void { fizzyCtx(ctx).app.pluginRegistered(id); } +fn fizzyPluginUnregistered(ctx: *anyopaque, id: []const u8) void { + fizzyCtx(ctx).app.pluginUnregistered(id); +} fn fizzyGetSecret(ctx: *anyopaque, key: []const u8) ?[]const u8 { return fizzyCtx(ctx).app.secrets.get(key); } diff --git a/tests/integration.zig b/tests/integration.zig index c51c27e99..00a14ff1d 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7381,9 +7381,11 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expectEqualStrings(want_cased, host.pluginDataFolder(&cased) orelse return error.TestUnexpectedResult); var lower = sdk.Plugin{ .state = undefined, .vtable = &vt, .id = "casetest", .display_name = "Case Test" }; try std.testing.expectError(error.DuplicatePluginId, host.registerPlugin(&lower)); - // Gone, as `unloadPlugin` says once its `deinit` has run, its path is not handed out. + // Unregistered, its path is still handed out for its `deinit`, which runs after; from + // the next frame's top it is not. host.unregisterPlugin(&cased); - editor.app.pluginGone(cased.id); + try std.testing.expect(host.pluginDataFolder(&cased) != null); + editor.app.releaseRetiredFolders(); try std.testing.expect(host.pluginDataFolder(&cased) == null); } // An id that is not a plain identifier names no folder. @@ -7424,9 +7426,24 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes const status = editor.app.pluginStatus("Text"); try std.testing.expectEqual(sdk.services.plugins.Api.State.failed, status.state); try std.testing.expect(std.mem.indexOf(u8, status.why, "differs only in case") != null); + // Uninstalling from that card never deletes the loaded plugin's folder, which on macOS + // and Windows is the same folder by another name. + try tmp.dir.createDirPath(dvui.io, "plugins/text"); + try tmp.dir.writeFile(dvui.io, .{ .sub_path = "plugins/text/keep", .data = "" }); try editor.uninstallPlugin("Text", true); try std.testing.expectEqual(sdk.services.plugins.Api.State.not_loaded, editor.app.pluginStatus("Text").state); try std.testing.expect(host.pluginById("text") != null); + _ = try tmp.dir.statFile(dvui.io, "plugins/text/keep", .{}); + try tmp.dir.deleteTree(dvui.io, "plugins/text"); + } + // A folder already in `plugins/` in another case clashes too, before a store install + // writes anything. + { + try tmp.dir.createDirPath(dvui.io, "plugins/Graphl"); + defer tmp.dir.deleteTree(dvui.io, "plugins/Graphl") catch {}; + var clash_buf: [64]u8 = undefined; + try std.testing.expectEqualStrings("Graphl", editor.app.pluginCaseClash(&clash_buf, "graphl") orelse return error.TestUnexpectedResult); + try std.testing.expect(editor.app.pluginCaseClash(&clash_buf, "Graphl") == null); } } // The app offers its plugins the `automation` service, and nothing is in flight.