Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 250 additions & 0 deletions app/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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;
}

/// `<config>/plugin-data/<id>` 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/<id>`, 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;
}

/// `<config>/plugins/<id>/<id>.<ext>` — where a user plugin's build lives. Caller frees.
Expand Down
9 changes: 9 additions & 0 deletions app/store/PluginManager.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>`: 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 {
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions app/store/PluginStore.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>`: 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 {
Expand Down
25 changes: 25 additions & 0 deletions docs/PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`**: `<config>/plugin-data/<id>`, 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/<id>`, 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
Expand Down
16 changes: 16 additions & 0 deletions sdk/src/EditorAPI.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<plugin>.<what>`), outside `settings.zon` —
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading