windows implementation

This commit is contained in:
Peter Li 2026-09-18 02:50:00 -07:00
parent 0c8270fbef
commit d1c4f14877
13 changed files with 1947 additions and 36 deletions

View File

@ -1,20 +1,25 @@
# Uhdk # Uhdk
A small macOS desktop platform library written in Zig 0.16.0 with a C99 ABI. A small desktop platform library written in Zig 0.16.0 with a C99 ABI.
It provides windows, input and IME, clipboard, file dialogs, drag and drop, menus, It provides windows, input and IME, clipboard, file dialogs, drag and drop, menus,
async file operations, file watching, and OpenGL context creation. The macOS async file operations, file watching, and OpenGL context creation. The macOS
backend uses AppKit, OpenGL, and FSEvents through a private Objective-C bridge. backend uses AppKit, OpenGL, and FSEvents through a private Objective-C bridge.
This directory is a standalone source package. It does not depend on the parent This directory is a standalone source package. It does not depend on the parent
repository, its examples, fonts, skills, or development tools. Currently macOS is repository, its examples, fonts, skills, or development tools. macOS builds require
the only implemented backend. Building requires Xcode Command Line Tools. Xcode Command Line Tools. The Windows Win32/WGL backend cross-compiles for
`x86_64-windows-gnu`; runtime verification on Windows is pending. It requires
Windows 10 version 1703 or later and an OpenGL 3.3-capable driver.
## Build ## Build
```sh ```sh
zig build # zig-out/lib/libuhdk.a and zig-out/include/ zig build # zig-out/lib/libuhdk.a and zig-out/include/
zig build -Dshared=true # zig-out/lib/libuhdk.dylib and headers zig build -Dshared=true # zig-out/lib/libuhdk.dylib and headers
zig build test # core library tests zig build test # run core library tests on the native target
zig build -Dtarget=x86_64-windows-gnu
zig build -Dtarget=x86_64-windows-gnu -Dshared=true
zig build check -Dtarget=x86_64-windows-gnu # compile only
``` ```
## Use from Zig ## Use from Zig
@ -63,3 +68,12 @@ The library includes the GLAD loader implementation. With a current context insi
`uhdk_gl_context_get_proc`. Keep a `GladGLContext` per native context; its members `uhdk_gl_context_get_proc`. Keep a `GladGLContext` per native context; its members
are named `Clear`, `DrawArrays`, etc. Check `VERSION_3_3` after loading. Do not define are named `Clear`, `DrawArrays`, etc. Check `VERSION_3_3` after loading. Do not define
`GLAD_GL_IMPLEMENTATION` in your application when linking libuhdk. `GLAD_GL_IMPLEMENTATION` in your application when linking libuhdk.
On Windows, the static artifact is `zig-out/lib/uhdk.lib`; shared builds install
`zig-out/bin/uhdk.dll` and its import library under `zig-out/lib/`. Keep the DLL
beside your executable. The first lifecycle caller establishes the Windows
platform thread; its message loop also owns COM dialogs, menus, clipboard and
watch completion. Input uses IMM32 composition; TSF reconversion and pen/touch
are not implemented. Native UTF-16 path bytes are kept separately from the WTF-8
paths passed to Zig I/O. If a native path cannot be exported losslessly as a shell
URI, URI export returns `UNSUPPORTED`; native-path export still preserves it.

View File

@ -2,7 +2,7 @@ const std = @import("std");
pub fn build(b: *std.Build) void { pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{}); const target = b.standardTargetOptions(.{});
if (target.result.os.tag != .macos) @panic("Uhdk currently implements macOS only."); if (target.result.os.tag != .macos and target.result.os.tag != .windows) @panic("Uhdk supports macOS and Windows.");
const optimize = b.standardOptimizeOption(.{}); const optimize = b.standardOptimizeOption(.{});
const shared = b.option(bool, "shared", "Build libuhdk as a shared library instead of static") orelse false; const shared = b.option(bool, "shared", "Build libuhdk as a shared library instead of static") orelse false;
const module = b.createModule(.{ const module = b.createModule(.{
@ -12,25 +12,30 @@ pub fn build(b: *std.Build) void {
.link_libc = true, .link_libc = true,
}); });
const native = b.addTranslateC(.{ const native = b.addTranslateC(.{
.root_source_file = b.path("src/macos/native.h"), .root_source_file = b.path("src/native.h"),
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
}); });
native.addIncludePath(b.path("include")); native.addIncludePath(b.path("include"));
module.addImport("native", native.createModule()); module.addImport("native", native.createModule());
module.addIncludePath(b.path("include")); module.addIncludePath(b.path("include"));
module.addIncludePath(b.path("src/macos")); module.addIncludePath(b.path("src"));
module.addCSourceFile(.{ if (target.result.os.tag == .macos) {
.file = b.path("src/macos/native.m"), module.addCSourceFile(.{
.flags = &.{ .file = b.path("src/macos/native.m"),
"-fobjc-arc", "-fblocks", "-Wno-deprecated-declarations", .flags = &.{
"-Wall", "-Wextra", "-Werror", "-fobjc-arc", "-fblocks", "-Wno-deprecated-declarations",
}, "-Wall", "-Wextra", "-Werror",
}); },
module.addCSourceFile(.{ .file = b.path("src/glad.c"), .flags = &.{"-std=c99"} }); });
module.linkFramework("Cocoa", .{}); module.linkFramework("Cocoa", .{});
module.linkFramework("OpenGL", .{}); module.linkFramework("OpenGL", .{});
module.linkFramework("CoreServices", .{}); module.linkFramework("CoreServices", .{});
} else {
module.addCSourceFile(.{ .file = b.path("src/windows/native.c"), .flags = &.{ "-std=c11", "-Wall", "-Wextra", "-Werror", "-DUNICODE", "-D_UNICODE" } });
for ([_][]const u8{ "user32", "gdi32", "opengl32", "shell32", "ole32", "uuid", "imm32", "comctl32", "shlwapi", "advapi32" }) |name| module.linkSystemLibrary(name, .{});
}
module.addCSourceFile(.{ .file = b.path("src/glad.c"), .flags = if (shared) &.{ "-std=c99", "-DGLAD_API_CALL_EXPORT", "-DGLAD_API_CALL_EXPORT_BUILD" } else &.{"-std=c99"} });
const lib = b.addLibrary(.{ const lib = b.addLibrary(.{
.name = "uhdk", .name = "uhdk",
.linkage = if (shared) .dynamic else .static, .linkage = if (shared) .dynamic else .static,
@ -48,6 +53,9 @@ pub fn build(b: *std.Build) void {
b.getInstallStep().dependOn(&install_lib.step); b.getInstallStep().dependOn(&install_lib.step);
b.getInstallStep().dependOn(&install_headers.step); b.getInstallStep().dependOn(&install_headers.step);
const unit = b.addTest(.{ .root_module = module }); const unit = b.addTest(.{ .root_module = module });
const check_step = b.step("check", "Compile library and core tests without running them");
check_step.dependOn(&lib.step);
check_step.dependOn(&unit.step);
const unit_run = b.addRunArtifact(unit); const unit_run = b.addRunArtifact(unit);
b.step("test", "Run core library tests").dependOn(&unit_run.step); b.step("test", "Run core library tests").dependOn(&unit_run.step);
const public_c = b.addTranslateC(.{ const public_c = b.addTranslateC(.{
@ -56,6 +64,7 @@ pub fn build(b: *std.Build) void {
.optimize = optimize, .optimize = optimize,
}); });
public_c.addIncludePath(b.path("include")); public_c.addIncludePath(b.path("include"));
if (shared) public_c.defineCMacro("UHDK_SHARED", "1");
const public_module = b.addModule("uhdk", .{ const public_module = b.addModule("uhdk", .{
.root_source_file = public_c.getOutput(), .root_source_file = public_c.getOutput(),
.target = target, .target = target,

View File

@ -2,7 +2,7 @@
#define UHDK_H_INCLUDED #define UHDK_H_INCLUDED
/* /*
* Uhdk desktop platform API — v0.1, macOS backend. * Uhdk desktop platform API — v0.1, macOS and Windows backends.
* *
* Implementation: Zig. ABI: C. Header baseline: C99 (also usable from C++). * Implementation: Zig. ABI: C. Header baseline: C99 (also usable from C++).
* No libc runtime objects or allocation contract. * No libc runtime objects or allocation contract.
@ -484,7 +484,10 @@ UHDK_API uhdk_status UHDK_CALL uhdk_window_cancel_composition_async(uhdk_window
/* File references preserve native names, including non-UTF-8 POSIX paths. /* File references preserve native names, including non-UTF-8 POSIX paths.
* Display names are UTF-8. URI/native-path export is explicit and owned by the * Display names are UTF-8. URI/native-path export is explicit and owned by the
* caller's buffer. Windows native path bytes are UTF-16LE (no terminator), * caller's buffer. Windows native path bytes are UTF-16LE (no terminator),
* POSIX native paths are uninterpreted bytes. A reference can carry an access * POSIX native paths are uninterpreted bytes. Windows URI export returns
* UNSUPPORTED when the shell cannot represent a native name losslessly (such
* as unpaired UTF-16 surrogates); native-path export remains available.
* A reference can carry an access
* grant; retaining it keeps that grant alive where the OS permits. Persistence * grant; retaining it keeps that grant alive where the OS permits. Persistence
* across launches/bookmark serialization is deferred to a future extension. * across launches/bookmark serialization is deferred to a future extension.
* Buffer queries: NULL/0 gets required byte count in out_len and returns UHDK_STATUS_OK. * Buffer queries: NULL/0 gets required byte count in out_len and returns UHDK_STATUS_OK.
@ -523,9 +526,11 @@ typedef struct uhdk_file_info {
* Read/write accept regular files only; final-component symlinks are rejected. * Read/write accept regular files only; final-component symlinks are rejected.
* Write copies bytes at submission, stages an exclusive same-directory temp, * Write copies bytes at submission, stages an exclusive same-directory temp,
* preserves POSIX mode of an existing regular file, then renames atomically. * preserves POSIX mode of an existing regular file, then renames atomically.
* Windows rejects read-only destinations; replacement gets the directory's
* inherited permissions and does not preserve other destination attributes.
* Symlinks/nonregular destinations are rejected. ACLs/xattrs/ownership are not * Symlinks/nonregular destinations are rejected. ACLs/xattrs/ownership are not
* preserved; replacement requires a directory writable by the current user. * preserved; replacement requires a directory writable by the current user.
* New files use mode 0600. No power-loss durability or conflict detection promise. * New POSIX files use mode 0600. No power-loss durability or conflict detection promise.
* No silent truncate fallback. Cancellation before rename leaves the target intact. * No silent truncate fallback. Cancellation before rename leaves the target intact.
*/ */
UHDK_API uhdk_status UHDK_CALL uhdk_file_read_all_async(uhdk_file *, uint64_t max_bytes, UHDK_API uhdk_status UHDK_CALL uhdk_file_read_all_async(uhdk_file *, uint64_t max_bytes,

View File

@ -11,6 +11,9 @@
* uhdk_gl_context_get_proc, inside a begin_frame/end_frame bracket. Keep one * uhdk_gl_context_get_proc, inside a begin_frame/end_frame bracket. Keep one
* table per context and use it only while that context is current. * table per context and use it only while that context is current.
*/ */
#if defined(UHDK_SHARED)
#define GLAD_API_CALL_EXPORT
#endif
#include "glad/gl.h" #include "glad/gl.h"
#endif /* UHDK_OPENGL_H_INCLUDED */ #endif /* UHDK_OPENGL_H_INCLUDED */

View File

@ -73,17 +73,19 @@ pub fn step(self: *FileOperation) !Result {
}; };
if (stat) |existing| { if (stat) |existing| {
if (existing.kind != .file) return error.NotRegularFile; if (existing.kind != .file) return error.NotRegularFile;
if (@import("builtin").os.tag == .windows and existing.permissions.toAttributes().READONLY)
return error.AccessDenied;
} }
self.atomic_file = try cwd.createFileAtomic(self.io, self.path, .{ self.atomic_file = try cwd.createFileAtomic(self.io, self.path, .{
.permissions = .fromMode(0o600), .permissions = if (@import("builtin").os.tag == .windows) .default_file else .fromMode(0o600),
.replace = true, .replace = true,
}); });
if (stat) |existing| { if (@import("builtin").os.tag != .windows) if (stat) |existing| {
try self.atomic_file.?.file.setPermissions( try self.atomic_file.?.file.setPermissions(
self.io, self.io,
.fromMode(existing.permissions.toMode() & 0o777), .fromMode(existing.permissions.toMode() & 0o777),
); );
} };
}, },
.info => unreachable, .info => unreachable,
} }

View File

@ -45,6 +45,7 @@ pub const Gl = struct {
}; };
pub const File = struct { pub const File = struct {
native_path: []const u8 = &.{},
path: [:0]const u8 = "", path: [:0]const u8 = "",
uri: []const u8 = "", uri: []const u8 = "",
display: []const u8 = "", display: []const u8 = "",

View File

@ -1,5 +1,6 @@
//! C ABI entry points for app. //! C ABI entry points for app.
const std = @import("std"); const std = @import("std");
const backend_kind = if (@import("builtin").os.tag == .windows) c.UHDK_BACKEND_WINDOWS else c.UHDK_BACKEND_MACOS;
const c = @import("native"); const c = @import("native");
const runtime = @import("../runtime.zig"); const runtime = @import("../runtime.zig");
const abi = @import("../abi.zig"); const abi = @import("../abi.zig");
@ -19,7 +20,7 @@ pub export fn uhdk_app_create(desc: [*c]const c.uhdk_app_desc, out: [*c]?*c.uhdk
desc.*.reserved != 0 or desc.*.reserved != 0 or
!abi.validText(desc.*.display_name, true) or !abi.validText(desc.*.display_name, true) or
!abi.validText(desc.*.application_id, true)) return abi.INVALID; !abi.validText(desc.*.application_id, true)) return abi.INVALID;
if (desc.*.preferred_backend != c.UHDK_BACKEND_AUTO and desc.*.preferred_backend != c.UHDK_BACKEND_MACOS) return c.UHDK_STATUS_UNSUPPORTED; if (desc.*.preferred_backend != c.UHDK_BACKEND_AUTO and desc.*.preferred_backend != backend_kind) return c.UHDK_STATUS_UNSUPPORTED;
if (singleton) return c.UHDK_STATUS_BUSY; if (singleton) return c.UHDK_STATUS_BUSY;
const gpa = std.heap.smp_allocator; const gpa = std.heap.smp_allocator;
const backend = gpa.create(std.Io.Threaded) catch return abi.OOM; const backend = gpa.create(std.Io.Threaded) catch return abi.OOM;
@ -218,7 +219,7 @@ pub export fn uhdk_app_set_wake_deadline(handle: ?*c.uhdk_app, deadline: u64) ab
} }
pub export fn uhdk_app_get_backend(_: ?*c.uhdk_app) c.uhdk_backend { pub export fn uhdk_app_get_backend(_: ?*c.uhdk_app) c.uhdk_backend {
return c.UHDK_BACKEND_MACOS; return backend_kind;
} }
pub export fn uhdk_app_has_capability(_: ?*c.uhdk_app, cap: c.uhdk_capability) c.uhdk_bool { pub export fn uhdk_app_has_capability(_: ?*c.uhdk_app, cap: c.uhdk_capability) c.uhdk_bool {

View File

@ -38,9 +38,9 @@ pub export fn uhdk_file_create_from_native_path(
if (handle == null or if (handle == null or
out == null or out == null or
!abi.validSpan(path.ptr, path.len) or !abi.validSpan(path.ptr, path.len) or
path.len == 0 or path.len == 0) return abi.INVALID;
abi.bytes(path)[0] != '/' or if (@import("builtin").os.tag != .windows and
std.mem.findScalar(u8, abi.bytes(path), 0) != null) return abi.INVALID; (abi.bytes(path)[0] != '/' or std.mem.findScalar(u8, abi.bytes(path), 0) != null)) return abi.INVALID;
if (runtime.checkThread(runtime.fromAppHandle(handle)) != abi.OK) return c.UHDK_STATUS_WRONG_THREAD; if (runtime.checkThread(runtime.fromAppHandle(handle)) != abi.OK) return c.UHDK_STATUS_WRONG_THREAD;
const object = runtime.createObject(runtime.fromAppHandle(handle), c.Z_KIND_FILE) catch return abi.OOM; const object = runtime.createObject(runtime.fromAppHandle(handle), c.Z_KIND_FILE) catch return abi.OOM;
const status = c.z_native_file_init(runtime.castHandle(*c.uhdk_file, object), path, .{ .ptr = null, .len = 0 }, null); const status = c.z_native_file_init(runtime.castHandle(*c.uhdk_file, object), path, .{ .ptr = null, .len = 0 }, null);
@ -91,9 +91,11 @@ pub export fn uhdk_file_copy_representation(
const data = switch (form) { const data = switch (form) {
c.UHDK_FILE_DISPLAY_NAME => object.payload.file.display, c.UHDK_FILE_DISPLAY_NAME => object.payload.file.display,
c.UHDK_FILE_URI => object.payload.file.uri, c.UHDK_FILE_URI => object.payload.file.uri,
c.UHDK_FILE_NATIVE_PATH => object.payload.file.path, c.UHDK_FILE_NATIVE_PATH => object.payload.file.native_path,
else => return abi.INVALID, else => return abi.INVALID,
}; };
if (@import("builtin").os.tag == .windows and form == c.UHDK_FILE_URI and data.len == 0)
return c.UHDK_STATUS_UNSUPPORTED;
out.* = data.len; out.* = data.len;
if (buffer == null and capacity == 0) return abi.OK; if (buffer == null and capacity == 0) return abi.OK;
if (capacity < data.len) return c.UHDK_STATUS_BUFFER_TOO_SMALL; if (capacity < data.len) return c.UHDK_STATUS_BUFFER_TOO_SMALL;

View File

@ -1,8 +1,8 @@
//! C ABI entry points for callbacks. //! C ABI entry points for callbacks.
const std = @import("std"); const std = @import("std");
const c = @import("native"); const c = @import("native");
const abi = @import("../abi.zig"); const abi = @import("abi.zig");
const runtime = @import("../runtime.zig"); const runtime = @import("runtime.zig");
export fn z_emit(handle: ?*c.uhdk_app, event: [*c]const c.uhdk_event) void { export fn z_emit(handle: ?*c.uhdk_app, event: [*c]const c.uhdk_event) void {
runtime.emit(handle, event); runtime.emit(handle, event);
@ -65,7 +65,15 @@ export fn z_file_init(
) abi.Status { ) abi.Status {
const object = runtime.fromObjectHandle(handle); const object = runtime.fromObjectHandle(handle);
const arena = object.arena.allocator(); const arena = object.arena.allocator();
object.payload.file.path = arena.dupeZ(u8, abi.bytes(path)) catch return abi.OOM; object.payload.file.native_path = arena.dupe(u8, abi.bytes(path)) catch return abi.OOM;
if (@import("builtin").os.tag == .windows) {
if (path.len % 2 != 0) return abi.INVALID;
const wide = arena.alloc(u16, @intCast(path.len / 2)) catch return abi.OOM;
@memcpy(std.mem.sliceAsBytes(wide), abi.bytes(path));
object.payload.file.path = std.unicode.wtf16LeToWtf8AllocZ(arena, wide) catch return abi.OOM;
} else {
object.payload.file.path = arena.dupeZ(u8, abi.bytes(path)) catch return abi.OOM;
}
object.payload.file.uri = arena.dupe(u8, abi.textBytes(uri)) catch return abi.OOM; object.payload.file.uri = arena.dupe(u8, abi.textBytes(uri)) catch return abi.OOM;
object.payload.file.display = arena.dupe(u8, abi.textBytes(display)) catch return abi.OOM; object.payload.file.display = arena.dupe(u8, abi.textBytes(display)) catch return abi.OOM;
object.native = grant; object.native = grant;
@ -87,7 +95,7 @@ export fn z_file_from_url(
export fn z_file_path(handle: ?*c.uhdk_file) c.uhdk_bytes { export fn z_file_path(handle: ?*c.uhdk_file) c.uhdk_bytes {
const object = runtime.fromObjectHandle(handle); const object = runtime.fromObjectHandle(handle);
return .{ .ptr = object.payload.file.path.ptr, .len = object.payload.file.path.len }; return .{ .ptr = object.payload.file.native_path.ptr, .len = object.payload.file.native_path.len };
} }
export fn z_menu_items(handle: ?*c.uhdk_menu, len: [*c]u64) [*c]const c.uhdk_menu_item { export fn z_menu_items(handle: ?*c.uhdk_menu, len: [*c]u64) [*c]const c.uhdk_menu_item {

View File

@ -10,7 +10,7 @@ comptime {
checkExports(@import("api/files.zig")); checkExports(@import("api/files.zig"));
checkExports(@import("api/menus.zig")); checkExports(@import("api/menus.zig"));
checkExports(@import("api/desktop.zig")); checkExports(@import("api/desktop.zig"));
_ = @import("macos/callbacks.zig"); _ = @import("callbacks.zig");
} }
test { test {

View File

@ -294,6 +294,7 @@ pub fn applicationMain(application: *Application) void {
application.worker_id = std.Thread.getCurrentId(); application.worker_id = std.Thread.getCurrentId();
application.unlock(); application.unlock();
application.entry.?(toAppHandle(application), application.user); application.entry.?(toAppHandle(application), application.user);
if (@import("builtin").os.tag == .windows) _ = c.z_native_gl(null, 4, null);
application.lock(); application.lock();
application.stopping = true; application.stopping = true;
application.worker_done = true; application.worker_done = true;
@ -364,7 +365,11 @@ pub fn applicationError(err: anyerror) abi.Status {
pub fn destroyObject(object: *Resource) void { pub fn destroyObject(object: *Resource) void {
if (!object.alive) return; if (!object.alive) return;
std.debug.assert(checkThread(object.app) == abi.OK); std.debug.assert(checkThread(object.app) == abi.OK);
if (object.payload == .gl) std.debug.assert(!object.payload.gl.bracket); if (object.payload == .gl) {
std.debug.assert(!object.payload.gl.bracket);
if (@import("builtin").os.tag == .windows and object.native != null)
_ = c.z_native_gl(object.native, 8, null);
}
if (object.payload == .window) if (object.payload.window.context) |ctx| { if (object.payload == .window) if (object.payload.window.context) |ctx| {
destroyObject(ctx); destroyObject(ctx);
object.payload.window.context = null; object.payload.window.context = null;

1861
src/windows/native.c Normal file

File diff suppressed because it is too large Load Diff