diff --git a/app/App.zig b/app/App.zig index 8c4155d4d..15cd17ef5 100644 --- a/app/App.zig +++ b/app/App.zig @@ -75,6 +75,16 @@ 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, +/// 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, @@ -758,6 +768,246 @@ 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 { + /// 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, + /// 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, + /// 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. 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; + app.lockPluginData(); + defer app.unlockPluginData(); + if (app.plugin_data_folders.getPtr(name)) |f| { + f.registered = true; + f.leaving = false; + return; + } + 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 null; + } + else + null; + const entry = app.plugin_data_folders.getOrPut(app.gpa, key) catch { + app.gpa.free(key); + if (path) |p| app.gpa.free(p); + return null; + }; + entry.value_ptr.* = .{ .path = path }; + return entry.value_ptr; +} + +/// `/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 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}); + 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); +} + +/// 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 }); + // The web's loader has no build on disk to read a version from. + const probe = if (comptime builtin.target.cpu.arch == .wasm32) null else 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. diff --git a/app/store/PluginManager.zig b/app/store/PluginManager.zig index 003084612..004d242ba 100644 --- a/app/store/PluginManager.zig +++ b/app/store/PluginManager.zig @@ -125,6 +125,11 @@ pub const VTable = struct { /// Bring the main region into view — on a narrow layout the store's README page is drawn /// somewhere the user cannot see yet. A no-op is a perfectly good implementation. revealMain: *const fn (ctx: *anyopaque) void, + /// Asked before an install or update writes anything into `plugins/`: refuse `id` when it + /// is a plugin already loaded or installed in another case (on macOS and Windows that folder + /// is the other plugin's), saying why where the app shows load failures. True when refused. + /// Null for an app that keeps no such rule. + refuseCaseClash: ?*const fn (ctx: *anyopaque, id: []const u8) bool = null, }; pub fn isDisabled(self: PluginManager, id: []const u8) bool { @@ -193,6 +198,10 @@ pub fn offerUpdates(self: PluginManager) void { pub fn revealMain(self: PluginManager) void { self.vtable.revealMain(self.ctx); } +pub fn refuseCaseClash(self: PluginManager, id: []const u8) bool { + const refuse = self.vtable.refuseCaseClash orelse return false; + return refuse(self.ctx, id); +} /// True when `id` has a dylib on disk that was refused at load. pub fn isFailed(self: PluginManager, id: []const u8) bool { diff --git a/app/store/PluginStore.zig b/app/store/PluginStore.zig index 3cf6560bb..e6335cfae 100644 --- a/app/store/PluginStore.zig +++ b/app/store/PluginStore.zig @@ -1698,6 +1698,9 @@ 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. + if (app.refuseCaseClash(id)) return; removeJob(id); // replace any prior failed job const job = buildJob(id, url, sha256, opts) catch { diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 6d1be2af9..c6f79fc63 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -881,6 +881,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..4a2db8364 100644 --- a/sdk/src/EditorAPI.zig +++ b/sdk/src/EditorAPI.zig @@ -151,6 +151,18 @@ 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. + /// 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` — @@ -462,6 +474,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 bc15cf853..805813dc8 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -437,6 +437,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 @@ -935,9 +962,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 @@ -950,6 +986,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); @@ -1072,7 +1109,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; @@ -1080,6 +1117,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, @@ -2799,6 +2847,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; @@ -2831,6 +2896,8 @@ 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 }); @@ -3221,6 +3288,8 @@ 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; @@ -3528,6 +3597,8 @@ 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 2d5d61eac..222378de1 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 = 0x5f9c818a6972efd4; +pub const recorded_sdk_shape_fingerprint: u64 = 0xba4664870675e13d; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index bc8b708fa..be5f7df78 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -741,6 +741,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}); @@ -751,7 +767,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 @@ -762,7 +778,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.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 { dvui.log.err("user plugin '{s}': id already registered by a built-in; skipped", .{plugin_id}); @@ -837,14 +855,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(); @@ -1450,6 +1461,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 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); @@ -2053,7 +2071,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". @@ -2373,6 +2400,9 @@ const fizzy_api_vtable: sdk.EditorAPI.VTable = .{ .extensionOwnerOverride = fizzyExtensionOwnerOverride, .folder = fizzyFolder, .paletteFolder = fizzyPaletteFolder, + .pluginDataFolder = fizzyPluginDataFolder, + .pluginRegistered = fizzyPluginRegistered, + .pluginUnregistered = fizzyPluginUnregistered, .getSecret = fizzyGetSecret, .setSecret = fizzySetSecret, .markSettingsDirty = fizzyMarkSettingsDirty, @@ -2539,6 +2569,15 @@ 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 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); } @@ -6666,6 +6705,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(); @@ -7034,6 +7075,15 @@ const plugin_manager_vtable: PluginManager.VTable = .{ pmSelf(ctx).revealCenter(); } }.f, + .refuseCaseClash = struct { + fn f(ctx: *anyopaque, id: []const u8) bool { + const app = &pmSelf(ctx).app; + var buf: [64]u8 = undefined; + const other = app.pluginCaseClash(&buf, id) orelse return false; + app.recordCaseClash(id, other, null); + return true; + } + }.f, }; pub fn pluginManager(editor: *Editor) PluginManager { diff --git a/tests/integration.zig b/tests/integration.zig index 5e3ec7e5b..53b3dc63b 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7537,6 +7537,104 @@ 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)); + // 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); + 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. + { + 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); + // 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. const automation_api = editor.app.host.getServiceTyped(sdk.services.automation.Api) orelse return error.TestUnexpectedResult; try std.testing.expect(automation_api.settled());