From 6a0c14e879b306ce0d288de62d536f143a5eb7f9 Mon Sep 17 00:00:00 2001 From: Alexander Clawthorne <55900620+alexander-clawthorne@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:54:38 -0500 Subject: [PATCH 01/13] feat(ui): add game shortcuts to the Ports menu Pressing Select on a library title writes a launcher for it next to GreenOvercast.sh, so the game gets its own Ports entry. The shortcut re-runs the port launcher with the title's product id and GREENOVERCAST_AUTOSTART, and the library starts that title once when it is in the catalog; otherwise the library opens as usual. After the session the library is shown as for any other launch. Pressing Select again removes the shortcut. On muOS the cached artwork is also written to the Ports catalogue as a PNG fitted inside 320x240, matching what PortMaster does for installed ports. Other CFWs get the entry without artwork. Only files carrying the shortcut marker are replaced or removed, so an existing port with the same name is never touched. Select acts on release, so the Select + Start exit chord never creates a shortcut. Co-Authored-By: Claude Opus 5.5 --- build.zig | 2 + .../portmaster/greenovercast/GreenOvercast.sh | 11 + src/ui/artwork_loader.zig | 28 +++ src/ui/cover_image.zig | 146 ++++++++++++ src/ui/handheld_ui.zig | 100 +++++++- src/ui/library_view.zig | 17 +- src/ui/port_shortcut.zig | 218 ++++++++++++++++++ 7 files changed, 517 insertions(+), 5 deletions(-) create mode 100644 src/ui/cover_image.zig create mode 100644 src/ui/port_shortcut.zig diff --git a/build.zig b/build.zig index debebc4..446b0d0 100644 --- a/build.zig +++ b/build.zig @@ -528,6 +528,8 @@ pub fn build(b: *std.Build) void { .{ .source = "src/ui/control_icons.zig" }, .{ .source = "src/ui/navigation_repeat.zig" }, .{ .source = "src/ui/persistent_settings.zig" }, + .{ .source = "src/ui/port_shortcut.zig" }, + .{ .source = "src/ui/cover_image.zig" }, .{ .source = "src/ui/stream_dimensions.zig" }, .{ .source = "src/media/rtp/h264_depacketizer.zig" }, .{ .source = "src/net/json_reader.zig" }, diff --git a/packaging/portmaster/greenovercast/GreenOvercast.sh b/packaging/portmaster/greenovercast/GreenOvercast.sh index 8decca6..2345bc3 100644 --- a/packaging/portmaster/greenovercast/GreenOvercast.sh +++ b/packaging/portmaster/greenovercast/GreenOvercast.sh @@ -94,6 +94,17 @@ export GREENOVERCAST_H264_BOOTSTRAP_FILE="$video_bootstrap_file" export GREENOVERCAST_CATALOG_FILE="$catalog_file" export GREENOVERCAST_SETTINGS_FILE="$settings_file" export GREENOVERCAST_ARTWORK_CACHE_DIR="$artwork_cache_dir" + +# Game shortcuts are written next to this launcher and run it again with a title id. +port_launcher=$(realpath "$0") && export GREENOVERCAST_PORT_LAUNCHER="$port_launcher" +# muOS shows port box art from its catalogue, the same folder PortMaster fills on install. +for ports_catalogue in "/run/muos/storage/info/catalogue/External - Ports" \ + "/mnt/mmc/MUOS/info/catalogue/External - Ports"; do + if [ -d "$ports_catalogue/box" ]; then + export GREENOVERCAST_PORT_ARTWORK_DIR="$ports_catalogue/box" + break + fi +done export GREENOVERCAST_CEDAR_LIBRARY="$GAMEDIR/libgreenovercast-cedar.so" export GREENOVERCAST_MPP_LIBRARY="$GAMEDIR/libgreenovercast-mpp.so" export LD_LIBRARY_PATH="$GAMEDIR:${LD_LIBRARY_PATH:-}" diff --git a/src/ui/artwork_loader.zig b/src/ui/artwork_loader.zig index 627a535..6772ae0 100644 --- a/src/ui/artwork_loader.zig +++ b/src/ui/artwork_loader.zig @@ -1,5 +1,6 @@ const std = @import("std"); const settings = @import("persistent_settings.zig"); +const cover_image = @import("cover_image.zig"); const c = @cImport({ @cInclude("SDL2/SDL.h"); @@ -39,6 +40,33 @@ pub const Loader = struct { self.thread = try std.Thread.spawn(.{}, worker, .{self}); } + /// Converts a title's cached artwork into a PNG sized for port frontends. Returns + /// null when the artwork has not been cached yet. + pub fn cachedCoverPng(self: *const Loader, allocator: std.mem.Allocator, product_id: []const u8) ?[]u8 { + if (!settings.validProductId(product_id)) return null; + var path_buffer: [640]u8 = undefined; + const path = cachePath(self, product_id, &path_buffer) orelse return null; + const data = std.fs.cwd().readFileAlloc(allocator, path, response_limit) catch return null; + defer allocator.free(data); + var image = std.mem.zeroes(c.GoArtworkImage); + if (c.go_artwork_decode_jpeg(data.ptr, data.len, 1024, 1024, &image) != 0) return null; + defer c.go_artwork_image_destroy(&image); + const width: usize = @intCast(image.width); + const height: usize = @intCast(image.height); + const stride: usize = @intCast(image.stride); + const fitted = cover_image.fitWithin( + allocator, + image.pixels[0 .. stride * height], + width, + height, + stride, + cover_image.box_width, + cover_image.box_height, + ) catch return null; + defer fitted.deinit(allocator); + return cover_image.encodePng(allocator, fitted) catch null; + } + pub fn request(self: *Loader, product_id: []const u8, url: []const u8) void { if (!settings.validProductId(product_id) or url.len == 0 or url.len >= self.request_url.len) return; self.mutex.lock(); diff --git a/src/ui/cover_image.zig b/src/ui/cover_image.zig new file mode 100644 index 0000000..c98ff56 --- /dev/null +++ b/src/ui/cover_image.zig @@ -0,0 +1,146 @@ +const std = @import("std"); + +// PortMaster resizes muOS port artwork with "320x240>": fit inside the box, +// keep the aspect ratio, never enlarge. +pub const box_width = 320; +pub const box_height = 240; + +pub const Image = struct { + pixels: []u8, + width: usize, + height: usize, + + pub fn deinit(self: Image, allocator: std.mem.Allocator) void { + allocator.free(self.pixels); + } +}; + +pub fn fittedSize(width: usize, height: usize, max_width: usize, max_height: usize) [2]usize { + if (width <= max_width and height <= max_height) return .{ width, height }; + if (width * max_height >= height * max_width) + return .{ max_width, @max(1, height * max_width / width) }; + return .{ @max(1, width * max_height / height), max_height }; +} + +/// Box-filters a tightly or loosely packed RGB24 image into a new packed RGB24 image +/// that fits inside max_width x max_height. +pub fn fitWithin( + allocator: std.mem.Allocator, + pixels: []const u8, + width: usize, + height: usize, + stride: usize, + max_width: usize, + max_height: usize, +) !Image { + if (width == 0 or height == 0 or stride < width * 3 or + pixels.len < stride * (height - 1) + width * 3) return error.InvalidImage; + const size = fittedSize(width, height, max_width, max_height); + const output = try allocator.alloc(u8, size[0] * size[1] * 3); + for (0..size[1]) |y| { + const y0 = y * height / size[1]; + const y1 = @max(y0 + 1, (y + 1) * height / size[1]); + for (0..size[0]) |x| { + const x0 = x * width / size[0]; + const x1 = @max(x0 + 1, (x + 1) * width / size[0]); + var sum = [3]usize{ 0, 0, 0 }; + for (y0..y1) |source_y| { + const row = pixels[source_y * stride ..]; + for (x0..x1) |source_x| { + for (0..3) |channel| sum[channel] += row[source_x * 3 + channel]; + } + } + const count = (y1 - y0) * (x1 - x0); + const target = (y * size[0] + x) * 3; + for (0..3) |channel| output[target + channel] = @intCast(sum[channel] / count); + } + } + return .{ .pixels = output, .width = size[0], .height = size[1] }; +} + +pub fn encodePng(allocator: std.mem.Allocator, image: Image) ![]u8 { + if (image.width == 0 or image.height == 0 or + image.width > std.math.maxInt(u31) or image.height > std.math.maxInt(u31) or + image.pixels.len != image.width * image.height * 3) return error.InvalidImage; + + const row_bytes = image.width * 3; + const raw = try allocator.alloc(u8, (row_bytes + 1) * image.height); + defer allocator.free(raw); + for (0..image.height) |y| { + const target = raw[y * (row_bytes + 1) ..]; + target[0] = 0; + @memcpy(target[1 .. row_bytes + 1], image.pixels[y * row_bytes ..][0..row_bytes]); + } + + var compressed = std.ArrayList(u8).init(allocator); + defer compressed.deinit(); + var source = std.io.fixedBufferStream(raw); + try std.compress.zlib.compress(source.reader(), compressed.writer(), .{}); + + var output = std.ArrayList(u8).init(allocator); + errdefer output.deinit(); + try output.appendSlice("\x89PNG\r\n\x1a\n"); + var header: [13]u8 = undefined; + std.mem.writeInt(u32, header[0..4], @intCast(image.width), .big); + std.mem.writeInt(u32, header[4..8], @intCast(image.height), .big); + header[8] = 8; // bit depth + header[9] = 2; // truecolour RGB + header[10] = 0; + header[11] = 0; + header[12] = 0; + try writeChunk(&output, "IHDR", &header); + try writeChunk(&output, "IDAT", compressed.items); + try writeChunk(&output, "IEND", ""); + return output.toOwnedSlice(); +} + +fn writeChunk(output: *std.ArrayList(u8), kind: *const [4]u8, data: []const u8) !void { + var length: [4]u8 = undefined; + std.mem.writeInt(u32, &length, @intCast(data.len), .big); + try output.appendSlice(&length); + try output.appendSlice(kind); + try output.appendSlice(data); + var crc = std.hash.Crc32.init(); + crc.update(kind); + crc.update(data); + var checksum: [4]u8 = undefined; + std.mem.writeInt(u32, &checksum, crc.final(), .big); + try output.appendSlice(&checksum); +} + +test "fits portrait and landscape art inside the port artwork box without enlarging" { + try std.testing.expectEqual([2]usize{ 160, 240 }, fittedSize(256, 384, box_width, box_height)); + try std.testing.expectEqual([2]usize{ 320, 180 }, fittedSize(1280, 720, box_width, box_height)); + try std.testing.expectEqual([2]usize{ 100, 80 }, fittedSize(100, 80, box_width, box_height)); +} + +test "box filter averages each source block" { + const pixels = [_]u8{ + 0, 0, 0, 255, 255, 255, + 255, 255, 255, 0, 0, 0, + }; + const image = try fitWithin(std.testing.allocator, &pixels, 2, 2, 6, 1, 1); + defer image.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(usize, 1), image.width); + try std.testing.expectEqualSlices(u8, &.{ 127, 127, 127 }, image.pixels); +} + +test "png carries the image header and decompressible rows" { + var pixels = [_]u8{ 10, 20, 30, 40, 50, 60 }; + const png = try encodePng(std.testing.allocator, .{ .pixels = &pixels, .width = 2, .height = 1 }); + defer std.testing.allocator.free(png); + + try std.testing.expectEqualSlices(u8, "\x89PNG\r\n\x1a\n", png[0..8]); + try std.testing.expectEqualSlices(u8, "IHDR", png[12..16]); + try std.testing.expectEqual(@as(u32, 2), std.mem.readInt(u32, png[16..20], .big)); + try std.testing.expectEqual(@as(u32, 1), std.mem.readInt(u32, png[20..24], .big)); + try std.testing.expectEqualSlices(u8, "\x00\x00\x00\x00IEND\xae\x42\x60\x82", png[png.len - 12 ..]); + + const idat_length = std.mem.readInt(u32, png[33..37], .big); + try std.testing.expectEqualSlices(u8, "IDAT", png[37..41]); + var compressed = std.io.fixedBufferStream(png[41 .. 41 + idat_length]); + var raw = std.ArrayList(u8).init(std.testing.allocator); + defer raw.deinit(); + try std.compress.zlib.decompress(compressed.reader(), raw.writer()); + try std.testing.expectEqualSlices(u8, &.{ 0, 10, 20, 30, 40, 50, 60 }, raw.items); +} diff --git a/src/ui/handheld_ui.zig b/src/ui/handheld_ui.zig index 9e38a0e..76c6d9f 100644 --- a/src/ui/handheld_ui.zig +++ b/src/ui/handheld_ui.zig @@ -5,6 +5,7 @@ const keyboard = @import("keyboard.zig"); const library = @import("library_view.zig"); const navigation = @import("navigation_repeat.zig"); const persistent = @import("persistent_settings.zig"); +const port_shortcut = @import("port_shortcut.zig"); const provider_picker = @import("provider_picker.zig"); const provider_badge = @import("provider_badge.zig"); const font = @import("pixel_font.zig"); @@ -34,6 +35,9 @@ const Ui = struct { quit_requested: bool = false, stream_width: u32, stream_height: u32, + port_launcher: ?[]const u8 = null, + port_artwork_dir: ?[]const u8 = null, + autostart: bool = false, }; pub export fn go_handheld_ui_draw_stream_controls(ui: ?*Ui, mouse_mode: c_int, x: f32, y: f32, width: c_int, height: c_int, show_hint: c_int) void { @@ -402,9 +406,66 @@ fn updateArtwork(ui: *Ui, view: *const library.View, state: *ArtworkSelection) ? return ui.artwork.textureFor(ui.renderer, product_id); } +/// Adds the title to the Ports folder as its own launcher, or removes the shortcut if it +/// already exists. Returns the notice to show, or null when shortcuts are unavailable. +fn togglePortShortcut(ui: *Ui, title: *const library.Title) ?[*:0]const u8 { + const launcher = ui.port_launcher orelse return null; + const product_id = library.productId(title); + var name_buffer: [port_shortcut.name_capacity]u8 = undefined; + const name = port_shortcut.portName(&name_buffer, library.titleName(title)); + if (name.len == 0 or !persistent.validProductId(product_id)) return "THIS GAME CANNOT BE ADDED TO PORTS"; + const ports_path = std.fs.path.dirname(launcher) orelse return "PORTS FOLDER NOT FOUND"; + var ports = std.fs.openDirAbsolute(ports_path, .{}) catch return "PORTS FOLDER NOT FOUND"; + defer ports.close(); + var artwork_dir: ?std.fs.Dir = if (ui.port_artwork_dir) |path| + std.fs.openDirAbsolute(path, .{}) catch null + else + null; + defer if (artwork_dir) |*directory| directory.close(); + + var script_buffer: [port_shortcut.name_capacity + 8]u8 = undefined; + const script_name = std.fmt.bufPrint(&script_buffer, "{s}.sh", .{name}) catch unreachable; + switch (port_shortcut.state(ports, script_name)) { + .shortcut => { + port_shortcut.remove(ports, artwork_dir, name) catch |err| { + std.debug.print("Port shortcut could not be removed: {s}\n", .{@errorName(err)}); + return "SHORTCUT COULD NOT BE REMOVED"; + }; + std.debug.print("Removed port shortcut: {s}\n", .{name}); + return "REMOVED FROM PORTS"; + }, + .foreign => return "A PORT WITH THIS NAME ALREADY EXISTS", + .absent => {}, + } + + const cover = if (artwork_dir != null) ui.artwork.cachedCoverPng(std.heap.c_allocator, product_id) else null; + defer if (cover) |png| std.heap.c_allocator.free(png); + port_shortcut.add(ports, artwork_dir, .{ + .launcher = launcher, + .service = switch (ui.provider) { + .xbox => .xbox, + .geforce_now => .geforce_now, + }, + .product_id = product_id, + }, name, cover) catch |err| { + std.debug.print("Port shortcut could not be saved: {s}\n", .{@errorName(err)}); + return "SHORTCUT COULD NOT BE SAVED"; + }; + std.debug.print("Added port shortcut: {s}{s}\n", .{ name, if (cover == null) " (no artwork)" else "" }); + return "ADDED TO PORTS"; +} + fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_int { if (titles.len == 0) return c.GO_HANDHELD_UI_PICK_CANCELLED; ui.cancelled = false; + if (ui.autostart) { + ui.autostart = false; + if (library.findTitle(titles, requested)) |title_index| { + std.debug.print("Starting shortcut title\n", .{}); + return @intCast(title_index); + } + std.debug.print("Shortcut title is not in the library\n", .{}); + } const indices = std.heap.c_allocator.alloc(usize, titles.len) catch return c.GO_HANDHELD_UI_PICK_CANCELLED; defer std.heap.c_allocator.free(indices); var view = library.View{ .titles = titles, .indices = indices }; @@ -416,6 +477,9 @@ fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_in var right_trigger_latched = false; var artwork_selection = ArtworkSelection{}; var artwork_texture: ?*anyopaque = null; + var select_armed = false; + var notice: ?[*:0]const u8 = null; + var notice_until: c.Uint32 = 0; var dirty = true; while (true) { @@ -472,9 +536,14 @@ fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_in repeat.begin(.down, c.SDL_GetTicks()); dirty = true; }, + // Select acts on release so the Select + Start exit chord never adds a shortcut. + c.SDL_CONTROLLER_BUTTON_BACK => select_armed = + c.go_controller_input_button_pressed(ui.controller, c.SDL_CONTROLLER_BUTTON_START) == 0, c.SDL_CONTROLLER_BUTTON_START => { - if (c.go_controller_input_button_pressed(ui.controller, c.SDL_CONTROLLER_BUTTON_BACK) != 0) + if (c.go_controller_input_button_pressed(ui.controller, c.SDL_CONTROLLER_BUTTON_BACK) != 0) { + select_armed = false; continue; + } const result = settings_view.run( ui.renderer, ui.controller, @@ -502,6 +571,18 @@ fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_in }, else => {}, }; + if (event.type == c.SDL_CONTROLLERBUTTONUP and activeControllerEvent(ui, &event) and + semanticButton(ui, event.cbutton.button) == c.SDL_CONTROLLER_BUTTON_BACK and select_armed) + { + select_armed = false; + if (view.selectedTitleIndex()) |title_index| { + if (togglePortShortcut(ui, &titles[title_index])) |message| { + notice = message; + notice_until = c.SDL_GetTicks() +% 2500; + dirty = true; + } + } + } if (event.type == c.SDL_CONTROLLERAXISMOTION and activeControllerEvent(ui, &event)) { if (event.caxis.axis == c.SDL_CONTROLLER_AXIS_TRIGGERLEFT) { if (event.caxis.value > 16000 and !left_trigger_latched) { @@ -544,8 +625,20 @@ fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_in artwork_texture = next_artwork_texture; dirty = true; } + if (notice != null and @as(i32, @bitCast(c.SDL_GetTicks() -% notice_until)) >= 0) { + notice = null; + dirty = true; + } if (dirty) { - library.draw(ui.renderer, &view, &ui.settings, artwork_texture, ui.provider); + library.draw( + ui.renderer, + &view, + &ui.settings, + artwork_texture, + ui.provider, + ui.port_launcher != null, + notice, + ); dirty = false; } c.SDL_Delay(16); @@ -579,6 +672,9 @@ pub export fn go_handheld_ui_create( const artwork_cache_path = std.posix.getenv("GREENOVERCAST_ARTWORK_CACHE_DIR"); ui.artwork.start(if (artwork_cache_path) |path| path else null) catch |err| std.debug.print("Artwork loading disabled: {s}\n", .{@errorName(err)}); + ui.port_launcher = std.posix.getenv("GREENOVERCAST_PORT_LAUNCHER"); + ui.port_artwork_dir = std.posix.getenv("GREENOVERCAST_PORT_ARTWORK_DIR"); + ui.autostart = std.posix.getenv("GREENOVERCAST_AUTOSTART") != null; c.go_controller_input_set_face_button_mode( controller_handle, if (stored.face_buttons == .system) c.GO_FACE_BUTTON_MODE_SYSTEM else c.GO_FACE_BUTTON_MODE_SWAPPED, diff --git a/src/ui/library_view.zig b/src/ui/library_view.zig index a1be432..e830fb3 100644 --- a/src/ui/library_view.zig +++ b/src/ui/library_view.zig @@ -110,12 +110,17 @@ pub fn artworkUrl(title: *const Title) []const u8 { return bufferString(&title.artwork_url); } -pub fn requestedTitle(titles: []const Title, requested: []const u8) usize { +pub fn findTitle(titles: []const Title, requested: []const u8) ?usize { + if (requested.len == 0) return null; for (titles, 0..) |*title, index| { if (std.mem.eql(u8, bufferString(&title.title_id), requested) or std.mem.eql(u8, productId(title), requested)) return index; } - return 0; + return null; +} + +pub fn requestedTitle(titles: []const Title, requested: []const u8) usize { + return findTitle(titles, requested) orelse 0; } pub fn matchingCount( @@ -138,6 +143,8 @@ pub fn draw( store: *const settings.Store, artwork_pointer: ?*anyopaque, provider: provider_badge.Provider, + shortcuts: bool, + notice: ?[*:0]const u8, ) void { const renderer: *c.SDL_Renderer = @ptrCast(@alignCast(renderer_pointer)); const artwork: ?*c.SDL_Texture = if (artwork_pointer) |value| @ptrCast(@alignCast(value)) else null; @@ -216,9 +223,13 @@ pub fn draw( controls.Prompt.two(.left_bumper, .right_bumper, "TAB"), controls.Prompt.two(.left_trigger, .right_trigger, "LETTER"), controls.Prompt.one(.start, "SETTINGS"), + controls.Prompt.one(.select, "PORTS"), }; controls.drawRow(renderer, 16, 431, &primary, style.bright()); - controls.drawRow(renderer, 16, 455, &secondary, style.accent()); + if (notice) |message| + font.text(renderer, 16, 459, 2, message, style.bright()) + else + controls.drawRow(renderer, 16, 455, secondary[0..if (shortcuts) secondary.len else secondary.len - 1], style.accent()); c.SDL_RenderPresent(renderer); } diff --git a/src/ui/port_shortcut.zig b/src/ui/port_shortcut.zig new file mode 100644 index 0000000..fa0d6a6 --- /dev/null +++ b/src/ui/port_shortcut.zig @@ -0,0 +1,218 @@ +const std = @import("std"); +const settings = @import("persistent_settings.zig"); + +// Second line of every generated script; only files carrying it are ever replaced or removed. +pub const marker = "# GreenOvercast game shortcut"; +pub const name_capacity = 64; + +pub const Service = enum { + xbox, + geforce_now, + + fn value(self: Service) []const u8 { + return switch (self) { + .xbox => "xbox", + .geforce_now => "geforce-now", + }; + } +}; + +pub const Shortcut = struct { + /// Absolute path of the GreenOvercast port launcher the shortcut runs. + launcher: []const u8, + service: Service, + product_id: []const u8, +}; + +pub const State = enum { + absent, + shortcut, + foreign, +}; + +/// Turns a catalog title into a file name every CFW frontend accepts: printable ASCII +/// without path, quoting or wildcard characters, single spaces, no leading or trailing +/// spaces or dots. +pub fn portName(buffer: *[name_capacity]u8, title: []const u8) []const u8 { + var length: usize = 0; + var pending_space = false; + for (title) |byte| { + const keep = std.ascii.isAlphanumeric(byte) or switch (byte) { + '-', '_', '.', ',', '!', '&', '(', ')', '+', '\'' => true, + else => false, + }; + if (!keep) { + if (byte == ' ' or byte == ':' or byte == '/' or byte == '\t') pending_space = length > 0; + continue; + } + if (pending_space) { + if (length + 1 >= buffer.len) break; + buffer[length] = ' '; + length += 1; + pending_space = false; + } + if (length == buffer.len) break; + buffer[length] = byte; + length += 1; + } + while (length > 0 and (buffer[length - 1] == ' ' or buffer[length - 1] == '.')) length -= 1; + var start: usize = 0; + while (start < length and (buffer[start] == '.' or buffer[start] == ' ')) start += 1; + return buffer[start..length]; +} + +pub fn writeScript(writer: anytype, shortcut: Shortcut, name: []const u8) !void { + if (!std.fs.path.isAbsolute(shortcut.launcher) or + std.mem.indexOfAny(u8, shortcut.launcher, "'\n\r") != null) return error.InvalidLauncher; + if (!settings.validProductId(shortcut.product_id)) return error.InvalidProductId; + if (name.len == 0 or std.mem.indexOfAny(u8, name, "\n\r") != null) return error.InvalidName; + try writer.writeAll("#!/bin/bash\n" ++ marker ++ "\n"); + try writer.print("# {s}\n", .{name}); + try writer.print("export GREENOVERCAST_SERVICE={s}\n", .{shortcut.service.value()}); + try writer.writeAll("export GREENOVERCAST_AUTOSTART=1\n"); + // exec keeps $0 as the real launcher, which PortMaster's control.txt resolves GAMEDIR from. + try writer.print("exec /bin/bash '{s}' '{s}'\n", .{ shortcut.launcher, shortcut.product_id }); +} + +pub fn state(directory: std.fs.Dir, script_name: []const u8) State { + const file = directory.openFile(script_name, .{}) catch |err| return switch (err) { + error.FileNotFound => .absent, + else => .foreign, + }; + defer file.close(); + var buffer: [96]u8 = undefined; + const length = file.readAll(&buffer) catch return .foreign; + var lines = std.mem.splitScalar(u8, buffer[0..length], '\n'); + _ = lines.next(); + const second = lines.next() orelse return .foreign; + return if (std.mem.eql(u8, second, marker)) .shortcut else .foreign; +} + +pub fn add( + ports: std.fs.Dir, + artwork: ?std.fs.Dir, + shortcut: Shortcut, + name: []const u8, + cover_png: ?[]const u8, +) !void { + var script_buffer: [name_capacity + 8]u8 = undefined; + const script_name = try std.fmt.bufPrint(&script_buffer, "{s}.sh", .{name}); + if (state(ports, script_name) == .foreign) return error.NameInUse; + + var content = std.ArrayList(u8).init(std.heap.page_allocator); + defer content.deinit(); + try writeScript(content.writer(), shortcut, name); + try writeAtomic(ports, script_name, content.items, 0o755); + + const directory = artwork orelse return; + const png = cover_png orelse return; + var cover_buffer: [name_capacity + 8]u8 = undefined; + const cover_name = try std.fmt.bufPrint(&cover_buffer, "{s}.png", .{name}); + writeAtomic(directory, cover_name, png, 0o644) catch |err| + std.debug.print("Shortcut artwork could not be saved: {s}\n", .{@errorName(err)}); +} + +pub fn remove(ports: std.fs.Dir, artwork: ?std.fs.Dir, name: []const u8) !void { + var script_buffer: [name_capacity + 8]u8 = undefined; + const script_name = try std.fmt.bufPrint(&script_buffer, "{s}.sh", .{name}); + if (state(ports, script_name) != .shortcut) return error.NotAShortcut; + try ports.deleteFile(script_name); + const directory = artwork orelse return; + var cover_buffer: [name_capacity + 8]u8 = undefined; + const cover_name = try std.fmt.bufPrint(&cover_buffer, "{s}.png", .{name}); + directory.deleteFile(cover_name) catch |err| switch (err) { + error.FileNotFound => {}, + else => return err, + }; +} + +fn writeAtomic(directory: std.fs.Dir, name: []const u8, data: []const u8, mode: std.fs.File.Mode) !void { + var temporary_buffer: [name_capacity + 16]u8 = undefined; + const temporary = try std.fmt.bufPrint(&temporary_buffer, "{s}.tmp", .{name}); + errdefer directory.deleteFile(temporary) catch {}; + var file = try directory.createFile(temporary, .{ .truncate = true }); + var closed = false; + defer if (!closed) file.close(); + try file.writeAll(data); + // The port launcher runs under umask 077; frontends need to read and run shortcuts. + try file.chmod(mode); + try file.sync(); + file.close(); + closed = true; + try directory.rename(temporary, name); +} + +test "port names keep readable titles and drop unsafe characters" { + var buffer: [name_capacity]u8 = undefined; + try std.testing.expectEqualStrings("CONTROL Resonant", portName(&buffer, "CONTROL Resonant")); + try std.testing.expectEqualStrings( + "Ori and the Will of the Wisps", + portName(&buffer, "Ori and the Will of the Wisps\xe2\x84\xa2"), + ); + try std.testing.expectEqualStrings("Fallout 4 GOTY", portName(&buffer, "Fallout 4: GOTY")); + try std.testing.expectEqualStrings("Aerial Knight's Never Yield", portName(&buffer, "Aerial Knight's Never Yield")); + try std.testing.expectEqualStrings("AC DC", portName(&buffer, " ../AC/DC\"*?. ")); + try std.testing.expectEqualStrings("", portName(&buffer, "\xe5\x8e\x9f\xe7\xa5\x9e")); + const long = portName(&buffer, "A" ** 100); + try std.testing.expectEqual(@as(usize, name_capacity), long.len); +} + +test "shortcut script relaunches the port launcher straight into the title" { + var output = std.ArrayList(u8).init(std.testing.allocator); + defer output.deinit(); + try writeScript(output.writer(), .{ + .launcher = "/mnt/sdcard/ROMS/PORTS/GreenOvercast.sh", + .service = .geforce_now, + .product_id = "a1b2-C3", + }, "CONTROL"); + try std.testing.expectEqualStrings( + "#!/bin/bash\n" ++ marker ++ "\n# CONTROL\n" ++ + "export GREENOVERCAST_SERVICE=geforce-now\n" ++ + "export GREENOVERCAST_AUTOSTART=1\n" ++ + "exec /bin/bash '/mnt/sdcard/ROMS/PORTS/GreenOvercast.sh' 'a1b2-C3'\n", + output.items, + ); +} + +test "shortcut script rejects values that would break shell quoting" { + var output = std.ArrayList(u8).init(std.testing.allocator); + defer output.deinit(); + const base = Shortcut{ .launcher = "/roms/ports/GreenOvercast.sh", .service = .xbox, .product_id = "9NBLGGH4R315" }; + var shortcut = base; + shortcut.launcher = "/roms/ports/it's/GreenOvercast.sh"; + try std.testing.expectError(error.InvalidLauncher, writeScript(output.writer(), shortcut, "X")); + shortcut = base; + shortcut.launcher = "ports/GreenOvercast.sh"; + try std.testing.expectError(error.InvalidLauncher, writeScript(output.writer(), shortcut, "X")); + shortcut = base; + shortcut.product_id = "id' ; rm -rf /"; + try std.testing.expectError(error.InvalidProductId, writeScript(output.writer(), shortcut, "X")); +} + +test "shortcuts are added and removed without touching other ports" { + var ports = std.testing.tmpDir(.{}); + defer ports.cleanup(); + var artwork = std.testing.tmpDir(.{}); + defer artwork.cleanup(); + const shortcut = Shortcut{ + .launcher = "/roms/ports/GreenOvercast.sh", + .service = .xbox, + .product_id = "9NBLGGH4R315", + }; + + try add(ports.dir, artwork.dir, shortcut, "Halo", "png-bytes"); + try std.testing.expectEqual(State.shortcut, state(ports.dir, "Halo.sh")); + const stat = try ports.dir.statFile("Halo.sh"); + try std.testing.expectEqual(@as(std.fs.File.Mode, 0o755), stat.mode & 0o777); + var cover_buffer: [16]u8 = undefined; + try std.testing.expectEqualStrings("png-bytes", try artwork.dir.readFile("Halo.png", &cover_buffer)); + + try ports.dir.writeFile(.{ .sub_path = "Celeste.sh", .data = "#!/bin/bash\n# PortMaster\n" }); + try std.testing.expectError(error.NameInUse, add(ports.dir, null, shortcut, "Celeste", null)); + try std.testing.expectError(error.NotAShortcut, remove(ports.dir, null, "Celeste")); + try std.testing.expectEqual(State.foreign, state(ports.dir, "Celeste.sh")); + + try remove(ports.dir, artwork.dir, "Halo"); + try std.testing.expectEqual(State.absent, state(ports.dir, "Halo.sh")); + try std.testing.expectError(error.FileNotFound, artwork.dir.access("Halo.png", .{})); +} From 8fc331600b7b06824a4488ad249fbe2c78501311 Mon Sep 17 00:00:00 2001 From: Alexander Clawthorne <55900620+alexander-clawthorne@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:51:33 -0500 Subject: [PATCH 02/13] fix(ui): read shortcut artwork without statx readFileAlloc sizes its buffer with File.stat(), which uses statx and fails on the 4.9 kernels H700 devices ship, so shortcuts were saved without artwork on muOS. Read the cached image with readToEndAlloc, as the settings store does, and log which step fails if a cover cannot be produced. Co-Authored-By: Claude Opus 5.5 --- src/ui/artwork_loader.zig | 33 +++++++++++++++++++++++++++++---- src/ui/handheld_ui.zig | 5 ++++- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/ui/artwork_loader.zig b/src/ui/artwork_loader.zig index 6772ae0..e5c09af 100644 --- a/src/ui/artwork_loader.zig +++ b/src/ui/artwork_loader.zig @@ -46,10 +46,18 @@ pub const Loader = struct { if (!settings.validProductId(product_id)) return null; var path_buffer: [640]u8 = undefined; const path = cachePath(self, product_id, &path_buffer) orelse return null; - const data = std.fs.cwd().readFileAlloc(allocator, path, response_limit) catch return null; + // Not readFileAlloc: it sizes the buffer with File.stat(), which needs statx and + // fails on the 4.9 kernels these handhelds ship (see stat_compat.zig). + const data = readCache(allocator, path) catch |err| { + std.debug.print("Shortcut artwork unavailable: {s}\n", .{@errorName(err)}); + return null; + }; defer allocator.free(data); var image = std.mem.zeroes(c.GoArtworkImage); - if (c.go_artwork_decode_jpeg(data.ptr, data.len, 1024, 1024, &image) != 0) return null; + if (c.go_artwork_decode_jpeg(data.ptr, data.len, 1024, 1024, &image) != 0) { + std.debug.print("Shortcut artwork could not be decoded\n", .{}); + return null; + } defer c.go_artwork_image_destroy(&image); const width: usize = @intCast(image.width); const height: usize = @intCast(image.height); @@ -62,9 +70,20 @@ pub const Loader = struct { stride, cover_image.box_width, cover_image.box_height, - ) catch return null; + ) catch |err| { + std.debug.print("Shortcut artwork could not be resized ({d}x{d}, stride {d}): {s}\n", .{ + width, + height, + stride, + @errorName(err), + }); + return null; + }; defer fitted.deinit(allocator); - return cover_image.encodePng(allocator, fitted) catch null; + return cover_image.encodePng(allocator, fitted) catch |err| { + std.debug.print("Shortcut artwork could not be encoded: {s}\n", .{@errorName(err)}); + return null; + }; } pub fn request(self: *Loader, product_id: []const u8, url: []const u8) void { @@ -273,6 +292,12 @@ fn cachePath(loader: *const Loader, product_id: []const u8, output: []u8) ?[]con ) catch null; } +fn readCache(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const file = try std.fs.cwd().openFile(path, .{}); + defer file.close(); + return file.readToEndAlloc(allocator, response_limit); +} + fn writeCache(path: []const u8, data: []const u8) void { var temporary_buffer: [645]u8 = undefined; const temporary = std.fmt.bufPrint(&temporary_buffer, "{s}.tmp", .{path}) catch return; diff --git a/src/ui/handheld_ui.zig b/src/ui/handheld_ui.zig index 76c6d9f..cf34c57 100644 --- a/src/ui/handheld_ui.zig +++ b/src/ui/handheld_ui.zig @@ -418,7 +418,10 @@ fn togglePortShortcut(ui: *Ui, title: *const library.Title) ?[*:0]const u8 { var ports = std.fs.openDirAbsolute(ports_path, .{}) catch return "PORTS FOLDER NOT FOUND"; defer ports.close(); var artwork_dir: ?std.fs.Dir = if (ui.port_artwork_dir) |path| - std.fs.openDirAbsolute(path, .{}) catch null + std.fs.openDirAbsolute(path, .{}) catch |err| blk: { + std.debug.print("Port artwork folder unavailable: {s}\n", .{@errorName(err)}); + break :blk null; + } else null; defer if (artwork_dir) |*directory| directory.close(); From b3bbf8e6651abc84dada211f2ba4b49e5eb03e5e Mon Sep 17 00:00:00 2001 From: Alexander Clawthorne <55900620+alexander-clawthorne@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:08:20 -0500 Subject: [PATCH 03/13] fix(ui): harden shortcut file handling - Treat a generated script as this title's shortcut only when it launches the same product id. Different titles can share a port name, and Select on one must not remove the other's shortcut. - Write through std.fs.AtomicFile, which creates a randomly named temporary file exclusively, instead of truncating .sh.tmp in the shared Ports folder. - Remove the artwork first and best-effort, then the script, so a reported failure always matches what is left on disk. - Stay quiet when a title's artwork is simply not cached yet, and note why cover generation runs on the caller's thread. Co-Authored-By: Claude Opus 5.5 --- src/ui/artwork_loader.zig | 7 ++- src/ui/handheld_ui.zig | 4 +- src/ui/port_shortcut.zig | 90 +++++++++++++++++++++++++-------------- 3 files changed, 67 insertions(+), 34 deletions(-) diff --git a/src/ui/artwork_loader.zig b/src/ui/artwork_loader.zig index e5c09af..bb68a94 100644 --- a/src/ui/artwork_loader.zig +++ b/src/ui/artwork_loader.zig @@ -42,6 +42,10 @@ pub const Loader = struct { /// Converts a title's cached artwork into a PNG sized for port frontends. Returns /// null when the artwork has not been cached yet. + /// + /// Runs on the caller's thread. It is only called when the player adds a shortcut, + /// and cached artwork is requested at w=256 (GeForce NOW) or 320x480 (Xbox), so the + /// decode, resize and encode are a one-off cost rather than per-frame work. pub fn cachedCoverPng(self: *const Loader, allocator: std.mem.Allocator, product_id: []const u8) ?[]u8 { if (!settings.validProductId(product_id)) return null; var path_buffer: [640]u8 = undefined; @@ -49,7 +53,8 @@ pub const Loader = struct { // Not readFileAlloc: it sizes the buffer with File.stat(), which needs statx and // fails on the 4.9 kernels these handhelds ship (see stat_compat.zig). const data = readCache(allocator, path) catch |err| { - std.debug.print("Shortcut artwork unavailable: {s}\n", .{@errorName(err)}); + if (err != error.FileNotFound) + std.debug.print("Shortcut artwork unavailable: {s}\n", .{@errorName(err)}); return null; }; defer allocator.free(data); diff --git a/src/ui/handheld_ui.zig b/src/ui/handheld_ui.zig index cf34c57..d42cac9 100644 --- a/src/ui/handheld_ui.zig +++ b/src/ui/handheld_ui.zig @@ -428,9 +428,9 @@ fn togglePortShortcut(ui: *Ui, title: *const library.Title) ?[*:0]const u8 { var script_buffer: [port_shortcut.name_capacity + 8]u8 = undefined; const script_name = std.fmt.bufPrint(&script_buffer, "{s}.sh", .{name}) catch unreachable; - switch (port_shortcut.state(ports, script_name)) { + switch (port_shortcut.state(ports, script_name, product_id)) { .shortcut => { - port_shortcut.remove(ports, artwork_dir, name) catch |err| { + port_shortcut.remove(ports, artwork_dir, name, product_id) catch |err| { std.debug.print("Port shortcut could not be removed: {s}\n", .{@errorName(err)}); return "SHORTCUT COULD NOT BE REMOVED"; }; diff --git a/src/ui/port_shortcut.zig b/src/ui/port_shortcut.zig index fa0d6a6..f0738e1 100644 --- a/src/ui/port_shortcut.zig +++ b/src/ui/port_shortcut.zig @@ -74,18 +74,25 @@ pub fn writeScript(writer: anytype, shortcut: Shortcut, name: []const u8) !void try writer.print("exec /bin/bash '{s}' '{s}'\n", .{ shortcut.launcher, shortcut.product_id }); } -pub fn state(directory: std.fs.Dir, script_name: []const u8) State { +/// `.shortcut` only for a generated script that launches this product. Different titles +/// can share a port name, so a marked script for another product counts as foreign. +pub fn state(directory: std.fs.Dir, script_name: []const u8, product_id: []const u8) State { const file = directory.openFile(script_name, .{}) catch |err| return switch (err) { error.FileNotFound => .absent, else => .foreign, }; defer file.close(); - var buffer: [96]u8 = undefined; + var buffer: [1024]u8 = undefined; const length = file.readAll(&buffer) catch return .foreign; - var lines = std.mem.splitScalar(u8, buffer[0..length], '\n'); + if (length == buffer.len) return .foreign; + const content = buffer[0..length]; + var lines = std.mem.splitScalar(u8, content, '\n'); _ = lines.next(); const second = lines.next() orelse return .foreign; - return if (std.mem.eql(u8, second, marker)) .shortcut else .foreign; + if (!std.mem.eql(u8, second, marker)) return .foreign; + var ending_buffer: [settings.product_id_capacity + 8]u8 = undefined; + const ending = std.fmt.bufPrint(&ending_buffer, " '{s}'\n", .{product_id}) catch return .foreign; + return if (std.mem.endsWith(u8, content, ending)) .shortcut else .foreign; } pub fn add( @@ -97,7 +104,7 @@ pub fn add( ) !void { var script_buffer: [name_capacity + 8]u8 = undefined; const script_name = try std.fmt.bufPrint(&script_buffer, "{s}.sh", .{name}); - if (state(ports, script_name) == .foreign) return error.NameInUse; + if (state(ports, script_name, shortcut.product_id) == .foreign) return error.NameInUse; var content = std.ArrayList(u8).init(std.heap.page_allocator); defer content.deinit(); @@ -112,34 +119,33 @@ pub fn add( std.debug.print("Shortcut artwork could not be saved: {s}\n", .{@errorName(err)}); } -pub fn remove(ports: std.fs.Dir, artwork: ?std.fs.Dir, name: []const u8) !void { +pub fn remove(ports: std.fs.Dir, artwork: ?std.fs.Dir, name: []const u8, product_id: []const u8) !void { var script_buffer: [name_capacity + 8]u8 = undefined; const script_name = try std.fmt.bufPrint(&script_buffer, "{s}.sh", .{name}); - if (state(ports, script_name) != .shortcut) return error.NotAShortcut; + if (state(ports, script_name, product_id) != .shortcut) return error.NotAShortcut; + // Artwork first and best-effort, so the script is removed last and any error reported + // matches what is still on disk. + if (artwork) |directory| { + var cover_buffer: [name_capacity + 8]u8 = undefined; + const cover_name = try std.fmt.bufPrint(&cover_buffer, "{s}.png", .{name}); + directory.deleteFile(cover_name) catch |err| switch (err) { + error.FileNotFound => {}, + else => std.debug.print("Shortcut artwork could not be removed: {s}\n", .{@errorName(err)}), + }; + } try ports.deleteFile(script_name); - const directory = artwork orelse return; - var cover_buffer: [name_capacity + 8]u8 = undefined; - const cover_name = try std.fmt.bufPrint(&cover_buffer, "{s}.png", .{name}); - directory.deleteFile(cover_name) catch |err| switch (err) { - error.FileNotFound => {}, - else => return err, - }; } fn writeAtomic(directory: std.fs.Dir, name: []const u8, data: []const u8, mode: std.fs.File.Mode) !void { - var temporary_buffer: [name_capacity + 16]u8 = undefined; - const temporary = try std.fmt.bufPrint(&temporary_buffer, "{s}.tmp", .{name}); - errdefer directory.deleteFile(temporary) catch {}; - var file = try directory.createFile(temporary, .{ .truncate = true }); - var closed = false; - defer if (!closed) file.close(); - try file.writeAll(data); + // AtomicFile creates a randomly named temporary exclusively, so nothing else in the + // shared Ports folder can be overwritten before the final rename. + var file = try directory.atomicFile(name, .{ .mode = mode }); + defer file.deinit(); + try file.file.writeAll(data); // The port launcher runs under umask 077; frontends need to read and run shortcuts. - try file.chmod(mode); - try file.sync(); - file.close(); - closed = true; - try directory.rename(temporary, name); + try file.file.chmod(mode); + try file.file.sync(); + try file.finish(); } test "port names keep readable titles and drop unsafe characters" { @@ -200,19 +206,41 @@ test "shortcuts are added and removed without touching other ports" { .product_id = "9NBLGGH4R315", }; + const product = shortcut.product_id; + try add(ports.dir, artwork.dir, shortcut, "Halo", "png-bytes"); - try std.testing.expectEqual(State.shortcut, state(ports.dir, "Halo.sh")); + try std.testing.expectEqual(State.shortcut, state(ports.dir, "Halo.sh", product)); const stat = try ports.dir.statFile("Halo.sh"); try std.testing.expectEqual(@as(std.fs.File.Mode, 0o755), stat.mode & 0o777); var cover_buffer: [16]u8 = undefined; try std.testing.expectEqualStrings("png-bytes", try artwork.dir.readFile("Halo.png", &cover_buffer)); + // Re-adding the same title replaces its own shortcut. + try add(ports.dir, artwork.dir, shortcut, "Halo", "png-bytes"); try ports.dir.writeFile(.{ .sub_path = "Celeste.sh", .data = "#!/bin/bash\n# PortMaster\n" }); + try ports.dir.writeFile(.{ .sub_path = "Celeste.sh.tmp", .data = "unrelated" }); try std.testing.expectError(error.NameInUse, add(ports.dir, null, shortcut, "Celeste", null)); - try std.testing.expectError(error.NotAShortcut, remove(ports.dir, null, "Celeste")); - try std.testing.expectEqual(State.foreign, state(ports.dir, "Celeste.sh")); + try std.testing.expectError(error.NotAShortcut, remove(ports.dir, null, "Celeste", product)); + try std.testing.expectEqual(State.foreign, state(ports.dir, "Celeste.sh", product)); + var tmp_buffer: [16]u8 = undefined; + try std.testing.expectEqualStrings("unrelated", try ports.dir.readFile("Celeste.sh.tmp", &tmp_buffer)); - try remove(ports.dir, artwork.dir, "Halo"); - try std.testing.expectEqual(State.absent, state(ports.dir, "Halo.sh")); + try remove(ports.dir, artwork.dir, "Halo", product); + try std.testing.expectEqual(State.absent, state(ports.dir, "Halo.sh", product)); try std.testing.expectError(error.FileNotFound, artwork.dir.access("Halo.png", .{})); } + +test "a shortcut for another title with the same port name is left alone" { + var ports = std.testing.tmpDir(.{}); + defer ports.cleanup(); + const first = Shortcut{ .launcher = "/roms/ports/GreenOvercast.sh", .service = .geforce_now, .product_id = "title-a" }; + var second = first; + second.product_id = "title-b"; + + // "A/B" and "A:B" both become "A B". + try add(ports.dir, null, first, "A B", null); + try std.testing.expectEqual(State.foreign, state(ports.dir, "A B.sh", second.product_id)); + try std.testing.expectError(error.NameInUse, add(ports.dir, null, second, "A B", null)); + try std.testing.expectError(error.NotAShortcut, remove(ports.dir, null, "A B", second.product_id)); + try std.testing.expectEqual(State.shortcut, state(ports.dir, "A B.sh", first.product_id)); +} From 3cb0f7895925df5f8c406260cc0fb7d589e3547a Mon Sep 17 00:00:00 2001 From: Alexander Clawthorne <55900620+alexander-clawthorne@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:08:20 -0500 Subject: [PATCH 04/13] fix(portmaster): report when shortcuts cannot find the launcher Resolve the launcher from BASH_SOURCE so the path stays right if a wrapper sources it, and log that game shortcuts are disabled when the path cannot be resolved instead of dropping them silently. Co-Authored-By: Claude Opus 5.5 --- packaging/portmaster/greenovercast/GreenOvercast.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packaging/portmaster/greenovercast/GreenOvercast.sh b/packaging/portmaster/greenovercast/GreenOvercast.sh index 2345bc3..75d5717 100644 --- a/packaging/portmaster/greenovercast/GreenOvercast.sh +++ b/packaging/portmaster/greenovercast/GreenOvercast.sh @@ -96,7 +96,11 @@ export GREENOVERCAST_SETTINGS_FILE="$settings_file" export GREENOVERCAST_ARTWORK_CACHE_DIR="$artwork_cache_dir" # Game shortcuts are written next to this launcher and run it again with a title id. -port_launcher=$(realpath "$0") && export GREENOVERCAST_PORT_LAUNCHER="$port_launcher" +if port_launcher=$(realpath "${BASH_SOURCE[0]:-$0}"); then + export GREENOVERCAST_PORT_LAUNCHER="$port_launcher" +else + echo "Game shortcuts disabled: unable to resolve the launcher path." >&2 +fi # muOS shows port box art from its catalogue, the same folder PortMaster fills on install. for ports_catalogue in "/run/muos/storage/info/catalogue/External - Ports" \ "/mnt/mmc/MUOS/info/catalogue/External - Ports"; do From 69517dd8bdce071be481209d48a183e01b1a9b5c Mon Sep 17 00:00:00 2001 From: Alexander Clawthorne <55900620+alexander-clawthorne@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:21:46 -0500 Subject: [PATCH 05/13] fix(ui): bound shortcut scripts to what state() reads writeScript accepted launcher paths of any length, while state() only reads the first 1024 bytes, so an unusually long Ports path could produce a shortcut that could never be recognised, removed or replaced. Cap the launcher path at 512 bytes and assert at compile time that the longest possible script fits in the read buffer. Co-Authored-By: Claude Opus 5.5 --- src/ui/port_shortcut.zig | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/ui/port_shortcut.zig b/src/ui/port_shortcut.zig index f0738e1..0c1de66 100644 --- a/src/ui/port_shortcut.zig +++ b/src/ui/port_shortcut.zig @@ -4,6 +4,16 @@ const settings = @import("persistent_settings.zig"); // Second line of every generated script; only files carrying it are ever replaced or removed. pub const marker = "# GreenOvercast game shortcut"; pub const name_capacity = 64; +/// Longest launcher path writeScript accepts; keeps every script within what state() reads. +pub const max_launcher_length = 512; + +const script_read_capacity = 1024; +const max_script_length = ("#!/bin/bash\n" ++ marker ++ "\n").len + "# \n".len + name_capacity + + "export GREENOVERCAST_SERVICE=geforce-now\n".len + "export GREENOVERCAST_AUTOSTART=1\n".len + + "exec /bin/bash '' ''\n".len + max_launcher_length + settings.product_id_capacity; +comptime { + std.debug.assert(max_script_length < script_read_capacity); +} pub const Service = enum { xbox, @@ -62,7 +72,7 @@ pub fn portName(buffer: *[name_capacity]u8, title: []const u8) []const u8 { } pub fn writeScript(writer: anytype, shortcut: Shortcut, name: []const u8) !void { - if (!std.fs.path.isAbsolute(shortcut.launcher) or + if (!std.fs.path.isAbsolute(shortcut.launcher) or shortcut.launcher.len > max_launcher_length or std.mem.indexOfAny(u8, shortcut.launcher, "'\n\r") != null) return error.InvalidLauncher; if (!settings.validProductId(shortcut.product_id)) return error.InvalidProductId; if (name.len == 0 or std.mem.indexOfAny(u8, name, "\n\r") != null) return error.InvalidName; @@ -82,7 +92,7 @@ pub fn state(directory: std.fs.Dir, script_name: []const u8, product_id: []const else => .foreign, }; defer file.close(); - var buffer: [1024]u8 = undefined; + var buffer: [script_read_capacity]u8 = undefined; const length = file.readAll(&buffer) catch return .foreign; if (length == buffer.len) return .foreign; const content = buffer[0..length]; @@ -244,3 +254,23 @@ test "a shortcut for another title with the same port name is left alone" { try std.testing.expectError(error.NotAShortcut, remove(ports.dir, null, "A B", second.product_id)); try std.testing.expectEqual(State.shortcut, state(ports.dir, "A B.sh", first.product_id)); } + +test "the longest accepted shortcut can still be recognised and removed" { + var ports = std.testing.tmpDir(.{}); + defer ports.cleanup(); + const launcher = "/" ++ "p" ** (max_launcher_length - 1); + const product_id = "x" ** (settings.product_id_capacity - 1); + const name = "N" ** name_capacity; + const longest = Shortcut{ .launcher = launcher, .service = .geforce_now, .product_id = product_id }; + + try add(ports.dir, null, longest, name, null); + try std.testing.expectEqual(State.shortcut, state(ports.dir, name ++ ".sh", product_id)); + try remove(ports.dir, null, name, product_id); + try std.testing.expectEqual(State.absent, state(ports.dir, name ++ ".sh", product_id)); + + var too_long = longest; + too_long.launcher = launcher ++ "p"; + var output = std.ArrayList(u8).init(std.testing.allocator); + defer output.deinit(); + try std.testing.expectError(error.InvalidLauncher, writeScript(output.writer(), too_long, "N")); +} From d4842b541646809c61d8df230a939528c31e7ba4 Mon Sep 17 00:00:00 2001 From: Alexander Clawthorne <55900620+alexander-clawthorne@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:16:15 -0500 Subject: [PATCH 06/13] fix(ui): only replace or remove artwork the shortcut created muOS keeps every port's box art in one shared catalogue, also across both cards, so a same-named PNG can belong to a native port. add() overwrote it when a cover was cached, and remove() deleted it either way. A shortcut now writes its artwork only when no image exists yet, and records the CRC-32 and size of what it wrote in its own script. remove() deletes the image only when that record exists and still matches the file, so artwork created or replaced by anything else is kept. Re-adding a shortcut replaces only its own unchanged image and keeps the record when no new cover is available. The image is read rather than stat'ed, since statx is unavailable on 4.9 kernels. Co-Authored-By: Claude Opus 5.5 --- src/ui/port_shortcut.zig | 280 +++++++++++++++++++++++++++++++++------ 1 file changed, 242 insertions(+), 38 deletions(-) diff --git a/src/ui/port_shortcut.zig b/src/ui/port_shortcut.zig index 0c1de66..9c3c776 100644 --- a/src/ui/port_shortcut.zig +++ b/src/ui/port_shortcut.zig @@ -6,10 +6,16 @@ pub const marker = "# GreenOvercast game shortcut"; pub const name_capacity = 64; /// Longest launcher path writeScript accepts; keeps every script within what state() reads. pub const max_launcher_length = 512; +/// Written only when the shortcut created its artwork. Port artwork folders such as muOS's +/// shared catalogue also hold other ports' images, so artwork is replaced or removed only +/// when this record exists and still matches the file on disk. +pub const artwork_prefix = "# GreenOvercast artwork: "; +const max_artwork_bytes = 4 * 1024 * 1024; const script_read_capacity = 1024; const max_script_length = ("#!/bin/bash\n" ++ marker ++ "\n").len + "# \n".len + name_capacity + "export GREENOVERCAST_SERVICE=geforce-now\n".len + "export GREENOVERCAST_AUTOSTART=1\n".len + + (artwork_prefix ++ "crc32=00000000 size=\n").len + std.fmt.count("{d}", .{std.math.maxInt(u64)}) + "exec /bin/bash '' ''\n".len + max_launcher_length + settings.product_id_capacity; comptime { std.debug.assert(max_script_length < script_read_capacity); @@ -40,6 +46,16 @@ pub const State = enum { foreign, }; +/// Identifies the exact artwork file a shortcut wrote. +pub const ArtworkRecord = struct { + crc32: u32, + size: u64, + + pub fn of(data: []const u8) ArtworkRecord { + return .{ .crc32 = std.hash.Crc32.hash(data), .size = data.len }; + } +}; + /// Turns a catalog title into a file name every CFW frontend accepts: printable ASCII /// without path, quoting or wildcard characters, single spaces, no leading or trailing /// spaces or dots. @@ -71,7 +87,7 @@ pub fn portName(buffer: *[name_capacity]u8, title: []const u8) []const u8 { return buffer[start..length]; } -pub fn writeScript(writer: anytype, shortcut: Shortcut, name: []const u8) !void { +pub fn writeScript(writer: anytype, shortcut: Shortcut, name: []const u8, artwork: ?ArtworkRecord) !void { if (!std.fs.path.isAbsolute(shortcut.launcher) or shortcut.launcher.len > max_launcher_length or std.mem.indexOfAny(u8, shortcut.launcher, "'\n\r") != null) return error.InvalidLauncher; if (!settings.validProductId(shortcut.product_id)) return error.InvalidProductId; @@ -80,29 +96,81 @@ pub fn writeScript(writer: anytype, shortcut: Shortcut, name: []const u8) !void try writer.print("# {s}\n", .{name}); try writer.print("export GREENOVERCAST_SERVICE={s}\n", .{shortcut.service.value()}); try writer.writeAll("export GREENOVERCAST_AUTOSTART=1\n"); + if (artwork) |record| + try writer.print(artwork_prefix ++ "crc32={x:0>8} size={d}\n", .{ record.crc32, record.size }); // exec keeps $0 as the real launcher, which PortMaster's control.txt resolves GAMEDIR from. try writer.print("exec /bin/bash '{s}' '{s}'\n", .{ shortcut.launcher, shortcut.product_id }); } -/// `.shortcut` only for a generated script that launches this product. Different titles -/// can share a port name, so a marked script for another product counts as foreign. -pub fn state(directory: std.fs.Dir, script_name: []const u8, product_id: []const u8) State { +const Inspection = struct { + state: State, + content: []const u8 = "", +}; + +fn inspect( + directory: std.fs.Dir, + script_name: []const u8, + product_id: []const u8, + buffer: *[script_read_capacity]u8, +) Inspection { const file = directory.openFile(script_name, .{}) catch |err| return switch (err) { - error.FileNotFound => .absent, - else => .foreign, + error.FileNotFound => .{ .state = .absent }, + else => .{ .state = .foreign }, }; defer file.close(); - var buffer: [script_read_capacity]u8 = undefined; - const length = file.readAll(&buffer) catch return .foreign; - if (length == buffer.len) return .foreign; + const length = file.readAll(buffer) catch return .{ .state = .foreign }; + if (length == buffer.len) return .{ .state = .foreign }; const content = buffer[0..length]; var lines = std.mem.splitScalar(u8, content, '\n'); _ = lines.next(); - const second = lines.next() orelse return .foreign; - if (!std.mem.eql(u8, second, marker)) return .foreign; + const second = lines.next() orelse return .{ .state = .foreign }; + if (!std.mem.eql(u8, second, marker)) return .{ .state = .foreign }; var ending_buffer: [settings.product_id_capacity + 8]u8 = undefined; - const ending = std.fmt.bufPrint(&ending_buffer, " '{s}'\n", .{product_id}) catch return .foreign; - return if (std.mem.endsWith(u8, content, ending)) .shortcut else .foreign; + const ending = std.fmt.bufPrint(&ending_buffer, " '{s}'\n", .{product_id}) catch + return .{ .state = .foreign }; + if (!std.mem.endsWith(u8, content, ending)) return .{ .state = .foreign }; + return .{ .state = .shortcut, .content = content }; +} + +/// `.shortcut` only for a generated script that launches this product. Different titles +/// can share a port name, so a marked script for another product counts as foreign. +pub fn state(directory: std.fs.Dir, script_name: []const u8, product_id: []const u8) State { + var buffer: [script_read_capacity]u8 = undefined; + return inspect(directory, script_name, product_id, &buffer).state; +} + +/// The artwork a generated script recorded as its own, if any. +pub fn recordedArtwork(script: []const u8) ?ArtworkRecord { + var lines = std.mem.splitScalar(u8, script, '\n'); + while (lines.next()) |line| { + if (!std.mem.startsWith(u8, line, artwork_prefix)) continue; + var fields = std.mem.tokenizeScalar(u8, line[artwork_prefix.len..], ' '); + const crc_field = fields.next() orelse return null; + const size_field = fields.next() orelse return null; + if (fields.next() != null or !std.mem.startsWith(u8, crc_field, "crc32=") or + !std.mem.startsWith(u8, size_field, "size=")) return null; + return .{ + .crc32 = std.fmt.parseUnsigned(u32, crc_field["crc32=".len..], 16) catch return null, + .size = std.fmt.parseUnsigned(u64, size_field["size=".len..], 10) catch return null, + }; + } + return null; +} + +/// Whether the artwork on disk is still exactly the file a shortcut wrote. Reads the file +/// rather than calling stat(), which needs statx and fails on 4.9 kernels. +fn artworkMatches(directory: std.fs.Dir, cover_name: []const u8, record: ArtworkRecord) bool { + const file = directory.openFile(cover_name, .{}) catch return false; + defer file.close(); + const data = file.readToEndAlloc(std.heap.page_allocator, max_artwork_bytes) catch return false; + defer std.heap.page_allocator.free(data); + const current = ArtworkRecord.of(data); + return current.crc32 == record.crc32 and current.size == record.size; +} + +fn artworkExists(directory: std.fs.Dir, cover_name: []const u8) bool { + directory.access(cover_name, .{}) catch |err| return err != error.FileNotFound; + return true; } pub fn add( @@ -114,35 +182,65 @@ pub fn add( ) !void { var script_buffer: [name_capacity + 8]u8 = undefined; const script_name = try std.fmt.bufPrint(&script_buffer, "{s}.sh", .{name}); - if (state(ports, script_name, shortcut.product_id) == .foreign) return error.NameInUse; - - var content = std.ArrayList(u8).init(std.heap.page_allocator); - defer content.deinit(); - try writeScript(content.writer(), shortcut, name); - try writeAtomic(ports, script_name, content.items, 0o755); + var existing_buffer: [script_read_capacity]u8 = undefined; + const existing = inspect(ports, script_name, shortcut.product_id, &existing_buffer); + if (existing.state == .foreign) return error.NameInUse; - const directory = artwork orelse return; - const png = cover_png orelse return; + var owned: ?ArtworkRecord = null; + var created_artwork = false; var cover_buffer: [name_capacity + 8]u8 = undefined; const cover_name = try std.fmt.bufPrint(&cover_buffer, "{s}.png", .{name}); - writeAtomic(directory, cover_name, png, 0o644) catch |err| - std.debug.print("Shortcut artwork could not be saved: {s}\n", .{@errorName(err)}); + if (artwork) |directory| { + // Only an image this shortcut wrote, still unchanged on disk, may be replaced, and + // its record carries over when no new image is written. + if (recordedArtwork(existing.content)) |record| { + if (artworkMatches(directory, cover_name, record)) owned = record; + } + } + if (artwork) |directory| if (cover_png) |png| { + const absent = !artworkExists(directory, cover_name); + const ours = owned != null; + if (absent or ours) { + if (writeAtomic(directory, cover_name, png, 0o644)) { + owned = ArtworkRecord.of(png); + created_artwork = absent; + } else |err| std.debug.print("Shortcut artwork could not be saved: {s}\n", .{@errorName(err)}); + } else std.debug.print("Existing artwork kept: {s}\n", .{cover_name}); + }; + + var content = std.ArrayList(u8).init(std.heap.page_allocator); + defer content.deinit(); + writeScript(content.writer(), shortcut, name, owned) catch |err| { + if (created_artwork) artwork.?.deleteFile(cover_name) catch {}; + return err; + }; + writeAtomic(ports, script_name, content.items, 0o755) catch |err| { + if (created_artwork) artwork.?.deleteFile(cover_name) catch {}; + return err; + }; } pub fn remove(ports: std.fs.Dir, artwork: ?std.fs.Dir, name: []const u8, product_id: []const u8) !void { var script_buffer: [name_capacity + 8]u8 = undefined; const script_name = try std.fmt.bufPrint(&script_buffer, "{s}.sh", .{name}); - if (state(ports, script_name, product_id) != .shortcut) return error.NotAShortcut; + var existing_buffer: [script_read_capacity]u8 = undefined; + const existing = inspect(ports, script_name, product_id, &existing_buffer); + if (existing.state != .shortcut) return error.NotAShortcut; // Artwork first and best-effort, so the script is removed last and any error reported - // matches what is still on disk. - if (artwork) |directory| { + // matches what is still on disk. Only an image this shortcut wrote, unchanged since, + // is removed; anything else in the shared artwork folder belongs to someone else. + if (artwork) |directory| if (recordedArtwork(existing.content)) |record| { var cover_buffer: [name_capacity + 8]u8 = undefined; const cover_name = try std.fmt.bufPrint(&cover_buffer, "{s}.png", .{name}); - directory.deleteFile(cover_name) catch |err| switch (err) { - error.FileNotFound => {}, - else => std.debug.print("Shortcut artwork could not be removed: {s}\n", .{@errorName(err)}), - }; - } + if (artworkMatches(directory, cover_name, record)) { + directory.deleteFile(cover_name) catch |err| switch (err) { + error.FileNotFound => {}, + else => std.debug.print("Shortcut artwork could not be removed: {s}\n", .{@errorName(err)}), + }; + } else if (artworkExists(directory, cover_name)) { + std.debug.print("Artwork changed since the shortcut saved it, kept: {s}\n", .{cover_name}); + } + }; try ports.deleteFile(script_name); } @@ -180,7 +278,7 @@ test "shortcut script relaunches the port launcher straight into the title" { .launcher = "/mnt/sdcard/ROMS/PORTS/GreenOvercast.sh", .service = .geforce_now, .product_id = "a1b2-C3", - }, "CONTROL"); + }, "CONTROL", null); try std.testing.expectEqualStrings( "#!/bin/bash\n" ++ marker ++ "\n# CONTROL\n" ++ "export GREENOVERCAST_SERVICE=geforce-now\n" ++ @@ -190,19 +288,41 @@ test "shortcut script relaunches the port launcher straight into the title" { ); } +test "shortcut script records the artwork it owns" { + var output = std.ArrayList(u8).init(std.testing.allocator); + defer output.deinit(); + const record = ArtworkRecord{ .crc32 = 0x0a1b2c3d, .size = 104952 }; + try writeScript(output.writer(), .{ + .launcher = "/mnt/sdcard/ROMS/PORTS/GreenOvercast.sh", + .service = .xbox, + .product_id = "9NBLGGH4R315", + }, "Halo", record); + try std.testing.expectEqualStrings( + "#!/bin/bash\n" ++ marker ++ "\n# Halo\n" ++ + "export GREENOVERCAST_SERVICE=xbox\n" ++ + "export GREENOVERCAST_AUTOSTART=1\n" ++ + artwork_prefix ++ "crc32=0a1b2c3d size=104952\n" ++ + "exec /bin/bash '/mnt/sdcard/ROMS/PORTS/GreenOvercast.sh' '9NBLGGH4R315'\n", + output.items, + ); + try std.testing.expectEqual(record, recordedArtwork(output.items).?); + try std.testing.expectEqual(@as(?ArtworkRecord, null), recordedArtwork("#!/bin/bash\n" ++ marker ++ "\n")); + try std.testing.expectEqual(@as(?ArtworkRecord, null), recordedArtwork(artwork_prefix ++ "crc32=zz size=1\n")); +} + test "shortcut script rejects values that would break shell quoting" { var output = std.ArrayList(u8).init(std.testing.allocator); defer output.deinit(); const base = Shortcut{ .launcher = "/roms/ports/GreenOvercast.sh", .service = .xbox, .product_id = "9NBLGGH4R315" }; var shortcut = base; shortcut.launcher = "/roms/ports/it's/GreenOvercast.sh"; - try std.testing.expectError(error.InvalidLauncher, writeScript(output.writer(), shortcut, "X")); + try std.testing.expectError(error.InvalidLauncher, writeScript(output.writer(), shortcut, "X", null)); shortcut = base; shortcut.launcher = "ports/GreenOvercast.sh"; - try std.testing.expectError(error.InvalidLauncher, writeScript(output.writer(), shortcut, "X")); + try std.testing.expectError(error.InvalidLauncher, writeScript(output.writer(), shortcut, "X", null)); shortcut = base; shortcut.product_id = "id' ; rm -rf /"; - try std.testing.expectError(error.InvalidProductId, writeScript(output.writer(), shortcut, "X")); + try std.testing.expectError(error.InvalidProductId, writeScript(output.writer(), shortcut, "X", null)); } test "shortcuts are added and removed without touching other ports" { @@ -240,6 +360,86 @@ test "shortcuts are added and removed without touching other ports" { try std.testing.expectError(error.FileNotFound, artwork.dir.access("Halo.png", .{})); } +const celeste = Shortcut{ .launcher = "/roms/ports/GreenOvercast.sh", .service = .geforce_now, .product_id = "gfn-celeste" }; + +fn expectArtwork(directory: std.fs.Dir, expected: []const u8) !void { + var buffer: [32]u8 = undefined; + try std.testing.expectEqualStrings(expected, try directory.readFile("Celeste.png", &buffer)); +} + +test "existing artwork survives a shortcut added without a cover" { + // muOS shares one artwork catalogue between the Ports folders on both cards, so a + // native Celeste port elsewhere can already own Celeste.png. + var ports = std.testing.tmpDir(.{}); + defer ports.cleanup(); + var artwork = std.testing.tmpDir(.{}); + defer artwork.cleanup(); + try artwork.dir.writeFile(.{ .sub_path = "Celeste.png", .data = "native-port-art" }); + + try add(ports.dir, artwork.dir, celeste, "Celeste", null); + try expectArtwork(artwork.dir, "native-port-art"); + try remove(ports.dir, artwork.dir, "Celeste", celeste.product_id); + try expectArtwork(artwork.dir, "native-port-art"); + try std.testing.expectEqual(State.absent, state(ports.dir, "Celeste.sh", celeste.product_id)); +} + +test "existing artwork is neither overwritten nor removed by a shortcut with a cover" { + var ports = std.testing.tmpDir(.{}); + defer ports.cleanup(); + var artwork = std.testing.tmpDir(.{}); + defer artwork.cleanup(); + try artwork.dir.writeFile(.{ .sub_path = "Celeste.png", .data = "native-port-art" }); + + try add(ports.dir, artwork.dir, celeste, "Celeste", "shortcut-art"); + try expectArtwork(artwork.dir, "native-port-art"); + var script_buffer: [script_read_capacity]u8 = undefined; + try std.testing.expectEqual( + @as(?ArtworkRecord, null), + recordedArtwork(try ports.dir.readFile("Celeste.sh", &script_buffer)), + ); + try remove(ports.dir, artwork.dir, "Celeste", celeste.product_id); + try expectArtwork(artwork.dir, "native-port-art"); +} + +test "a shortcut replaces and removes only the artwork it wrote" { + var ports = std.testing.tmpDir(.{}); + defer ports.cleanup(); + var artwork = std.testing.tmpDir(.{}); + defer artwork.cleanup(); + + try add(ports.dir, artwork.dir, celeste, "Celeste", "shortcut-art-1"); + try expectArtwork(artwork.dir, "shortcut-art-1"); + var script_buffer: [script_read_capacity]u8 = undefined; + try std.testing.expectEqual( + ArtworkRecord.of("shortcut-art-1"), + recordedArtwork(try ports.dir.readFile("Celeste.sh", &script_buffer)).?, + ); + + // Re-adding replaces the image it owns and records the new one. + try add(ports.dir, artwork.dir, celeste, "Celeste", "shortcut-art-2"); + try expectArtwork(artwork.dir, "shortcut-art-2"); + // Re-adding without a cached cover keeps that image and its record. + try add(ports.dir, artwork.dir, celeste, "Celeste", null); + try expectArtwork(artwork.dir, "shortcut-art-2"); + try remove(ports.dir, artwork.dir, "Celeste", celeste.product_id); + try std.testing.expectError(error.FileNotFound, artwork.dir.access("Celeste.png", .{})); +} + +test "artwork replaced after the shortcut saved it is kept" { + var ports = std.testing.tmpDir(.{}); + defer ports.cleanup(); + var artwork = std.testing.tmpDir(.{}); + defer artwork.cleanup(); + + try add(ports.dir, artwork.dir, celeste, "Celeste", "shortcut-art"); + // For example, a native Celeste port installed later writes its own image. + try artwork.dir.writeFile(.{ .sub_path = "Celeste.png", .data = "native-port-art" }); + try add(ports.dir, artwork.dir, celeste, "Celeste", "shortcut-art-2"); + try expectArtwork(artwork.dir, "native-port-art"); + try remove(ports.dir, artwork.dir, "Celeste", celeste.product_id); + try expectArtwork(artwork.dir, "native-port-art"); +} + test "a shortcut for another title with the same port name is left alone" { var ports = std.testing.tmpDir(.{}); defer ports.cleanup(); @@ -258,19 +458,23 @@ test "a shortcut for another title with the same port name is left alone" { test "the longest accepted shortcut can still be recognised and removed" { var ports = std.testing.tmpDir(.{}); defer ports.cleanup(); + var artwork = std.testing.tmpDir(.{}); + defer artwork.cleanup(); const launcher = "/" ++ "p" ** (max_launcher_length - 1); const product_id = "x" ** (settings.product_id_capacity - 1); const name = "N" ** name_capacity; const longest = Shortcut{ .launcher = launcher, .service = .geforce_now, .product_id = product_id }; - try add(ports.dir, null, longest, name, null); + // With artwork, so the ownership line is part of the longest script. + try add(ports.dir, artwork.dir, longest, name, "png"); try std.testing.expectEqual(State.shortcut, state(ports.dir, name ++ ".sh", product_id)); - try remove(ports.dir, null, name, product_id); + try remove(ports.dir, artwork.dir, name, product_id); try std.testing.expectEqual(State.absent, state(ports.dir, name ++ ".sh", product_id)); + try std.testing.expectError(error.FileNotFound, artwork.dir.access(name ++ ".png", .{})); var too_long = longest; too_long.launcher = launcher ++ "p"; var output = std.ArrayList(u8).init(std.testing.allocator); defer output.deinit(); - try std.testing.expectError(error.InvalidLauncher, writeScript(output.writer(), too_long, "N")); + try std.testing.expectError(error.InvalidLauncher, writeScript(output.writer(), too_long, "N", null)); } From 5c17dc0737118f1f7869b78b6288c44be965df59 Mon Sep 17 00:00:00 2001 From: Alexander Clawthorne <55900620+alexander-clawthorne@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:16:15 -0500 Subject: [PATCH 07/13] fix(portmaster): resolve the launcher before changing directories The shortcut launcher path was resolved after `cd "$GAMEDIR"`, so a relative start such as `bash ./GreenOvercast.sh` exported a nonexistent path inside the data directory. Resolve it before PortMaster's helpers run or the directory changes, and export it only when it names an existing file. Co-Authored-By: Claude Opus 5.5 --- packaging/portmaster/greenovercast/GreenOvercast.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packaging/portmaster/greenovercast/GreenOvercast.sh b/packaging/portmaster/greenovercast/GreenOvercast.sh index 75d5717..9eff7b7 100644 --- a/packaging/portmaster/greenovercast/GreenOvercast.sh +++ b/packaging/portmaster/greenovercast/GreenOvercast.sh @@ -2,6 +2,10 @@ umask 077 +# Resolve this launcher before PortMaster's helpers run or the directory changes, so a +# relative invocation such as "bash ./GreenOvercast.sh" still names the real file. +greenovercast_launcher=$(realpath "${BASH_SOURCE[0]:-$0}" 2>/dev/null) || greenovercast_launcher= + XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share} XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config} @@ -96,8 +100,8 @@ export GREENOVERCAST_SETTINGS_FILE="$settings_file" export GREENOVERCAST_ARTWORK_CACHE_DIR="$artwork_cache_dir" # Game shortcuts are written next to this launcher and run it again with a title id. -if port_launcher=$(realpath "${BASH_SOURCE[0]:-$0}"); then - export GREENOVERCAST_PORT_LAUNCHER="$port_launcher" +if [ -n "$greenovercast_launcher" ] && [ -f "$greenovercast_launcher" ]; then + export GREENOVERCAST_PORT_LAUNCHER="$greenovercast_launcher" else echo "Game shortcuts disabled: unable to resolve the launcher path." >&2 fi From 3e77e68660105229465e278fc004e16b6ec25122 Mon Sep 17 00:00:00 2001 From: Alexander Clawthorne <55900620+alexander-clawthorne@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:37:15 -0500 Subject: [PATCH 08/13] fix(ui): keep shortcut artwork and its record consistent add() wrote the artwork before building and saving the script, so a failed script save left the previous script recording the old image while a new one was on disk, orphaning it. Build and validate the script first, and if saving it fails, delete artwork that was just created or restore the image the shortcut owned from the bytes read when checking ownership. A failed artwork write saves the script with the previous record, so an image that is not on disk is never recorded. New artwork is created exclusively, so a file another writer creates after the existence check is kept instead of replaced. Exclusive creation is used rather than a no-replace rename, which FUSE filesystems such as the muOS catalogue's do not reliably support. remove() still verifies and deletes in separate steps, which POSIX cannot make atomic; that is documented. Co-Authored-By: Claude Opus 5.5 --- src/ui/port_shortcut.zig | 192 +++++++++++++++++++++++++++++++++------ 1 file changed, 163 insertions(+), 29 deletions(-) diff --git a/src/ui/port_shortcut.zig b/src/ui/port_shortcut.zig index 9c3c776..a1224eb 100644 --- a/src/ui/port_shortcut.zig +++ b/src/ui/port_shortcut.zig @@ -157,15 +157,41 @@ pub fn recordedArtwork(script: []const u8) ?ArtworkRecord { return null; } -/// Whether the artwork on disk is still exactly the file a shortcut wrote. Reads the file -/// rather than calling stat(), which needs statx and fails on 4.9 kernels. -fn artworkMatches(directory: std.fs.Dir, cover_name: []const u8, record: ArtworkRecord) bool { - const file = directory.openFile(cover_name, .{}) catch return false; +/// The artwork's bytes when the file on disk is still exactly what `record` describes, so +/// add() can restore it if saving the script fails. Reads the file rather than calling +/// stat(), which needs statx and fails on 4.9 kernels. Free with std.heap.page_allocator. +fn readOwnedArtwork(directory: std.fs.Dir, cover_name: []const u8, record: ArtworkRecord) ?[]u8 { + const file = directory.openFile(cover_name, .{}) catch return null; defer file.close(); - const data = file.readToEndAlloc(std.heap.page_allocator, max_artwork_bytes) catch return false; - defer std.heap.page_allocator.free(data); + const data = file.readToEndAlloc(std.heap.page_allocator, max_artwork_bytes) catch return null; const current = ArtworkRecord.of(data); - return current.crc32 == record.crc32 and current.size == record.size; + if (current.crc32 == record.crc32 and current.size == record.size) return data; + std.heap.page_allocator.free(data); + return null; +} + +fn artworkMatches(directory: std.fs.Dir, cover_name: []const u8, record: ArtworkRecord) bool { + const data = readOwnedArtwork(directory, cover_name, record) orelse return false; + std.heap.page_allocator.free(data); + return true; +} + +/// Creates a file that must not exist yet. If another writer creates the name after our +/// existence check, this fails with error.PathAlreadyExists instead of replacing their file. +/// Exclusive creation rather than a no-replace rename, which FUSE filesystems such as the +/// muOS catalogue's do not reliably support. A failed write removes only the file it created. +fn writeNew(directory: std.fs.Dir, name: []const u8, data: []const u8, mode: std.fs.File.Mode) !void { + var file = try directory.createFile(name, .{ .exclusive = true }); + var complete = false; + defer { + file.close(); + if (!complete) directory.deleteFile(name) catch {}; + } + try file.writeAll(data); + // The port launcher runs under umask 077; frontends need to read the artwork. + try file.chmod(mode); + try file.sync(); + complete = true; } fn artworkExists(directory: std.fs.Dir, cover_name: []const u8) bool { @@ -186,36 +212,65 @@ pub fn add( const existing = inspect(ports, script_name, shortcut.product_id, &existing_buffer); if (existing.state == .foreign) return error.NameInUse; - var owned: ?ArtworkRecord = null; - var created_artwork = false; var cover_buffer: [name_capacity + 8]u8 = undefined; const cover_name = try std.fmt.bufPrint(&cover_buffer, "{s}.png", .{name}); - if (artwork) |directory| { - // Only an image this shortcut wrote, still unchanged on disk, may be replaced, and - // its record carries over when no new image is written. - if (recordedArtwork(existing.content)) |record| { - if (artworkMatches(directory, cover_name, record)) owned = record; + + // Only an image this shortcut wrote, still unchanged on disk, counts as its own. Its + // bytes are kept so it can be restored if saving the script fails. + var previous: ?ArtworkRecord = null; + var previous_bytes: ?[]u8 = null; + defer if (previous_bytes) |bytes| std.heap.page_allocator.free(bytes); + if (artwork) |directory| if (recordedArtwork(existing.content)) |record| { + if (readOwnedArtwork(directory, cover_name, record)) |bytes| { + previous = record; + previous_bytes = bytes; } - } - if (artwork) |directory| if (cover_png) |png| { - const absent = !artworkExists(directory, cover_name); - const ours = owned != null; - if (absent or ours) { - if (writeAtomic(directory, cover_name, png, 0o644)) { - owned = ArtworkRecord.of(png); - created_artwork = absent; - } else |err| std.debug.print("Shortcut artwork could not be saved: {s}\n", .{@errorName(err)}); - } else std.debug.print("Existing artwork kept: {s}\n", .{cover_name}); }; + const ArtworkPlan = enum { keep, create, replace }; + const plan: ArtworkPlan = plan: { + const directory = artwork orelse break :plan .keep; + if (cover_png == null) break :plan .keep; + if (previous != null) break :plan .replace; + if (!artworkExists(directory, cover_name)) break :plan .create; + std.debug.print("Existing artwork kept: {s}\n", .{cover_name}); + break :plan .keep; + }; + + // Build and validate the script before touching any file. var content = std.ArrayList(u8).init(std.heap.page_allocator); defer content.deinit(); - writeScript(content.writer(), shortcut, name, owned) catch |err| { - if (created_artwork) artwork.?.deleteFile(cover_name) catch {}; - return err; - }; + try writeScript(content.writer(), shortcut, name, if (plan == .keep) previous else ArtworkRecord.of(cover_png.?)); + + var written = plan; + switch (plan) { + .keep => {}, + .create => writeNew(artwork.?, cover_name, cover_png.?, 0o644) catch |err| { + written = .keep; + if (err == error.PathAlreadyExists) + std.debug.print("Existing artwork kept: {s}\n", .{cover_name}) + else + std.debug.print("Shortcut artwork could not be saved: {s}\n", .{@errorName(err)}); + }, + .replace => writeAtomic(artwork.?, cover_name, cover_png.?, 0o644) catch |err| { + written = .keep; + std.debug.print("Shortcut artwork could not be saved: {s}\n", .{@errorName(err)}); + }, + } + // Never record an image that is not on disk: fall back to what the shortcut owned before. + if (written != plan) { + content.clearRetainingCapacity(); + try writeScript(content.writer(), shortcut, name, previous); + } + writeAtomic(ports, script_name, content.items, 0o755) catch |err| { - if (created_artwork) artwork.?.deleteFile(cover_name) catch {}; + // Roll the artwork back so the existing script and its record stay consistent. + switch (written) { + .keep => {}, + .create => artwork.?.deleteFile(cover_name) catch {}, + .replace => writeAtomic(artwork.?, cover_name, previous_bytes.?, 0o644) catch |restore_err| + std.debug.print("Shortcut artwork could not be restored: {s}\n", .{@errorName(restore_err)}), + } return err; }; } @@ -229,6 +284,9 @@ pub fn remove(ports: std.fs.Dir, artwork: ?std.fs.Dir, name: []const u8, product // Artwork first and best-effort, so the script is removed last and any error reported // matches what is still on disk. Only an image this shortcut wrote, unchanged since, // is removed; anything else in the shared artwork folder belongs to someone else. + // Verifying and deleting are separate steps, as POSIX has no compare-and-delete. That + // leaves a short window, but GreenOvercast runs in the foreground on these single-app + // handhelds, so nothing else writes the catalogue while a shortcut is being removed. if (artwork) |directory| if (recordedArtwork(existing.content)) |record| { var cover_buffer: [name_capacity + 8]u8 = undefined; const cover_name = try std.fmt.bufPrint(&cover_buffer, "{s}.png", .{name}); @@ -440,6 +498,82 @@ test "artwork replaced after the shortcut saved it is kept" { try expectArtwork(artwork.dir, "native-port-art"); } +fn setMode(directory: std.fs.Dir, mode: std.posix.mode_t) !void { + const path = try directory.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(path); + try std.posix.fchmodat(std.fs.cwd().fd, path, mode, 0); +} + +fn skipAsRoot() !void { + // Root ignores directory permissions, so a read-only Ports folder cannot fail a save. + if (std.os.linux.geteuid() == 0) return error.SkipZigTest; +} + +test "invalid input changes no artwork" { + var ports = std.testing.tmpDir(.{}); + defer ports.cleanup(); + var artwork = std.testing.tmpDir(.{}); + defer artwork.cleanup(); + try add(ports.dir, artwork.dir, celeste, "Celeste", "shortcut-art-1"); + + var invalid = celeste; + invalid.launcher = "relative/GreenOvercast.sh"; + try std.testing.expectError(error.InvalidLauncher, add(ports.dir, artwork.dir, invalid, "Celeste", "shortcut-art-2")); + try expectArtwork(artwork.dir, "shortcut-art-1"); +} + +test "a failed script save restores the artwork the shortcut owned" { + try skipAsRoot(); + var ports = std.testing.tmpDir(.{}); + defer ports.cleanup(); + var artwork = std.testing.tmpDir(.{}); + defer artwork.cleanup(); + try add(ports.dir, artwork.dir, celeste, "Celeste", "shortcut-art-1"); + + try setMode(ports.dir, 0o555); + const result = add(ports.dir, artwork.dir, celeste, "Celeste", "shortcut-art-2"); + try setMode(ports.dir, 0o755); + try std.testing.expectError(error.AccessDenied, result); + + // The previous script and its record still describe the artwork on disk. + try expectArtwork(artwork.dir, "shortcut-art-1"); + var script_buffer: [script_read_capacity]u8 = undefined; + try std.testing.expectEqual( + ArtworkRecord.of("shortcut-art-1"), + recordedArtwork(try ports.dir.readFile("Celeste.sh", &script_buffer)).?, + ); + try remove(ports.dir, artwork.dir, "Celeste", celeste.product_id); + try std.testing.expectError(error.FileNotFound, artwork.dir.access("Celeste.png", .{})); +} + +test "a failed script save removes artwork it just created" { + try skipAsRoot(); + var ports = std.testing.tmpDir(.{}); + defer ports.cleanup(); + var artwork = std.testing.tmpDir(.{}); + defer artwork.cleanup(); + + try setMode(ports.dir, 0o555); + const result = add(ports.dir, artwork.dir, celeste, "Celeste", "shortcut-art"); + try setMode(ports.dir, 0o755); + try std.testing.expectError(error.AccessDenied, result); + try std.testing.expectError(error.FileNotFound, artwork.dir.access("Celeste.png", .{})); + try std.testing.expectEqual(State.absent, state(ports.dir, "Celeste.sh", celeste.product_id)); +} + +test "new artwork never replaces a file created after the existence check" { + var artwork = std.testing.tmpDir(.{}); + defer artwork.cleanup(); + // Another writer created the name between artworkExists() and the write. + try artwork.dir.writeFile(.{ .sub_path = "Celeste.png", .data = "native-port-art" }); + try std.testing.expectError(error.PathAlreadyExists, writeNew(artwork.dir, "Celeste.png", "shortcut-art", 0o644)); + try expectArtwork(artwork.dir, "native-port-art"); + + try writeNew(artwork.dir, "Fresh.png", "shortcut-art", 0o644); + const stat = try artwork.dir.statFile("Fresh.png"); + try std.testing.expectEqual(@as(std.fs.File.Mode, 0o644), stat.mode & 0o777); +} + test "a shortcut for another title with the same port name is left alone" { var ports = std.testing.tmpDir(.{}); defer ports.cleanup(); From 94550d0b0453119924f699d40d838ed5b5f52e0e Mon Sep 17 00:00:00 2001 From: Alexander Clawthorne <55900620+alexander-clawthorne@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:13:13 -0500 Subject: [PATCH 09/13] fix(ui): ignore invalid shortcut settings from the environment openDirAbsolute() asserts that its path is absolute, so a relative or empty GREENOVERCAST_PORT_LAUNCHER or GREENOVERCAST_PORT_ARTWORK_DIR ended the app on Select in ReleaseSafe builds. Accept only non-empty absolute paths and treat anything else as shortcuts being unavailable. Autostart now requires GREENOVERCAST_AUTOSTART=1 rather than any value. Co-Authored-By: Claude Opus 5.5 --- src/ui/handheld_ui.zig | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/ui/handheld_ui.zig b/src/ui/handheld_ui.zig index d42cac9..5e28bd5 100644 --- a/src/ui/handheld_ui.zig +++ b/src/ui/handheld_ui.zig @@ -406,6 +406,17 @@ fn updateArtwork(ui: *Ui, view: *const library.View, state: *ArtworkSelection) ? return ui.artwork.textureFor(ui.renderer, product_id); } +/// A shortcut path from the environment, or null unless it is a non-empty absolute path. +/// openDirAbsolute() asserts on anything else, which would end the app in ReleaseSafe. +fn absoluteEnvPath(name: [:0]const u8) ?[]const u8 { + const value = std.posix.getenv(name) orelse return null; + if (value.len == 0 or !std.fs.path.isAbsolute(value)) { + std.debug.print("Ignoring {s}: not an absolute path\n", .{name}); + return null; + } + return value; +} + /// Adds the title to the Ports folder as its own launcher, or removes the shortcut if it /// already exists. Returns the notice to show, or null when shortcuts are unavailable. fn togglePortShortcut(ui: *Ui, title: *const library.Title) ?[*:0]const u8 { @@ -675,9 +686,9 @@ pub export fn go_handheld_ui_create( const artwork_cache_path = std.posix.getenv("GREENOVERCAST_ARTWORK_CACHE_DIR"); ui.artwork.start(if (artwork_cache_path) |path| path else null) catch |err| std.debug.print("Artwork loading disabled: {s}\n", .{@errorName(err)}); - ui.port_launcher = std.posix.getenv("GREENOVERCAST_PORT_LAUNCHER"); - ui.port_artwork_dir = std.posix.getenv("GREENOVERCAST_PORT_ARTWORK_DIR"); - ui.autostart = std.posix.getenv("GREENOVERCAST_AUTOSTART") != null; + ui.port_launcher = absoluteEnvPath("GREENOVERCAST_PORT_LAUNCHER"); + ui.port_artwork_dir = absoluteEnvPath("GREENOVERCAST_PORT_ARTWORK_DIR"); + ui.autostart = if (std.posix.getenv("GREENOVERCAST_AUTOSTART")) |value| std.mem.eql(u8, value, "1") else false; c.go_controller_input_set_face_button_mode( controller_handle, if (stored.face_buttons == .system) c.GO_FACE_BUTTON_MODE_SYSTEM else c.GO_FACE_BUTTON_MODE_SWAPPED, From ad94ad927f852ca1fc6b9d9ad4714861725f1126 Mon Sep 17 00:00:00 2001 From: Alexander Clawthorne <55900620+alexander-clawthorne@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:13:15 -0500 Subject: [PATCH 10/13] fix(ui): cancel a pending Select when another view opens The search keyboard and settings consume Select's release, so a press made before opening them could toggle a shortcut on a later release back in the library. Clear the pending press before either view opens. Co-Authored-By: Claude Opus 5.5 --- src/ui/handheld_ui.zig | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ui/handheld_ui.zig b/src/ui/handheld_ui.zig index 5e28bd5..e2f5d0e 100644 --- a/src/ui/handheld_ui.zig +++ b/src/ui/handheld_ui.zig @@ -520,6 +520,9 @@ fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_in return c.GO_HANDHELD_UI_PICK_CANCELLED; }, c.SDL_CONTROLLER_BUTTON_X => { + // The keyboard consumes Select's release, so a pending press must not + // fire on a later release back in the library. + select_armed = false; const preserve = view.selectedTitleIndex(); const result = runSearchKeyboard(ui, &view); if (result < 0) return c.GO_HANDHELD_UI_PICK_CANCELLED; @@ -554,10 +557,9 @@ fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_in c.SDL_CONTROLLER_BUTTON_BACK => select_armed = c.go_controller_input_button_pressed(ui.controller, c.SDL_CONTROLLER_BUTTON_START) == 0, c.SDL_CONTROLLER_BUTTON_START => { - if (c.go_controller_input_button_pressed(ui.controller, c.SDL_CONTROLLER_BUTTON_BACK) != 0) { - select_armed = false; + select_armed = false; + if (c.go_controller_input_button_pressed(ui.controller, c.SDL_CONTROLLER_BUTTON_BACK) != 0) continue; - } const result = settings_view.run( ui.renderer, ui.controller, From 5d979418e184b987db5be554cd22d5bf2dd5a6ab Mon Sep 17 00:00:00 2001 From: Alexander Clawthorne <55900620+alexander-clawthorne@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:13:16 -0500 Subject: [PATCH 11/13] fix(ui): report a missing shortcut game and show shortcut problems in red A shortcut whose game is no longer in the catalog opened the library without explanation. Show SHORTCUT GAME NOT FOUND instead. Notices now carry a kind, and problems (game not found, save or removal failures, name in use, missing folders) draw in a new danger() red so they stand out from ADDED and REMOVED. Co-Authored-By: Claude Opus 5.5 --- src/ui/handheld_ui.zig | 29 ++++++++++++++++++----------- src/ui/library_view.zig | 17 +++++++++++++++-- src/ui/view_style.zig | 5 +++++ 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/ui/handheld_ui.zig b/src/ui/handheld_ui.zig index e2f5d0e..19fceec 100644 --- a/src/ui/handheld_ui.zig +++ b/src/ui/handheld_ui.zig @@ -418,15 +418,16 @@ fn absoluteEnvPath(name: [:0]const u8) ?[]const u8 { } /// Adds the title to the Ports folder as its own launcher, or removes the shortcut if it -/// already exists. Returns the notice to show, or null when shortcuts are unavailable. -fn togglePortShortcut(ui: *Ui, title: *const library.Title) ?[*:0]const u8 { +/// already exists. Returns the notice to show (problems in red), or null when shortcuts +/// are unavailable. +fn togglePortShortcut(ui: *Ui, title: *const library.Title) ?library.Notice { const launcher = ui.port_launcher orelse return null; const product_id = library.productId(title); var name_buffer: [port_shortcut.name_capacity]u8 = undefined; const name = port_shortcut.portName(&name_buffer, library.titleName(title)); - if (name.len == 0 or !persistent.validProductId(product_id)) return "THIS GAME CANNOT BE ADDED TO PORTS"; - const ports_path = std.fs.path.dirname(launcher) orelse return "PORTS FOLDER NOT FOUND"; - var ports = std.fs.openDirAbsolute(ports_path, .{}) catch return "PORTS FOLDER NOT FOUND"; + if (name.len == 0 or !persistent.validProductId(product_id)) return library.Notice.problem("THIS GAME CANNOT BE ADDED TO PORTS"); + const ports_path = std.fs.path.dirname(launcher) orelse return library.Notice.problem("PORTS FOLDER NOT FOUND"); + var ports = std.fs.openDirAbsolute(ports_path, .{}) catch return library.Notice.problem("PORTS FOLDER NOT FOUND"); defer ports.close(); var artwork_dir: ?std.fs.Dir = if (ui.port_artwork_dir) |path| std.fs.openDirAbsolute(path, .{}) catch |err| blk: { @@ -443,12 +444,12 @@ fn togglePortShortcut(ui: *Ui, title: *const library.Title) ?[*:0]const u8 { .shortcut => { port_shortcut.remove(ports, artwork_dir, name, product_id) catch |err| { std.debug.print("Port shortcut could not be removed: {s}\n", .{@errorName(err)}); - return "SHORTCUT COULD NOT BE REMOVED"; + return library.Notice.problem("SHORTCUT COULD NOT BE REMOVED"); }; std.debug.print("Removed port shortcut: {s}\n", .{name}); - return "REMOVED FROM PORTS"; + return .{ .text = "REMOVED FROM PORTS" }; }, - .foreign => return "A PORT WITH THIS NAME ALREADY EXISTS", + .foreign => return library.Notice.problem("A PORT WITH THIS NAME ALREADY EXISTS"), .absent => {}, } @@ -463,15 +464,16 @@ fn togglePortShortcut(ui: *Ui, title: *const library.Title) ?[*:0]const u8 { .product_id = product_id, }, name, cover) catch |err| { std.debug.print("Port shortcut could not be saved: {s}\n", .{@errorName(err)}); - return "SHORTCUT COULD NOT BE SAVED"; + return library.Notice.problem("SHORTCUT COULD NOT BE SAVED"); }; std.debug.print("Added port shortcut: {s}{s}\n", .{ name, if (cover == null) " (no artwork)" else "" }); - return "ADDED TO PORTS"; + return .{ .text = "ADDED TO PORTS" }; } fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_int { if (titles.len == 0) return c.GO_HANDHELD_UI_PICK_CANCELLED; ui.cancelled = false; + var shortcut_title_missing = false; if (ui.autostart) { ui.autostart = false; if (library.findTitle(titles, requested)) |title_index| { @@ -479,6 +481,7 @@ fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_in return @intCast(title_index); } std.debug.print("Shortcut title is not in the library\n", .{}); + shortcut_title_missing = true; } const indices = std.heap.c_allocator.alloc(usize, titles.len) catch return c.GO_HANDHELD_UI_PICK_CANCELLED; defer std.heap.c_allocator.free(indices); @@ -492,8 +495,12 @@ fn pickTitle(ui: *Ui, titles: []const library.Title, requested: []const u8) c_in var artwork_selection = ArtworkSelection{}; var artwork_texture: ?*anyopaque = null; var select_armed = false; - var notice: ?[*:0]const u8 = null; + var notice: ?library.Notice = null; var notice_until: c.Uint32 = 0; + if (shortcut_title_missing) { + notice = library.Notice.problem("SHORTCUT GAME NOT FOUND"); + notice_until = c.SDL_GetTicks() +% 2500; + } var dirty = true; while (true) { diff --git a/src/ui/library_view.zig b/src/ui/library_view.zig index e830fb3..cfbe725 100644 --- a/src/ui/library_view.zig +++ b/src/ui/library_view.zig @@ -15,6 +15,19 @@ pub const query_capacity = 32; pub const Title = c.GoCatalogTitle; +/// A short message shown in place of the secondary prompt row. Problems draw in red so the +/// player notices them. +pub const Notice = struct { + text: [*:0]const u8, + kind: Kind = .info, + + pub const Kind = enum { info, problem }; + + pub fn problem(text: [*:0]const u8) Notice { + return .{ .text = text, .kind = .problem }; + } +}; + pub const Collection = enum { all, favorites, @@ -144,7 +157,7 @@ pub fn draw( artwork_pointer: ?*anyopaque, provider: provider_badge.Provider, shortcuts: bool, - notice: ?[*:0]const u8, + notice: ?Notice, ) void { const renderer: *c.SDL_Renderer = @ptrCast(@alignCast(renderer_pointer)); const artwork: ?*c.SDL_Texture = if (artwork_pointer) |value| @ptrCast(@alignCast(value)) else null; @@ -227,7 +240,7 @@ pub fn draw( }; controls.drawRow(renderer, 16, 431, &primary, style.bright()); if (notice) |message| - font.text(renderer, 16, 459, 2, message, style.bright()) + font.text(renderer, 16, 459, 2, message.text, if (message.kind == .problem) style.danger() else style.bright()) else controls.drawRow(renderer, 16, 455, secondary[0..if (shortcuts) secondary.len else secondary.len - 1], style.accent()); c.SDL_RenderPresent(renderer); diff --git a/src/ui/view_style.zig b/src/ui/view_style.zig index ff381f6..acbbf4e 100644 --- a/src/ui/view_style.zig +++ b/src/ui/view_style.zig @@ -40,6 +40,11 @@ pub fn warning() Color { return .{ .r = 224, .g = 137, .b = 105, .a = 255 }; } +/// For messages the player has to act on, such as a shortcut whose game is gone. +pub fn danger() Color { + return .{ .r = 235, .g = 87, .b = 87, .a = 255 }; +} + pub fn setColor(renderer_pointer: *anyopaque, color: Color) void { const renderer: *c.SDL_Renderer = @ptrCast(@alignCast(renderer_pointer)); _ = c.SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a); From 88234809d8c41659ca043eb10855f79e190d7c4c Mon Sep 17 00:00:00 2001 From: Alexander Clawthorne <55900620+alexander-clawthorne@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:13:18 -0500 Subject: [PATCH 12/13] fix(ui): keep a shortcut's artwork record until its artwork is removed remove() deleted the script, the only record of the artwork it owns, even when deleting that artwork failed, leaving an image nothing could ever remove. Keep the script in that case, and when a configured artwork folder cannot be opened, so a later attempt can clean up. A missing artwork folder still means the system has none, so shortcuts there remain removable. writeScript() also enforces name_capacity and rejects ':' in names, so a name can never imitate the artwork record line. Co-Authored-By: Claude Opus 5.5 --- src/ui/handheld_ui.zig | 4 ++++ src/ui/port_shortcut.zig | 43 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/ui/handheld_ui.zig b/src/ui/handheld_ui.zig index 19fceec..ce6e9b0 100644 --- a/src/ui/handheld_ui.zig +++ b/src/ui/handheld_ui.zig @@ -442,6 +442,10 @@ fn togglePortShortcut(ui: *Ui, title: *const library.Title) ?library.Notice { const script_name = std.fmt.bufPrint(&script_buffer, "{s}.sh", .{name}) catch unreachable; switch (port_shortcut.state(ports, script_name, product_id)) { .shortcut => { + // remove() treats a missing folder as "no artwork on this system". A configured + // folder that cannot be opened may still hold this shortcut's image, so keep the + // shortcut and its record until it can be removed properly. + if (ui.port_artwork_dir != null and artwork_dir == null) return library.Notice.problem("SHORTCUT COULD NOT BE REMOVED"); port_shortcut.remove(ports, artwork_dir, name, product_id) catch |err| { std.debug.print("Port shortcut could not be removed: {s}\n", .{@errorName(err)}); return library.Notice.problem("SHORTCUT COULD NOT BE REMOVED"); diff --git a/src/ui/port_shortcut.zig b/src/ui/port_shortcut.zig index a1224eb..eab9914 100644 --- a/src/ui/port_shortcut.zig +++ b/src/ui/port_shortcut.zig @@ -91,7 +91,10 @@ pub fn writeScript(writer: anytype, shortcut: Shortcut, name: []const u8, artwor if (!std.fs.path.isAbsolute(shortcut.launcher) or shortcut.launcher.len > max_launcher_length or std.mem.indexOfAny(u8, shortcut.launcher, "'\n\r") != null) return error.InvalidLauncher; if (!settings.validProductId(shortcut.product_id)) return error.InvalidProductId; - if (name.len == 0 or std.mem.indexOfAny(u8, name, "\n\r") != null) return error.InvalidName; + // portName() already guarantees these; checked here too because a ':' could make the + // "# " comment imitate the artwork record line. + if (name.len == 0 or name.len > name_capacity or std.mem.indexOfAny(u8, name, "\n\r:") != null) + return error.InvalidName; try writer.writeAll("#!/bin/bash\n" ++ marker ++ "\n"); try writer.print("# {s}\n", .{name}); try writer.print("export GREENOVERCAST_SERVICE={s}\n", .{shortcut.service.value()}); @@ -287,13 +290,19 @@ pub fn remove(ports: std.fs.Dir, artwork: ?std.fs.Dir, name: []const u8, product // Verifying and deleting are separate steps, as POSIX has no compare-and-delete. That // leaves a short window, but GreenOvercast runs in the foreground on these single-app // handhelds, so nothing else writes the catalogue while a shortcut is being removed. + // If the owned artwork cannot be deleted, the script stays: it is the only record of + // that image, and keeping it lets a later attempt clean up instead of leaving an image + // nobody owns. A null `artwork` means this system has no artwork folder at all. if (artwork) |directory| if (recordedArtwork(existing.content)) |record| { var cover_buffer: [name_capacity + 8]u8 = undefined; const cover_name = try std.fmt.bufPrint(&cover_buffer, "{s}.png", .{name}); if (artworkMatches(directory, cover_name, record)) { directory.deleteFile(cover_name) catch |err| switch (err) { error.FileNotFound => {}, - else => std.debug.print("Shortcut artwork could not be removed: {s}\n", .{@errorName(err)}), + else => { + std.debug.print("Shortcut artwork could not be removed: {s}\n", .{@errorName(err)}); + return err; + }, }; } else if (artworkExists(directory, cover_name)) { std.debug.print("Artwork changed since the shortcut saved it, kept: {s}\n", .{cover_name}); @@ -561,6 +570,36 @@ test "a failed script save removes artwork it just created" { try std.testing.expectEqual(State.absent, state(ports.dir, "Celeste.sh", celeste.product_id)); } +test "the script is kept while its artwork cannot be removed" { + try skipAsRoot(); + var ports = std.testing.tmpDir(.{}); + defer ports.cleanup(); + var artwork = std.testing.tmpDir(.{}); + defer artwork.cleanup(); + try add(ports.dir, artwork.dir, celeste, "Celeste", "shortcut-art"); + + try setMode(artwork.dir, 0o555); + const result = remove(ports.dir, artwork.dir, "Celeste", celeste.product_id); + try setMode(artwork.dir, 0o755); + try std.testing.expectError(error.AccessDenied, result); + // The record survives, so a later attempt still removes the image. + try std.testing.expectEqual(State.shortcut, state(ports.dir, "Celeste.sh", celeste.product_id)); + try expectArtwork(artwork.dir, "shortcut-art"); + + try remove(ports.dir, artwork.dir, "Celeste", celeste.product_id); + try std.testing.expectError(error.FileNotFound, artwork.dir.access("Celeste.png", .{})); + try std.testing.expectEqual(State.absent, state(ports.dir, "Celeste.sh", celeste.product_id)); +} + +test "port names that could imitate the artwork record are rejected" { + var output = std.ArrayList(u8).init(std.testing.allocator); + defer output.deinit(); + const imitation = "GreenOvercast artwork: crc32=00000000 size=1"; + try std.testing.expectError(error.InvalidName, writeScript(output.writer(), celeste, imitation, null)); + try std.testing.expectError(error.InvalidName, writeScript(output.writer(), celeste, "N" ** (name_capacity + 1), null)); + try writeScript(output.writer(), celeste, "N" ** name_capacity, null); +} + test "new artwork never replaces a file created after the existence check" { var artwork = std.testing.tmpDir(.{}); defer artwork.cleanup(); From 80ef3eeee8e0fbe3f2785dbd2e7ce99d779f5c38 Mon Sep 17 00:00:00 2001 From: Alexander Clawthorne <55900620+alexander-clawthorne@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:13:20 -0500 Subject: [PATCH 13/13] test(ui): make shortcut rollback tests portable and conclusive The rollback tests only checked the end state, so they also passed if the artwork was never written. A test-only hook, compiled out of other builds, now records the artwork between its write and the script save, proving each rollback actually undid something. skipAsRoot() uses libc geteuid() off Linux, since host tests also run on macOS. add() documents that it also handles re-adding, which the UI does not do. Co-Authored-By: Claude Opus 5.5 --- src/ui/port_shortcut.zig | 49 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/ui/port_shortcut.zig b/src/ui/port_shortcut.zig index eab9914..57b195c 100644 --- a/src/ui/port_shortcut.zig +++ b/src/ui/port_shortcut.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const settings = @import("persistent_settings.zig"); // Second line of every generated script; only files carrying it are ever replaced or removed. @@ -202,6 +203,13 @@ fn artworkExists(directory: std.fs.Dir, cover_name: []const u8) bool { return true; } +/// Test seam, compiled out of other builds: runs after add() writes artwork and before it +/// saves the script, so tests can see the state a failed save has to roll back. +var after_artwork_write_for_tests: ?*const fn () void = null; + +/// Creates the shortcut, or rewrites it when it already exists for this title. The UI only +/// adds absent shortcuts (Select on an existing one removes it), but re-adding is handled +/// too: an image the shortcut owns is replaced, or kept with its record without a new cover. pub fn add( ports: std.fs.Dir, artwork: ?std.fs.Dir, @@ -260,6 +268,7 @@ pub fn add( std.debug.print("Shortcut artwork could not be saved: {s}\n", .{@errorName(err)}); }, } + if (builtin.is_test) if (after_artwork_write_for_tests) |hook| hook(); // Never record an image that is not on disk: fall back to what the shortcut owned before. if (written != plan) { content.clearRetainingCapacity(); @@ -513,9 +522,37 @@ fn setMode(directory: std.fs.Dir, mode: std.posix.mode_t) !void { try std.posix.fchmodat(std.fs.cwd().fd, path, mode, 0); } +/// Records Celeste.png as add() left it just before saving the script. +const ArtworkObserver = struct { + var directory: std.fs.Dir = undefined; + var buffer: [32]u8 = undefined; + var length: usize = 0; + + fn start(artwork: std.fs.Dir) void { + directory = artwork; + length = 0; + after_artwork_write_for_tests = observe; + } + + fn stop() void { + after_artwork_write_for_tests = null; + } + + fn observe() void { + const data = directory.readFile("Celeste.png", &buffer) catch ""; + length = data.len; + } + + fn seen() []const u8 { + return buffer[0..length]; + } +}; + fn skipAsRoot() !void { - // Root ignores directory permissions, so a read-only Ports folder cannot fail a save. - if (std.os.linux.geteuid() == 0) return error.SkipZigTest; + // Root ignores directory permissions, so a read-only folder cannot fail a write. Host + // tests also run on macOS, where only the libc call is valid. + const uid = if (builtin.os.tag == .linux) std.os.linux.geteuid() else std.c.geteuid(); + if (uid == 0) return error.SkipZigTest; } test "invalid input changes no artwork" { @@ -539,11 +576,15 @@ test "a failed script save restores the artwork the shortcut owned" { defer artwork.cleanup(); try add(ports.dir, artwork.dir, celeste, "Celeste", "shortcut-art-1"); + ArtworkObserver.start(artwork.dir); + defer ArtworkObserver.stop(); try setMode(ports.dir, 0o555); const result = add(ports.dir, artwork.dir, celeste, "Celeste", "shortcut-art-2"); try setMode(ports.dir, 0o755); try std.testing.expectError(error.AccessDenied, result); + // The new image really was written before the save failed, and was then restored. + try std.testing.expectEqualStrings("shortcut-art-2", ArtworkObserver.seen()); // The previous script and its record still describe the artwork on disk. try expectArtwork(artwork.dir, "shortcut-art-1"); var script_buffer: [script_read_capacity]u8 = undefined; @@ -562,10 +603,14 @@ test "a failed script save removes artwork it just created" { var artwork = std.testing.tmpDir(.{}); defer artwork.cleanup(); + ArtworkObserver.start(artwork.dir); + defer ArtworkObserver.stop(); try setMode(ports.dir, 0o555); const result = add(ports.dir, artwork.dir, celeste, "Celeste", "shortcut-art"); try setMode(ports.dir, 0o755); try std.testing.expectError(error.AccessDenied, result); + // The image really was created before the save failed, and was then deleted. + try std.testing.expectEqualStrings("shortcut-art", ArtworkObserver.seen()); try std.testing.expectError(error.FileNotFound, artwork.dir.access("Celeste.png", .{})); try std.testing.expectEqual(State.absent, state(ports.dir, "Celeste.sh", celeste.product_id)); }