oriel

Clipboard

Read and write the system clipboard from Zig: text and PNG images, on every platform, from any thread.

oriel.clipboard reads and writes the system clipboard: plain text and images (always as PNG bytes). It is the clipboard of the whole system, not the page’s: what it writes, every other app can paste, and it reads what other apps copied, including while your window is in the background where the platform allows it.

Use it when Zig needs the clipboard: a hotkey that rewrites the selected text, a tray item that copies a value, a “copy” button whose content comes from Zig. To put text into another app, write it here and send the paste keystroke with Input. The API is Zig-only: the page reaches it through one of your commands.

Availability

PlatformBackendTextImagesBackground reads
Linux (Wayland)ext-data-control-v1 on workers, GdkClipboard on the main thread✓PNG✓ where the compositor offers ext-data-control-v1; else through GTK
Linux (X11)GdkClipboard✓PNG (any format GDK decodes)✓
WindowsWin32 OpenClipboard, CF_UNICODETEXT, registered PNG and CF_DIB/CF_DIBV5✓PNG; DIBs converted✓
macOSNSPasteboard (general pasteboard)✓PNG; TIFF converted✓
AndroidClipboardManager✓first image Uri of the clip, re-encoded as PNG✗: Android 10+ returns "" to apps not in focus
iOSUIPasteboard (general pasteboard)✓PNG; JPEG, HEIC… converted✓, with iOS’s “pasted from” banner

The module is on by default everywhere (clipboard in b.dependency("oriel", .{ ... }); .clipboard = false leaves it out). oriel.clipboard.available is true whenever it’s built, and window.oriel.features.clipboard tells the page. It needs no permission on any platform; iOS 16+ may ask the user when the app reads what another app copied.

Quick start

// build.zig: on by default; shown here to be explicit
const dep = b.dependency("oriel", .{ .target = target, .optimize = optimize, .clipboard = true });
// src/main.zig
const std = @import("std");
const oriel = @import("oriel");
const app = @import("oriel_app");

pub const Commands = struct {
    /// Reads may wait for another app: run this command on a worker.
    pub const async_commands = .{"paste"};

    pub fn copy(_: std.mem.Allocator, args: struct { text: []const u8 }) !void {
        try oriel.clipboard.writeText(args.text);
    }

    pub fn paste(gpa: std.mem.Allocator) ![]const u8 {
        return oriel.clipboard.readText(gpa);
    }
};

pub const Events = struct {};

pub fn main(init: std.process.Init) !u8 {
    return oriel.main(init, .{ .commands = Commands, .events = Events }, .{
        .app = app.build,
        .window = .{ .title = "Clipboard", .width = 600, .height = 400 },
    });
}
await window.oriel.invoke("copy", { text: "Hello from the page" });
const text = await window.oriel.invoke("paste", {});

How it works

flowchart LR
  Page -->|"invoke('paste')"| Worker["Worker thread<br/>(async command, App.spawn)"]
  Worker -->|"readText (blocks)"| API["oriel.clipboard"]
  API -->|"Linux Wayland:<br/>ext-data-control<br/>(own connection)"| Compositor
  API -->|"everything else:<br/>marshal and wait"| Main["Main thread"]
  Main -->|"GdkClipboard / OpenClipboard /<br/>NSPasteboard / ClipboardManager /<br/>UIPasteboard"| OS["System clipboard"]
  Main -.->|"readTextAsync callback"| Zig["Main-thread code<br/>(hotkey, tray)"]

Every platform’s clipboard belongs to the UI thread, so each backend runs the actual clipboard call there:

  • Windows, macOS, Android, iOS (src/plugins/clipboard/windows.zig, macos.zig, android.zig, ios.zig): a call made on a worker is marshalled to the main thread with Shell.runOnMainThread and the worker waits for it; a call made on the main thread runs there directly. The async reads are queued to the main thread with Shell.dispatchWithCleanup and call back there; if the app stops before the queued read runs, the callback gets error.AppNotRunning.
  • Linux (src/plugins/clipboard/linux.zig): GTK can’t serve its own selection while the main thread is blocked waiting for it, so a blocking read is never allowed on the main thread (error.WouldBlockMainThread). On a worker, under Wayland, the read goes through the ext-data-control-v1 protocol on a private Wayland connection (src/plugins/wayland_globals.zig): it works without window focus. Without that protocol (X11, or a compositor that lacks it) the worker hands the read to the main loop’s GdkClipboard and waits up to 5 s. Writes from a worker are handed to the main loop the same way.
  • Your own data. Every Linux write also offers a per-process marker type (application/x-oriel-owner-<pid>) and keeps a copy; a read that sees the marker returns that copy without a round trip through the compositor.

Images are PNG on the Zig side everywhere. Windows reads the registered PNG format first (trimmed past its IEND chunk, since GlobalSize pads it), else converts CF_DIBV5/CF_DIB (24 or 32 bpp, top-down or bottom-up, bitfields; at most 32768 px a side) to PNG; writes put both a DIB and the PNG. macOS writes PNG and TIFF (what most Mac apps paste) and converts TIFF on read. iOS converts any image UIKit reads. Android reads the clip’s first image/* Uri and re-encodes it, and writes the PNG to the runtime’s FileProvider and puts its content:// URI on the clip.

Examples

Copy from a global hotkey

A global shortcut callback runs on the main thread. Writing there is fine; reading belongs on a worker:

fn onHotkey(_: []const u8) void {
    oriel.App.spawn(rewriteSelection, .{}) catch |err| std.log.err("spawn: {s}", .{@errorName(err)});
}

fn rewriteSelection() !void {
    const gpa = std.heap.smp_allocator;
    const text = try oriel.clipboard.readText(gpa); // worker: may block
    defer gpa.free(text);
    const upper = try std.ascii.allocUpperString(gpa, text);
    defer gpa.free(upper);
    try oriel.clipboard.writeText(upper);
}

Read without blocking the main thread

fn onTrayItem(_: []const u8, _: ?bool) void {
    oriel.clipboard.readTextAsync(onText, null);
}

fn onText(result: anyerror![]const u8, _: ?*anyopaque) void {
    const text = result catch |err| return std.log.warn("clipboard: {s}", .{@errorName(err)});
    // `text` is valid only during this call: copy it to keep it.
    std.log.info("clipboard has {d} bytes", .{text.len});
}

Save and restore what the user had

GhostPen puts its result on the clipboard to paste it, then puts back what was there:

const Saved = union(enum) { none, text: []u8, image: []u8 };

fn save(gpa: std.mem.Allocator) Saved {
    const image = oriel.clipboard.readImage(gpa) catch null;
    if (image) |png| return .{ .image = png };
    const text = oriel.clipboard.readText(gpa) catch return .none;
    return .{ .text = text };
}

fn restore(saved: Saved) void {
    switch (saved) {
        .none => {},
        .text => |t| oriel.clipboard.writeText(t) catch {},
        .image => |png| oriel.clipboard.writeImage(png) catch {},
    }
}

examples/showcase (System tab: copy, paste, dictate anywhere) and examples/smoke (a write/read round trip) use the module too.

Reference

Functions

pub const available: bool; // true when the module is built

pub fn readText(gpa: std.mem.Allocator) ![]u8;
pub fn readImage(gpa: std.mem.Allocator) !?[]u8;
pub fn writeText(text: []const u8) !void;
pub fn writeImage(png_bytes: []const u8) !void;

pub const TextCallback = *const fn (result: anyerror![]const u8, user_data: ?*anyopaque) void;
pub const ImageCallback = *const fn (result: anyerror!?[]const u8, user_data: ?*anyopaque) void;
pub fn readTextAsync(callback: TextCallback, user_data: ?*anyopaque) void;
pub fn readImageAsync(callback: ImageCallback, user_data: ?*anyopaque) void;

pub fn check(gpa: std.mem.Allocator, ctx: oriel.CheckContext) !oriel.Check;
FunctionReturnsThreads
readTextThe clipboard’s text, owned by the caller (gpa). "" when it holds no text (or, on Android, when the app isn’t focused).Any on Windows, macOS, Android and iOS. Linux: workers only; the main thread gets error.WouldBlockMainThread.
readImagePNG bytes owned by the caller, or null when there is no image.As readText.
writeTextAny thread. From a worker it waits for the main thread.
writeImageAny thread. The input must be an image the platform decodes: PNG on Android (checked by signature) and Windows (decoded with zigimg); anything GDK, AppKit or UIKit reads on Linux, macOS and iOS. Otherwise error.InvalidImage (Linux: error.TextureNew).
readTextAsyncCalls callback on the main thread with the text, valid only during the call.Linux: call it on the main thread. Elsewhere any thread.
readImageAsyncCalls callback with PNG bytes or null, valid only during the call.As readTextAsync.
checkThe module’s smoke check (oriel.checkAll): which backend is usable. macOS runs a text and PNG round trip on a private pasteboard; Android replaces the user’s clipboard with a test string.

The clipboard holds one thing at a time: writing text drops an image and the other way round. Text is UTF-8 on the Zig side (UTF-16 on Windows, converted).

Android focus. Android 10 and later only give the clipboard to the app in focus. A read on a worker waits up to 1.5 s for one of the app’s windows to get focus (so a read made as a window opens sees the clipboard); a read on the UI thread can’t wait and reads at once.

Limits. A Wayland background read gives up when the owner sends nothing for 2 s (error.ClipboardTimeout) or more than 256 MiB (error.ClipboardTooLarge); a worker waiting for the Linux main loop gives up after 5 s (error.MainThreadTimeout). Windows retries OpenClipboard 10 times, 10 ms apart, while another app holds it.

Errors

ErrorWhereMeaning
error.WouldBlockMainThreadLinuxreadText / readImage on the main thread: use a worker or the async reads
error.MainThreadTimeoutLinuxa worker waited 5 s for the main loop (it isn’t running, or another app didn’t answer)
error.ClipboardTimeout, error.ClipboardTooLargeLinux (Wayland)the selection owner was too slow, or sent too much
error.NoDisplayLinuxno GDK display (GTK not initialized)
error.ClipboardReadFailed, error.ClipboardSetFailed, error.TextureNewLinuxGDK refused the read or write, or couldn’t decode the image (logged)
error.AppNotRunningWindows, macOS, Android, iOScalled before the app’s main loop started, or after it stopped (call from setup or later)
error.ClipboardOpenFailedWindowsanother app kept the clipboard open
error.ClipboardWriteFailedmacOS, Androidthe pasteboard or ClipboardManager refused the write
error.InvalidImageWindows, macOS, Android, iOSwriteImage got bytes that aren’t a readable image, or a read image couldn’t be converted
error.InvalidUtf8macOS, iOSwriteText got invalid UTF-8

Migrating from 0.11

  • oriel.clipboard.common and impl are no longer public.

Troubleshooting

  • error.WouldBlockMainThread on Linux. The read ran on the main thread: a sync command, a hotkey or tray callback, setup. List the command in async_commands, or oriel.App.spawn the work, or use readTextAsync.
  • Reads return stale or empty text on Wayland while the window is in the background. The compositor doesn’t offer ext-data-control-v1; the smoke check (oriel.checkAll) then reports GdkClipboard on the app's display (Wayland data-control: none). The older zwlr_data_control_manager_v1 is detected but not used.
  • Android reads return "". The app has no focused window (Android 10+). Read in response to the user, while the window is in front.
  • iOS shows “pasted from” every time. That is iOS’s behaviour for any read of another app’s content; read only when the user asks.

Related

  • Input: send Ctrl+V (⌘V) after writing, to paste into the focused app.
  • Global shortcuts: the usual trigger for clipboard work.
  • Commands: exposing a Zig function to the page, and async_commands.

Search documentation

Type to search the documentation.