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..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} @@ -94,6 +98,21 @@ 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. +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 +# 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..bb68a94 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,57 @@ 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. + /// + /// 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; + const path = cachePath(self, product_id, &path_buffer) orelse 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| { + if (err != error.FileNotFound) + 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) { + 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); + 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 |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 |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 { if (!settings.validProductId(product_id) or url.len == 0 or url.len >= self.request_url.len) return; self.mutex.lock(); @@ -245,6 +297,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/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..ce6e9b0 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,87 @@ 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 (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 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: { + std.debug.print("Port artwork folder unavailable: {s}\n", .{@errorName(err)}); + break :blk 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, 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"); + }; + std.debug.print("Removed port shortcut: {s}\n", .{name}); + return .{ .text = "REMOVED FROM PORTS" }; + }, + .foreign => return library.Notice.problem("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 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 .{ .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| { + std.debug.print("Starting shortcut title\n", .{}); + 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); var view = library.View{ .titles = titles, .indices = indices }; @@ -416,6 +498,13 @@ 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: ?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) { @@ -442,6 +531,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; @@ -472,7 +564,11 @@ 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 => { + select_armed = false; if (c.go_controller_input_button_pressed(ui.controller, c.SDL_CONTROLLER_BUTTON_BACK) != 0) continue; const result = settings_view.run( @@ -502,6 +598,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 +652,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 +699,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 = 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, diff --git a/src/ui/library_view.zig b/src/ui/library_view.zig index a1be432..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, @@ -110,12 +123,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 +156,8 @@ pub fn draw( store: *const settings.Store, artwork_pointer: ?*anyopaque, provider: provider_badge.Provider, + shortcuts: bool, + 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; @@ -216,9 +236,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.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/port_shortcut.zig b/src/ui/port_shortcut.zig new file mode 100644 index 0000000..57b195c --- /dev/null +++ b/src/ui/port_shortcut.zig @@ -0,0 +1,698 @@ +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. +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); +} + +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, +}; + +/// 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. +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, 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; + // 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()}); + 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 }); +} + +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 => .{ .state = .absent }, + else => .{ .state = .foreign }, + }; + defer file.close(); + 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 .{ .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 .{ .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; +} + +/// 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 null; + const current = ArtworkRecord.of(data); + 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 { + directory.access(cover_name, .{}) catch |err| return err != error.FileNotFound; + 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, + 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}); + 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; + + var cover_buffer: [name_capacity + 8]u8 = undefined; + const cover_name = try std.fmt.bufPrint(&cover_buffer, "{s}.png", .{name}); + + // 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; + } + }; + + 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(); + 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)}); + }, + } + 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(); + try writeScript(content.writer(), shortcut, name, previous); + } + + writeAtomic(ports, script_name, content.items, 0o755) catch |err| { + // 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; + }; +} + +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}); + 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. 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 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)}); + return 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); +} + +fn writeAtomic(directory: std.fs.Dir, name: []const u8, data: []const u8, mode: std.fs.File.Mode) !void { + // 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.file.chmod(mode); + try file.file.sync(); + try file.finish(); +} + +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", null); + 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 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", null)); + shortcut = base; + shortcut.launcher = "ports/GreenOvercast.sh"; + 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", null)); +} + +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", + }; + + 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", 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", 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", product); + try std.testing.expectEqual(State.absent, state(ports.dir, "Halo.sh", product)); + 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"); +} + +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); +} + +/// 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 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" { + 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"); + + 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; + 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(); + + 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)); +} + +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(); + // 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(); + 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)); +} + +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 }; + + // 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, 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", null)); +} 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);