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
65 changes: 33 additions & 32 deletions app/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ pub const PluginEnabledState = enum { unset, enabled, disabled };
const Layout = @import("layout/Layout.zig");
const Host = sdk.Host;
const PluginLoader = @import("root.zig").store.Loader;
const store = @import("root.zig").store;
const SettingsWatcher = @import("watch/SettingsWatcher.zig");
const ControlServer = @import("control/Server.zig");
const ControlState = @import("control/routes.zig").State;
Expand Down Expand Up @@ -962,30 +963,40 @@ 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];
};
/// Why a build of `id` may not be installed or loaded: another plugin goes by `id` in another
/// case, loaded or with a folder in `plugins/` (on macOS and Windows `plugins/Graphl` *is*
/// `plugins/graphl`, so writing one would overwrite the other), or `plugins/` cannot be read to
/// tell, which is refused as a possible clash.
pub const CaseClash = union(enum) {
/// The other plugin's id or folder name. Borrowed from the loaded plugin, or from the
/// listing it was read from.
other: []const u8,
unreadable,
};

/// `id`'s clash with what is loaded and with `folders`, a listing of `plugins/`
/// (`store.folders.read`). Asked before anything keyed by `id` touches the disk.
pub fn caseClashIn(app: *App, folders: store.folders.Folders, id: []const u8) ?CaseClash {
if (app.host.pluginByIdIgnoringCase(id)) |p| if (!std.mem.eql(u8, p.id, id)) return .{ .other = p.id };
return .{ .other = store.folders.clash(folders.names, id) orelse return null };
}

/// The clash `recordCaseClash` explains, for a load (`Editor.loadUserPluginById`, the startup
/// scan): `plugins/` read now. The name is copied into `buf`, whole; one that does not fit is
/// refused as unreadable rather than shown cut off.
pub fn pluginCaseClash(app: *App, buf: []u8, id: []const u8) ?CaseClash {
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;
const plugins_dir = std.fs.path.join(app.gpa, &.{ app.config_folder, "plugins" }) catch return .unreadable;
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;
const folders = store.folders.read(app.gpa, dvui.io, plugins_dir) catch return .unreadable;
defer folders.deinit(app.gpa);
return switch (app.caseClashIn(folders, id) orelse return null) {
.unreadable => .unreadable,
.other => |name| if (name.len <= buf.len) blk: {
@memcpy(buf[0..name.len], name);
break :blk .{ .other = buf[0..name.len] };
} else .unreadable,
};
}

/// The card for a build of `id` refused because `other`, the same id in another case, is loaded or
Expand All @@ -1000,16 +1011,6 @@ pub fn recordCaseClash(app: *App, id: []const u8, other: []const u8, path: ?[]co
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.
pub fn userPluginPath(gpa: std.mem.Allocator, app: *App, id: []const u8) ![]u8 {
const file_name = try PluginLoader.pluginFilename(id, gpa);
Expand Down
2 changes: 2 additions & 0 deletions app/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ pub const store = struct {
else
@import("store/PluginLoader.zig");
pub const registry = @import("store/registry/store.zig");
/// The folders in `plugins/` and what a plugin id may do among them.
pub const folders = @import("store/plugin_folders.zig");
};

// The root of `zig build test-integration`'s `fizzy-app-tests` (`build/app.zig`): the files here
Expand Down
9 changes: 0 additions & 9 deletions app/store/PluginManager.zig
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,6 @@ 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 @@ -198,10 +193,6 @@ 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
94 changes: 85 additions & 9 deletions app/store/PluginStore.zig
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const store = @import("registry/store.zig");
const Page = @import("PluginPage.zig");
const PluginLoader = @import("PluginLoader.zig");
const PluginManager = @import("PluginManager.zig");
const plugin_folders = @import("plugin_folders.zig");
const App = @import("../App.zig");
/// Pretend two installed plugins have store updates waiting, so the update flow (the app's
/// offer, or the silent path's logging) can be exercised without an actually out-of-date
Expand Down Expand Up @@ -103,6 +104,9 @@ const Job = struct {
win: *dvui.Window,
err_buf: [64]u8 = undefined,
err_len: usize = 0,
/// Refused before it started, and why (`refusal`): owned. Such a job never downloads; its card
/// shows the reason with a Dismiss, and the plugin's own controls stay below it.
refused: ?[]u8 = null,
/// This job's download worker, as a cancelable `Io` task group rather than a detached
/// `std.Thread`. The worker writes `err_buf`/`err_len`/`status` back into the `Job`, so the
/// `Job` must not be freed while it is alive — and a detached thread gave `freeJob` no way to
Expand All @@ -127,6 +131,8 @@ const PendingAction = union(enum) {
set_enabled: struct { id: []u8, enabled: bool },
set_auto_update: struct { id: []u8, on: bool },
uninstall: struct { id: []u8 },
/// A refused job's card dismissed (`Job.refused`).
dismiss: struct { id: []u8 },
};

var pending_actions: std.ArrayListUnmanaged(PendingAction) = .empty;
Expand Down Expand Up @@ -782,6 +788,7 @@ pub fn deinit() void {
.set_enabled => |a| app.gpa.free(a.id),
.set_auto_update => |a| app.gpa.free(a.id),
.uninstall => |a| app.gpa.free(a.id),
.dismiss => |a| app.gpa.free(a.id),
};
pending_actions.deinit(app.gpa);
clearPendingUpdates();
Expand All @@ -797,6 +804,7 @@ pub fn deinit() void {
.set_enabled => |a| app.gpa.free(a.id),
.set_auto_update => |a| app.gpa.free(a.id),
.uninstall => |a| app.gpa.free(a.id),
.dismiss => |a| app.gpa.free(a.id),
};
pending_actions.deinit(app.gpa);
for (name_cache.keys()) |k| app.gpa.free(k);
Expand Down Expand Up @@ -831,6 +839,7 @@ fn freeJob(job: *Job) void {
app.gpa.free(job.url);
app.gpa.free(job.sha256);
app.gpa.free(job.dest);
if (job.refused) |r| app.gpa.free(r);
app.gpa.destroy(job);
}

Expand Down Expand Up @@ -1073,6 +1082,16 @@ pub fn updateStatus(id: []const u8) ?[]const u8 {
}

pub fn applyAllPendingUpdates() void {
// One listing of `plugins/` for the whole pass (`refusal`). Unreadable, each install reads its
// own and is refused on its own.
const folders: ?plugin_folders.Folders = if (comptime builtin.target.cpu.arch == .wasm32) null else read: {
const plugins_dir = std.fs.path.join(app.gpa, &.{ app.config_folder, "plugins" }) catch break :read null;
defer app.gpa.free(plugins_dir);
break :read plugin_folders.read(app.gpa, dvui.io, plugins_dir) catch null;
};
defer if (folders) |f| f.deinit(app.gpa);
pass_folders = if (folders) |*f| f else null;
defer pass_folders = null;
// Index rather than pointer iteration: `applyPendingUpdate` writes back into the list.
var i: usize = 0;
while (i < pending_updates.items.len) : (i += 1) {
Expand Down Expand Up @@ -1394,7 +1413,7 @@ pub fn tick() void {
// Nobody asked for a quiet job, so it leaves no failed card to retry from. Log it,
// drop the job, and put the Update button back on its row as Retry.
if (job.quiet) {
dvui.log.warn("plugin updates: '{s}' could not be downloaded ({s})", .{ job.id, job.err_buf[0..job.err_len] });
dvui.log.warn("plugin updates: '{s}' could not be downloaded ({s})", .{ job.id, job.refused orelse job.err_buf[0..job.err_len] });
markPendingUpdateFailed(job.id);
jobs.swapRemoveAt(i);
freeJob(job);
Expand Down Expand Up @@ -1698,10 +1717,20 @@ 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;
removeJob(id); // replace any prior failed job
// 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
// Refused, the job fails at once and says why: on its card, or as the update offer marked
// failed for the automatic pass (`tick`), never left "Installing…".
var why_buf: [512]u8 = undefined;
if (refusal(&why_buf, id)) |why| {
var refused = opts;
refused.refused = why;
const job = buildJob(id, url, sha256, refused) catch return reportError("could not refuse the download for '{s}'", .{id});
jobs.put(app.gpa, job.id, job) catch freeJob(job);
dvui.log.warn("plugin '{s}': not installed: {s}", .{ id, why });
return;
}

const job = buildJob(id, url, sha256, opts) catch {
reportError("could not prepare download for '{s}'", .{id});
Expand All @@ -1720,13 +1749,38 @@ fn startDownloadUrl(id: []const u8, url: []const u8, sha256: []const u8, opts: D
};
}

/// The listing of `plugins/` an automatic update pass reads once and asks about every plugin it
/// updates (`applyAllPendingUpdates`); null outside one, when each install reads its own.
var pass_folders: ?*const plugin_folders.Folders = null;

/// Why installing `id` now must not go ahead, written into `buf`; null when it may. Another plugin
/// goes by `id` in another case, loaded or installed, or `plugins/` cannot be read to tell: on
/// macOS and Windows that other plugin's folder *is* `plugins/<id>`. A name too long for the
/// message gets the message without it, never cut short.
fn refusal(buf: []u8, id: []const u8) ?[]const u8 {
if (app.host.pluginByIdIgnoringCase(id)) |p| if (!std.mem.eql(u8, p.id, id))
return std.fmt.bufPrint(buf, "its id differs only in case from the loaded plugin '{s}'", .{p.id}) catch "its id differs only in case from a loaded plugin";
const read: ?plugin_folders.Folders = if (pass_folders == null) fresh: {
const plugins_dir = std.fs.path.join(app.gpa, &.{ app.config_folder, "plugins" }) catch return "the plugins folder could not be read to check it";
defer app.gpa.free(plugins_dir);
break :fresh plugin_folders.read(app.gpa, dvui.io, plugins_dir) catch return "the plugins folder could not be read to check it";
} else null;
defer if (read) |f| f.deinit(app.gpa);
const folders = if (pass_folders) |f| f.* else read.?;
const other = plugin_folders.clash(folders.names, id) orelse return null;
return std.fmt.bufPrint(buf, "its id differs only in case from the installed plugin '{s}'", .{other}) catch "its id differs only in case from an installed plugin";
}

/// Allocate a `Job` with all strings owned; `errdefer` unwinds every partial allocation so a
/// mid-build OOM never leaks.
pub const DownloadOptions = struct {
/// Replace a *running* plugin (unload + reload) rather than installing a fresh one.
is_update: bool,
/// Suppress the error reporting and Retry card a user-initiated job leaves behind.
quiet: bool = false,
/// Refused before it starts, and why (`refusal`): the job is built failed, and nothing is
/// made on disk.
refused: ?[]const u8 = null,
};

fn buildJob(id: []const u8, url: []const u8, sha256: []const u8, opts: DownloadOptions) !*Job {
Expand All @@ -1736,8 +1790,10 @@ fn buildJob(id: []const u8, url: []const u8, sha256: []const u8, opts: DownloadO
defer a.free(plugins_dir);
const plugin_dir = try std.fs.path.join(a, &.{ plugins_dir, id });
defer a.free(plugin_dir);
std.Io.Dir.createDirAbsolute(dvui.io, plugins_dir, .default_dir) catch {}; // best-effort; exists is fine
std.Io.Dir.createDirAbsolute(dvui.io, plugin_dir, .default_dir) catch {}; // best-effort; exists is fine
if (opts.refused == null) {
std.Io.Dir.createDirAbsolute(dvui.io, plugins_dir, .default_dir) catch {}; // best-effort; exists is fine
std.Io.Dir.createDirAbsolute(dvui.io, plugin_dir, .default_dir) catch {}; // best-effort; exists is fine
}
const file_name = try PluginLoader.pluginFilename(id, a);
defer a.free(file_name);

Expand All @@ -1751,9 +1807,12 @@ fn buildJob(id: []const u8, url: []const u8, sha256: []const u8, opts: DownloadO
errdefer a.free(sha_dup);
const dest = try std.fs.path.join(a, &.{ plugin_dir, file_name });
errdefer a.free(dest);
const refused: ?[]u8 = if (opts.refused) |why| try a.dupe(u8, why) else null;
errdefer if (refused) |r| a.free(r);

job.* = .{
.status = .init(@backingInt(JobStatus.downloading)),
.status = .init(@backingInt(if (refused != null) JobStatus.failed else JobStatus.downloading)),
.refused = refused,
.id = id_dup,
.url = url_dup,
.sha256 = sha_dup,
Expand Down Expand Up @@ -3113,7 +3172,12 @@ fn drawCardControls(entry: StoreEntry) void {
drawInstalling(if (job.is_update) "Updating…" else "Installing…", muted);
return;
},
.failed => {
.failed => if (job.refused) |why| {
// Refused, not failed: Retry would only refuse again. Say why, offer to dismiss it,
// and leave the plugin's own controls (Uninstall among them) below.
dvui.labelNoFmt(@src(), why, .{}, .{ .gravity_y = 0.5, .color_text = .{ .color = theme.color(.err, .text) } });
if (dvui.button(@src(), "Dismiss", .{}, .{ .gravity_y = 0.5 })) queueDismiss(entry.id);
} else {
if (selectedRelease(entry)) |rel| {
if (dvui.button(@src(), "Retry", .{}, .{ .gravity_y = 0.5, .color_text = .{ .color = theme.color(.err, .text) } }))
startDownload(entry.id, rel, .{ .is_update = job.is_update });
Expand Down Expand Up @@ -3399,13 +3463,13 @@ fn removePendingForId(id: []const u8) void {
.uninstall => |a| std.mem.eql(u8, a.id, id),
// A different axis entirely: enabling or uninstalling a plugin must not silently
// swallow a queued auto-update toggle for the same id.
.set_auto_update => false,
.set_auto_update, .dismiss => false,
};
if (matches) {
switch (action) {
.set_enabled => |a| app.gpa.free(a.id),
.uninstall => |a| app.gpa.free(a.id),
.set_auto_update => unreachable,
.set_auto_update, .dismiss => unreachable,
}
_ = pending_actions.orderedRemove(i);
} else {
Expand Down Expand Up @@ -3444,6 +3508,12 @@ fn queueSetAutoUpdate(id: []const u8, on: bool) void {
};
}

/// Drop a refused job's card in `tick`, not mid-draw: its reason is what the card is drawing.
fn queueDismiss(id: []const u8) void {
const dup = app.gpa.dupe(u8, id) catch return;
pending_actions.append(app.gpa, .{ .dismiss = .{ .id = dup } }) catch app.gpa.free(dup);
}

fn queueUninstall(id: []const u8) void {
removePendingForId(id);
const dup = app.gpa.dupe(u8, id) catch {
Expand Down Expand Up @@ -3473,9 +3543,15 @@ fn applyPendingActions() void {
app.gpa.free(a.id);
},
.uninstall => |a| {
// A refused job's card goes with the plugin it was refused beside.
removeJob(a.id);
applyUninstall(a.id);
app.gpa.free(a.id);
},
.dismiss => |a| {
removeJob(a.id);
app.gpa.free(a.id);
},
};
pending_actions.clearRetainingCapacity();
}
Expand Down
Loading
Loading