macos: implement +new-window IPC via Unix domain socket

Implements `ghostty +new-window` for macOS, mirroring the existing GTK
implementation which uses D-Bus. The CLI command, IPC abstraction, and
argument parsing (`--title`, `--working-directory`, `-e`) already existed;
this adds the macOS transport.

The running Ghostty.app listens on a per-user Unix domain socket at
`<confstr(_CS_DARWIN_USER_TEMP_DIR)>/ghostty-ipc-<bundle-id>.sock`.
The CLI process (embedded apprt) connects, sends a length-prefixed frame
of the CLI arguments, and exits. The app parses the arguments and opens
a new window via the existing `TerminalController.newWindow` path.

The `--title` flag is wired through to `ghostty_surface_config_s.title`
(new field), which causes the core to ignore subsequent OSC 0/2 title
change requests from the running program — matching the behavior of the
`title` config key.

Both ends resolve the socket path via `confstr` rather than `$TMPDIR` so
they agree regardless of environment.
pull/12903/head
Andy Tung 2026-06-02 09:25:58 -07:00
parent c4eba3da38
commit 6b30f61fed
7 changed files with 573 additions and 26 deletions

View File

@ -472,6 +472,7 @@ typedef struct {
float font_size;
const char* working_directory;
const char* command;
const char* title;
ghostty_env_var_s* env_vars;
size_t env_var_count;
const char* initial_input;

View File

@ -153,6 +153,9 @@ class AppDelegate: NSObject,
private let appIconUpdater = AppIconUpdater()
/// Listens for IPC requests (e.g. `ghostty +new-window`) from CLI processes.
private var ipcServer: IPCServer?
@MainActor private lazy var menuShortcutManager = Ghostty.MenuShortcutManager()
override init() {
@ -218,6 +221,13 @@ class AppDelegate: NSObject,
// Register our service provider. This must happen after everything is initialized.
NSApp.servicesProvider = ServiceProvider()
// Start listening for IPC requests (e.g. `ghostty +new-window`) so that
// CLI invocations open windows in this running instance.
ipcServer = IPCServer { [weak self] config in
guard let self else { return }
_ = TerminalController.newWindow(self.ghostty, withBaseConfig: config)
}
// This registers the Ghostty => Services menu to exist.
NSApp.servicesMenu = menuServices

View File

@ -0,0 +1,202 @@
import Foundation
import Darwin
/// Listens on a per-user Unix domain socket for requests from `ghostty` CLI
/// processes (e.g. `ghostty +new-window`) and dispatches them to the running
/// app. The sender lives in the Zig core (`apprt/embedded.zig`, `performIpc`).
///
/// The two ends agree on the socket path and wire format without any shared
/// state:
/// - Path: `<per-user temp dir>/ghostty-ipc-<bundle id>.sock`, where the temp
/// dir is resolved via `confstr(_CS_DARWIN_USER_TEMP_DIR)` on both sides so
/// they match regardless of `$TMPDIR`.
/// - Frame: `[u8 action][u32 argc]` followed by `argc` times `[u32 len][bytes]`,
/// all integers little-endian. The app replies with a single byte: 0 for
/// success, non-zero for failure.
final class IPCServer {
/// Invoked on the main queue when a new-window request arrives.
typealias NewWindowHandler = (Ghostty.SurfaceConfiguration) -> Void
/// Actions, matching `apprt.ipc.Action.Key` on the Zig side.
private enum Action: UInt8 {
case newWindow = 0
case toggleQuickTerminal = 1
}
private let socketPath: String
private let onNewWindow: NewWindowHandler
private var listenFD: Int32 = -1
private var source: DispatchSourceRead?
private let queue = DispatchQueue(label: "com.mitchellh.ghostty.ipc")
init?(onNewWindow: @escaping NewWindowHandler) {
guard let path = IPCServer.socketPath() else { return nil }
self.socketPath = path
self.onNewWindow = onNewWindow
guard listenOnSocket() else { return nil }
}
deinit {
if listenFD >= 0 { close(listenFD) }
unlink(socketPath)
}
private static func socketPath() -> String? {
var buf = [CChar](repeating: 0, count: Int(PATH_MAX))
let n = confstr(_CS_DARWIN_USER_TEMP_DIR, &buf, buf.count)
guard n > 0, n <= buf.count else { return nil }
let dir = String(cString: buf) // already ends in a path separator
let bundleID = Bundle.main.bundleIdentifier ?? "com.mitchellh.ghostty"
return "\(dir)ghostty-ipc-\(bundleID).sock"
}
private func listenOnSocket() -> Bool {
// Remove any stale socket left by a previous (crashed) run.
unlink(socketPath)
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return false }
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let pathBytes = Array(socketPath.utf8)
// sun_path is fixed-size and must remain NUL-terminated.
guard pathBytes.count < MemoryLayout.size(ofValue: addr.sun_path) else {
close(fd)
return false
}
withUnsafeMutableBytes(of: &addr.sun_path) { raw in
raw.copyBytes(from: pathBytes)
}
let size = socklen_t(MemoryLayout<sockaddr_un>.size)
let bound = withUnsafePointer(to: &addr) { p in
p.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, size) }
}
guard bound == 0, listen(fd, 8) == 0 else {
close(fd)
return false
}
listenFD = fd
let src = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue)
src.setEventHandler { [weak self] in self?.acceptOne() }
src.resume()
source = src
return true
}
private func acceptOne() {
let client = accept(listenFD, nil, nil)
guard client >= 0 else { return }
defer { close(client) }
guard let raw = readUInt8(client), let action = Action(rawValue: raw) else {
writeAck(client, ok: false)
return
}
switch action {
case .newWindow:
guard let args = readArguments(client) else {
writeAck(client, ok: false)
return
}
let config = IPCServer.surfaceConfiguration(from: args)
writeAck(client, ok: true)
DispatchQueue.main.async { [onNewWindow] in onNewWindow(config) }
case .toggleQuickTerminal:
// Not implemented yet.
writeAck(client, ok: false)
}
}
/// Parse CLI-style arguments into a surface configuration, mirroring the
/// GTK receiver (`apprt/gtk/class/application.zig`, `actionNewWindow`).
static func surfaceConfiguration(from args: [String]) -> Ghostty.SurfaceConfiguration {
var config = Ghostty.SurfaceConfiguration()
var directCommand: [String] = []
var eSeen = false
for arg in args {
if eSeen {
directCommand.append(arg)
} else if arg == "-e" {
eSeen = true
} else if let v = arg.ipcStripPrefix("--command=") {
config.command = v
} else if let v = arg.ipcStripPrefix("--working-directory=") {
config.workingDirectory = v.trimmingCharacters(in: .whitespaces)
} else if let v = arg.ipcStripPrefix("--title=") {
config.title = v.trimmingCharacters(in: .whitespaces)
}
}
// `-e` is a direct command (argv). libghostty's surface command always
// runs via `/bin/sh -c`, so shell-quote each argument to preserve word
// boundaries (e.g. arguments that themselves contain spaces).
if !directCommand.isEmpty {
config.command = directCommand.map { Ghostty.Shell.quote($0) }.joined(separator: " ")
}
return config
}
// MARK: - Socket reading helpers
private func readUInt8(_ fd: Int32) -> UInt8? {
var v: UInt8 = 0
let ok = withUnsafeMutableBytes(of: &v) { readFull(fd, into: $0) }
return ok ? v : nil
}
private func readUInt32(_ fd: Int32) -> UInt32? {
var v: UInt32 = 0
let ok = withUnsafeMutableBytes(of: &v) { readFull(fd, into: $0) }
return ok ? UInt32(littleEndian: v) : nil
}
private func readArguments(_ fd: Int32) -> [String]? {
guard let count = readUInt32(fd) else { return nil }
var result: [String] = []
result.reserveCapacity(Int(count))
for _ in 0..<count {
guard let len = readUInt32(fd) else { return nil }
if len == 0 {
result.append("")
continue
}
var buf = [UInt8](repeating: 0, count: Int(len))
let ok = buf.withUnsafeMutableBytes { readFull(fd, into: $0) }
guard ok, let s = String(bytes: buf, encoding: .utf8) else { return nil }
result.append(s)
}
return result
}
/// Read exactly `buffer.count` bytes, looping over short reads.
private func readFull(_ fd: Int32, into buffer: UnsafeMutableRawBufferPointer) -> Bool {
guard let base = buffer.baseAddress else { return true }
var total = 0
while total < buffer.count {
let n = read(fd, base.advanced(by: total), buffer.count - total)
if n <= 0 { return false }
total += n
}
return true
}
private func writeAck(_ fd: Int32, ok: Bool) {
var b: UInt8 = ok ? 0 : 1
_ = withUnsafeBytes(of: &b) { write(fd, $0.baseAddress, 1) }
}
}
private extension String {
/// If the string starts with `prefix`, return the remainder, else nil.
func ipcStripPrefix(_ prefix: String) -> String? {
guard hasPrefix(prefix) else { return nil }
return String(dropFirst(prefix.count))
}
}

View File

@ -641,6 +641,10 @@ extension Ghostty {
/// Explicit command to set
var command: String?
/// Forced title. When set, the surface ignores title change requests
/// from the running program (e.g. OSC 0/2 escape sequences).
var title: String?
/// Environment variables to set for the terminal
var environmentVariables: [String: String] = [:]
@ -663,6 +667,9 @@ extension Ghostty {
if let command = config.command {
self.command = String.init(cString: command, encoding: .utf8)
}
if let title = config.title {
self.title = String.init(cString: title, encoding: .utf8)
}
// Convert the C env vars to Swift dictionary
if config.env_var_count > 0, let envVars = config.env_vars {
@ -718,30 +725,34 @@ extension Ghostty {
return try command.withCString { cCommand in
config.command = cCommand
return try initialInput.withCString { cInput in
config.initial_input = cInput
return try title.withCString { cTitle in
config.title = cTitle
// Convert dictionary to arrays for easier processing
let keys = Array(environmentVariables.keys)
let values = Array(environmentVariables.values)
return try initialInput.withCString { cInput in
config.initial_input = cInput
// Create C strings for all keys and values
return try keys.withCStrings { keyCStrings in
return try values.withCStrings { valueCStrings in
// Create array of ghostty_env_var_s
var envVars = [ghostty_env_var_s]()
envVars.reserveCapacity(environmentVariables.count)
for i in 0..<environmentVariables.count {
envVars.append(ghostty_env_var_s(
key: keyCStrings[i],
value: valueCStrings[i]
))
}
// Convert dictionary to arrays for easier processing
let keys = Array(environmentVariables.keys)
let values = Array(environmentVariables.values)
return try envVars.withUnsafeMutableBufferPointer { buffer in
config.env_vars = buffer.baseAddress
config.env_var_count = environmentVariables.count
return try body(&config)
// Create C strings for all keys and values
return try keys.withCStrings { keyCStrings in
return try values.withCStrings { valueCStrings in
// Create array of ghostty_env_var_s
var envVars = [ghostty_env_var_s]()
envVars.reserveCapacity(environmentVariables.count)
for i in 0..<environmentVariables.count {
envVars.append(ghostty_env_var_s(
key: keyCStrings[i],
value: valueCStrings[i]
))
}
return try envVars.withUnsafeMutableBufferPointer { buffer in
config.env_vars = buffer.baseAddress
config.env_var_count = environmentVariables.count
return try body(&config)
}
}
}
}

View File

@ -0,0 +1,150 @@
@testable import Ghostty
import Testing
struct IPCServerTests {
// MARK: - title
@Test func titleFlag() {
let config = IPCServer.surfaceConfiguration(from: ["--title=My Window"])
#expect(config.title == "My Window")
}
@Test func titleFlagWithSpaces() {
let config = IPCServer.surfaceConfiguration(from: ["--title=REMOTE: nutty-strawberry"])
#expect(config.title == "REMOTE: nutty-strawberry")
}
@Test func titleFlagTrimsWhitespace() {
let config = IPCServer.surfaceConfiguration(from: ["--title= padded "])
#expect(config.title == "padded")
}
@Test func titleFlagEmpty() {
let config = IPCServer.surfaceConfiguration(from: ["--title="])
#expect(config.title == "")
}
@Test func noTitleFlag() {
let config = IPCServer.surfaceConfiguration(from: [])
#expect(config.title == nil)
}
// MARK: - working directory
@Test func workingDirectoryFlag() {
let config = IPCServer.surfaceConfiguration(from: ["--working-directory=/tmp/foo"])
#expect(config.workingDirectory == "/tmp/foo")
}
@Test func workingDirectoryFlagTrimsWhitespace() {
let config = IPCServer.surfaceConfiguration(from: ["--working-directory= /tmp/foo "])
#expect(config.workingDirectory == "/tmp/foo")
}
@Test func noWorkingDirectoryFlag() {
let config = IPCServer.surfaceConfiguration(from: [])
#expect(config.workingDirectory == nil)
}
// MARK: - command
@Test func commandFlag() {
let config = IPCServer.surfaceConfiguration(from: ["--command=vim"])
#expect(config.command == "vim")
}
@Test func noCommandFlag() {
let config = IPCServer.surfaceConfiguration(from: [])
#expect(config.command == nil)
}
// MARK: - -e (direct command)
@Test func eFlagSingleArg() {
let config = IPCServer.surfaceConfiguration(from: ["-e", "vim"])
#expect(config.command == "vim")
}
@Test func eFlagMultipleArgs() {
let config = IPCServer.surfaceConfiguration(from: ["-e", "ssh", "myhost"])
#expect(config.command == "ssh myhost")
}
@Test func eFlagArgsWithSpacesAreQuoted() {
let config = IPCServer.surfaceConfiguration(from: ["-e", "ssh", "my host"])
// "my host" contains a space so Shell.quote wraps it in single quotes
#expect(config.command == "ssh 'my host'")
}
@Test func eFlagConsumeAllRemainingArgs() {
let config = IPCServer.surfaceConfiguration(from: ["-e", "dtach", "-A", "/tmp/work.sock", "bash", "-l"])
#expect(config.command == "dtach -A /tmp/work.sock bash -l")
}
@Test func eFlagAfterOtherFlags() {
let config = IPCServer.surfaceConfiguration(from: [
"--title=My Window",
"--working-directory=/tmp",
"-e", "ssh", "myhost",
])
#expect(config.title == "My Window")
#expect(config.workingDirectory == "/tmp")
#expect(config.command == "ssh myhost")
}
// MARK: - arg order / combinations
@Test func allFlagsTogether() {
let config = IPCServer.surfaceConfiguration(from: [
"--working-directory=/home/user",
"--title=REMOTE: work",
"-e", "ssh", "myhost", "-t", "bash",
])
#expect(config.workingDirectory == "/home/user")
#expect(config.title == "REMOTE: work")
#expect(config.command == "ssh myhost -t bash")
}
@Test func emptyArgs() {
let config = IPCServer.surfaceConfiguration(from: [])
#expect(config.title == nil)
#expect(config.workingDirectory == nil)
#expect(config.command == nil)
}
@Test func unknownFlagsAreIgnored() {
let config = IPCServer.surfaceConfiguration(from: ["--unknown-flag=value"])
#expect(config.title == nil)
#expect(config.workingDirectory == nil)
#expect(config.command == nil)
}
@Test func eFlagWithNoArgs() {
// -e with nothing after it should produce no command
let config = IPCServer.surfaceConfiguration(from: ["-e"])
#expect(config.command == nil)
}
// MARK: - rssh real-world cases
@Test func rsshNuttyCase() {
let config = IPCServer.surfaceConfiguration(from: [
"--working-directory=/Users/andtung/mp",
"--title=REMOTE: nutty-strawberry",
"-e", "rdev", "ssh", "traffic-envoy/nutty-strawberry", "--non-tmux",
])
#expect(config.title == "REMOTE: nutty-strawberry")
#expect(config.command == "rdev ssh traffic-envoy/nutty-strawberry --non-tmux")
}
@Test func rsshWorkCase() {
let config = IPCServer.surfaceConfiguration(from: [
"--working-directory=/Users/andtung/mp",
"--title=REMOTE: ld1 work",
"-e", "ssh", "andtung-ld1.linkedin.biz", "-t", "dtach -A /tmp/work.sock bash -l",
])
#expect(config.title == "REMOTE: ld1 work")
#expect(config.command != nil)
#expect(config.command!.hasPrefix("ssh andtung-ld1.linkedin.biz"))
}
}

View File

@ -21,6 +21,7 @@ const CoreSurface = @import("../Surface.zig");
const configpkg = @import("../config.zig");
const Config = configpkg.Config;
const String = @import("../main_c.zig").String;
const ipc_new_window = @import("embedded/ipc/new_window.zig");
const log = std.log.scoped(.embedded_window);
@ -329,13 +330,17 @@ pub const App = struct {
/// some other process that is not Ghostty) there is no full-featured apprt App
/// to use.
pub fn performIpc(
_: Allocator,
_: apprt.ipc.Target,
alloc: Allocator,
target: apprt.ipc.Target,
comptime action: apprt.ipc.Action.Key,
_: apprt.ipc.Action.Value(action),
) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool {
value: apprt.ipc.Action.Value(action),
) !bool {
// IPC is only implemented for macOS. Other embedded targets (e.g. iOS)
// have no notion of a separate CLI process talking to a running app.
if (comptime builtin.target.os.tag != .macos) return false;
switch (action) {
.new_window => return false,
.new_window => return ipc_new_window.newWindow(alloc, target, value),
.toggle_quick_terminal => return false,
}
}
@ -450,6 +455,12 @@ pub const Surface = struct {
/// future once we have a concrete use case.
command: ?[*:0]const u8 = null,
/// A title to force for the surface. If this is set then the surface
/// will use this title and ignore any title change requests from the
/// running program (e.g. OSC 0/2 escape sequences). This mirrors the
/// `title` configuration option but on a per-surface basis.
title: ?[*:0]const u8 = null,
/// Extra environment variables to set for the surface.
env_vars: ?[*]EnvVar = null,
env_var_count: usize = 0,
@ -536,6 +547,16 @@ pub const Surface = struct {
}
}
// If we have a title from the options then we force it. Setting the
// title via config causes Ghostty to ignore title change requests
// from the running program (see Surface.handleMessage).
if (opts.title) |c_title| {
const t = std.mem.sliceTo(c_title, 0);
if (t.len > 0) {
config.title = try config.arenaAlloc().dupeZ(u8, t);
}
}
// Apply any environment variables that were requested.
if (opts.env_var_count > 0) {
const alloc = config.arenaAlloc();

View File

@ -0,0 +1,152 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const apprt = @import("../../../apprt.zig");
const build_config = @import("../../../build_config.zig");
// Use a Unix domain socket to open a new window in a running Ghostty instance.
//
// `ghostty +new-window` is equivalent to connecting to the socket and sending
// a frame with action=new_window and no arguments.
//
// `ghostty +new-window -e echo hello` sends the same frame with the arguments
// ["--working-directory=<cwd>", "-e", "echo", "hello"].
pub fn newWindow(
alloc: Allocator,
target: apprt.ipc.Target,
value: apprt.ipc.Action.NewWindow,
) (Allocator.Error || std.Io.Writer.Error || apprt.ipc.Errors)!bool {
var buf: [256]u8 = undefined;
var stderr_writer = std.fs.File.stderr().writer(&buf);
const stderr = &stderr_writer.interface;
var path_buf: [std.posix.PATH_MAX]u8 = undefined;
const path = socketPath(&path_buf, target) catch {
try stderr.print("ghostty: failed to determine the Ghostty IPC socket path\n", .{});
try stderr.flush();
return error.IPCFailed;
};
const stream = std.net.connectUnixSocket(path) catch |err| {
try stderr.print(
"ghostty: unable to reach a running Ghostty instance ({s}): {s}. Is Ghostty running?\n",
.{ path, @errorName(err) },
);
try stderr.flush();
return error.IPCFailed;
};
defer stream.close();
// Frame: [u8 action][u32 argc] then argc times [u32 len][bytes].
// All integers little-endian.
var frame: std.ArrayList(u8) = .empty;
defer frame.deinit(alloc);
try frame.append(alloc, @intCast(@intFromEnum(apprt.ipc.Action.Key.new_window)));
const arguments = value.arguments orelse &.{};
try appendU32(&frame, alloc, @intCast(arguments.len));
for (arguments) |arg| {
try appendU32(&frame, alloc, @intCast(arg.len));
try frame.appendSlice(alloc, arg);
}
stream.writeAll(frame.items) catch |err| {
try stderr.print("ghostty: failed to send the IPC request: {s}\n", .{@errorName(err)});
try stderr.flush();
return error.IPCFailed;
};
// A non-zero acknowledgement byte means the instance rejected the
// request. A missing ack is not treated as fatal: the request may have
// been handled anyway and there's nothing useful to retry.
var ack: [1]u8 = .{0};
const n = stream.read(&ack) catch 0;
if (n == 1 and ack[0] != 0) {
try stderr.print("ghostty: the running Ghostty instance could not handle the request\n", .{});
try stderr.flush();
return error.IPCFailed;
}
return true;
}
/// Build the socket path. Both this and the Swift listener resolve the
/// per-user temp dir via confstr so they agree without relying on $TMPDIR.
fn socketPath(buf: []u8, target: apprt.ipc.Target) ![]const u8 {
var dir_buf: [std.posix.PATH_MAX]u8 = undefined;
const n = confstr(CS_DARWIN_USER_TEMP_DIR, &dir_buf, dir_buf.len);
const dir: []const u8 = if (n > 0 and n <= dir_buf.len)
std.mem.sliceTo(&dir_buf, 0)
else
"/tmp/";
return socketPathForDir(buf, target, dir);
}
/// Build the socket path given an explicit directory. The directory is
/// expected to end with a path separator. Separated from socketPath so
/// it can be tested without a real confstr call.
fn socketPathForDir(buf: []u8, target: apprt.ipc.Target, dir: []const u8) ![]const u8 {
const instance: []const u8 = switch (target) {
.class => |class| class,
.detect => build_config.bundle_id,
};
// The Darwin temp dir already ends in a path separator.
return std.fmt.bufPrint(buf, "{s}ghostty-ipc-{s}.sock", .{ dir, instance });
}
fn appendU32(frame: *std.ArrayList(u8), alloc: Allocator, v: u32) Allocator.Error!void {
var tmp: [4]u8 = undefined;
std.mem.writeInt(u32, &tmp, v, .little);
try frame.appendSlice(alloc, &tmp);
}
const CS_DARWIN_USER_TEMP_DIR: c_int = 65537;
extern "c" fn confstr(name: c_int, buf: [*]u8, len: usize) usize;
test "socketPath: detect uses bundle id" {
var buf: [std.posix.PATH_MAX]u8 = undefined;
const path = try socketPathForDir(&buf, .detect, "/tmp/");
try std.testing.expectEqualStrings(
"/tmp/ghostty-ipc-" ++ build_config.bundle_id ++ ".sock",
path,
);
}
test "socketPath: class uses provided name" {
var buf: [std.posix.PATH_MAX]u8 = undefined;
const path = try socketPathForDir(&buf, .{ .class = "com.example.ghostty-debug" }, "/tmp/");
try std.testing.expectEqualStrings(
"/tmp/ghostty-ipc-com.example.ghostty-debug.sock",
path,
);
}
test "socketPath: dir already has trailing separator" {
var buf: [std.posix.PATH_MAX]u8 = undefined;
const path = try socketPathForDir(&buf, .detect, "/var/folders/xx/yyy/T/");
try std.testing.expect(std.mem.startsWith(u8, path, "/var/folders/xx/yyy/T/ghostty-ipc-"));
try std.testing.expect(std.mem.endsWith(u8, path, ".sock"));
}
test "appendU32: little-endian encoding" {
var frame: std.ArrayList(u8) = .empty;
defer frame.deinit(std.testing.allocator);
try appendU32(&frame, std.testing.allocator, 0x01020304);
try std.testing.expectEqualSlices(u8, &.{ 0x04, 0x03, 0x02, 0x01 }, frame.items);
}
test "appendU32: zero" {
var frame: std.ArrayList(u8) = .empty;
defer frame.deinit(std.testing.allocator);
try appendU32(&frame, std.testing.allocator, 0);
try std.testing.expectEqualSlices(u8, &.{ 0x00, 0x00, 0x00, 0x00 }, frame.items);
}
test "appendU32: max value" {
var frame: std.ArrayList(u8) = .empty;
defer frame.deinit(std.testing.allocator);
try appendU32(&frame, std.testing.allocator, std.math.maxInt(u32));
try std.testing.expectEqualSlices(u8, &.{ 0xFF, 0xFF, 0xFF, 0xFF }, frame.items);
}