oriel

Files

How the user's files reach an Oriel app and its page: dropped, picked, shared from other apps, or kept in a folder the user chose, and what crosses the bridge for each.

A file reaches an Oriel app in one of four ways: the user drops it on the window, picks it in a file dialog, shares it from another app (“Open with”, the share sheet), or the app writes into a folder the user chose once. This page says, for each, what the app’s Zig code gets, what the page gets, and how long access lasts. The rule behind all of them: the page never receives a path it could use to read other files. What the page holds is either a browser File (bytes the web view already read) or a file handle, a number Oriel maps to a file it opened read-only when the user handed it over.

Each way has its own page for the API; this one ties them together: Drag and drop (drops into the native renderer), Dialogs (pickers and lasting folder access), Share (files from other apps).

Availability

LinuxWindowsmacOSAndroidiOS
Drop on a web view pagethe web view’s own Filessamesamesamesame
Drop on a native renderer page (-Dnative_ui)handleshandleshandleshandles (ParcelFileDescriptor)handles (descriptor)
oriel.drop.path (native renderer)yesyesyeswhen the URI is a filewhile iOS keeps the file
<input type="file"> (web view)WebKitGTK’s pickerWebView2’s pickerWKWebView’s panelOriel’s chooser (system picker, camera with oriel.camera)WKWebView’s menu
oriel.dialog pickers (Zig)pathspathspathscache copy / descriptortemporary copy / folder path
Received shares (handles)noyes (Send To, Open with)yes (Open with)noyes (Open in)
Lasting folder accesspathpathpathSAF tree URIbookmark

Drops and <input type="file"> need nothing turned on. oriel.dialog is on by default (.dialog); shares need .share_target in build.zig (Share); the native renderer is -Dnative_ui (Native renderer). No permission is involved: each way is a choice the user made.

Quick start

A page that accepts dropped files and files shared from other apps, the same code in the web view and the native renderer:

const { listen, share } = window.oriel;

// Drops: standard HTML5, in both renderers.
document.addEventListener("dragover", (e) => e.preventDefault());
document.addEventListener("drop", async (e) => {
  e.preventDefault();
  for (const file of e.dataTransfer.files) show(file.name, await file.text());
});

// Shares (needs .share_target): handles, read as Files on demand.
listen("share:received", async ({ files }) => {
  for (const f of files) {
    const file = await share.file(f); // reads through the handle in 4 MiB chunks
    show(file.name, await file.text());
  }
});

How it works

flowchart LR
  User["The user"] -->|drop| WV["Web view<br/>(WebKitGTK, WebView2,<br/>WKWebView, Android WebView)"]
  User -->|drop| NR["Native renderer backend<br/>(gtk, win32, appkit,<br/>uikit, android)"]
  User -->|"Open with / share"| SH["oriel.share backend"]
  User -->|pick| DLG["oriel.dialog"]
  WV -->|"browser File (bytes)"| Page["Page"]
  NR -->|"open read-only"| T1["Engine.drops<br/>(FileHandles, per window)"]
  SH -->|"open read-only"| T2["share.received<br/>(FileHandles, per process)"]
  T1 -->|"File { handle }"| Page
  T2 -->|"{ handle, name, mime, size }"| Page
  Page -->|"host.fileRead(handle, offset, len)"| T1
  Page -->|"invoke share:read"| T2
  DLG -->|"path"| Zig["Your Zig command"]
  Zig -->|"whatever it returns"| Page

Handles. src/core/file_handles.zig is a table from a u32 handle to an open, read-only descriptor (a file HANDLE on Windows) plus the size and modification time it had when it was added. A backend opens each file the moment the user hands it over: by path where the OS gives one (addPath: desktop drops, macOS and Windows shares) or as the descriptor the OS gave (addFd: Android’s ParcelFileDescriptor for a drop, iOS’s drop). Only regular files are kept; a dropped folder or FIFO is skipped (files are opened non-blocking, so a FIFO can’t hang the UI thread). On Windows the file is opened shared for reading, writing and deleting, as Chromium opens a dropped file, so the user’s other programs keep working with it.

Reads go through the handle and nothing else: there is no call that opens a path the page names. Every read checks the file’s size and mtime again, so a file that changed after it was handed over reads as not readable, as in a browser (snapshot semantics). A read is at most 64 MiB; larger reads go in pieces. Reads use a duplicate of the descriptor taken under the table’s lock, so a long read never stalls other handles, and a release during a read can’t recycle the descriptor.

There are two tables:

  • Drops into the native renderer (src/native_ui/drop.zig, the engine’s drops): one per window, per document. The page gets a File whose bytes stay in the engine; file.text(), arrayBuffer(), FileReader and slice() call host.fileRead(reqId, handle, offset, length), answered on the engine’s next turn. The handle is released when the page holds no File or slice for it any more (a FinalizationRegistry calls host.fileRelease), when the page reloads, and when the window closes. The DOM side (DataTransfer, events, effects) is on Drag and drop.
  • Received shares (src/modules/share/common.zig, received): one per process. The page gets { handle, name, mime, size } in the share:received event and turns a handle into a File with oriel.share.file, which reads it through the share:read command; Zig opens one with oriel.share.open(handle). A share’s handles close when Zig calls oriel.share.release(id).

A held descriptor keeps working after the OS takes back what it lent for the drag: Android’s drag permissions, the macOS sandbox extension, an iOS temporary file that was deleted (the descriptor keeps the inode).

Web view drops are the web view’s business: the page gets ordinary browser File objects and Oriel never sees them. Oriel adds one guard (drop_guard_js in src/core/security.zig, in every web view’s bridge): a file dropped on a text field that the page didn’t handle would be inserted as the file’s absolute path (WebKit’s default); the guard cancels only that default, and the page still gets every drag event.

Picked files go to Zig, as paths, because the Zig command asked: oriel.dialog.openFile returns the file’s path on the desktop, a copy in the cache on Android and in the temporary directory on iOS. The page sees only what the command returns (the text, a name, a size). A web view’s <input type="file"> is the browser’s: the page gets Files and Zig sees nothing; on Android Oriel’s WebChromeClient.onShowFileChooser asks the platform hooks first, then oriel.camera for image inputs, then the system picker (several files when the input has multiple).

Lasting access is a folder id from oriel.dialog.openFolder: a path on the desktop, a SAF tree URI with a persisted grant on Android, a security-scoped bookmark on iOS. It lives in Zig (store it in your settings); see Dialogs.

Examples

Send a dropped file on (native renderer, trusted app)

A transfer app that hands dropped files to its own Zig engine needs the path, not the bytes. oriel.drop.path turns a dropped file’s handle back into where the file lives now:

document.addEventListener("drop", async (e) => {
  e.preventDefault();
  for (const file of e.dataTransfer.files) {
    const path = await window.oriel.drop.path(file); // null: gone, changed, or no path
    if (path) await window.oriel.invoke("send_file", { path, name: file.name });
  }
});

The command is checked like any other (security.capabilities), and answers null when the file was deleted or changed since the drop, the handle is unknown, or the platform can’t resolve it. Linux and Android read the descriptor’s /proc/self/fd link, macOS and iOS ask F_GETPATH, Windows asks GetFinalPathNameByHandleW (so a file renamed after the drop answers its new name). HollerShare (personal/ghostfile) sends dropped files this way.

Read a big shared file in Zig

fn onShare(r: *const oriel.share.Received) void {
    for (r.files) |f| {
        const file = oriel.share.open(f.handle) catch continue; // a duplicate: close it
        defer file.close(io);
        importFile(file, f.name, f.size);
    }
    oriel.share.release(r.id); // done with this share's files
}

Register it with oriel.share.onReceive(onShare) in setup (Share).

Save a picked file’s contents for the page

pub const async_commands = .{"import_csv"};

pub fn import_csv(gpa: std.mem.Allocator, io: std.Io) !?[]const u8 {
    const path = try oriel.dialog.openFile(gpa, .{
        .title = "Import a CSV",
        .filter = .{ .name = "CSV", .extensions = &.{"csv"} },
    }) orelse return null;
    return try std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(8 << 20));
}

Reference

What crosses to the page

SourcePage receivesRead withReleased
Web view drop, web view <input type="file">browser Filethe browser’s File APIby the browser
Native renderer dropFile (extensible) with name, type (from the OS, or guessed from the extension), size, lastModified (ms), webkitRelativePath = "", and handle (a number)text(), arrayBuffer(), bytes(), slice(), FileReader; reads are always asynchronouslast File/slice collected, page reload, window closed
Received shareshare:received event: { id, source, text, subject, url, files: [{ handle, name, mime, size }] }oriel.share.file(fileOrHandle) gives a Fileoriel.share.release(id) in Zig
oriel.dialognothing by itself: what your command returns
Lasting foldernothing by itself: the id stays in Zigoriel.dialog.forgetFolder

A native renderer File can’t cross windows: oriel.window.emitTo serializes to JSON. Handles in the two tables are separate numbers; a drop handle means nothing to oriel.share.file, and the reverse.

Page API

// A dropped file's path; null in the web view (its files have no handle).
oriel.drop.path(file: File | number): Promise<string | null>;

// Received shares (oriel.share; full API on the Share page).
oriel.share.file(f: { handle: number; name?: string; mime?: string } | number): Promise<File>;
  • oriel.drop.path sends the drop:path command, which every platform’s native bridge answers (src/core/native_commands.zig, after the command policy check). Anything but a dropped File or a number answers null without asking.
  • oriel.share.file reads in 4 MiB chunks through share:read (base64, at most 16 MiB per call) and assembles a File named name (default "file") of type mime. It fails with InvalidHandle once the share was released, and with NotReadable when the file changed after it arrived.

Errors

The handle table’s errors, as Zig and the page see them:

file_handles.ErrorPage (native renderer read)Meaning
BadHandleNotFoundError (share: InvalidHandle)never given, or released
NotReadableNotReadableErrorthe file changed since it was handed over (size or mtime), or a read failed
TooLargea single read over 64 MiB (the JS side splits reads, so pages don’t see it)
NotAFilea descriptor given to addFd isn’t a regular file
OpenFailedopening a dropped or shared path failed (the file is skipped)
Unsupportedout of handles (about 4 billion live ones)
OutOfMemory

Limits

  • At most 64 MiB per read (file_handles.max_read); larger blobs read in pieces.
  • Native renderer drops: at most 4096 items per drop, and 16 MiB per dropped string (see Drag and drop).
  • share:read: at most 16 MiB per call; share:send from the page: at most 16 MiB of file bytes in all (bigger files go from Zig paths or as received handles).
  • Received shares that arrive before the page listens: the last 8 are kept for it (and for the first Zig handler).

Internal API

FileHandles (src/core/file_handles.zig) is the table both features use; apps don’t create one. For contributors adding a source of files: init(gpa), deinit() (closes everything), addPath(path) !?u32 (null for a non-regular file), addFd(fd) !u32 (takes ownership, closes on failure), read(handle, offset, len, out), duplicate(handle) !std.Io.File (an owned copy), info(handle) ?Entry (size, mtime_ns, mtimeMs()), release(handle) (unknown handles ignored), count(), currentPath (Windows: where the file is now, for OS APIs that take only paths; never for the page) and nativePath (every platform: for drop:path). All are thread-safe. Linux and Android use Linux syscalls, macOS and iOS libc, Windows kernel32.

Troubleshooting

  • Reading a dropped or shared file fails with NotReadableError. The file was saved, touched or truncated after the user handed it over. Ask for it again; this is the browser’s behaviour too.
  • oriel.drop.path answers null. The file has no path any more: iOS deleted its temporary copy, or an Android content:// stream isn’t a file. Read the bytes through the File instead.
  • The page never gets share:received files’ bytes on Linux or Android. Receiving shares isn’t written there yet (oriel.share.capabilities() reports receive: null).

Related

  • Drag and drop: drops into native renderer pages, DataTransfer, effects, drags out.
  • Dialogs: open, save and folder pickers; lasting folder access.
  • Share: receiving files from other apps, and the share sheet.
  • Camera: image inputs and photos.
  • Security: which pages may call which commands.

Search documentation

Type to search the documentation.