diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index def45bc..2d7058c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,7 +118,7 @@ jobs: run: | profile='${{ matrix.profile }}' mcpp test --profile "$profile" 2>&1 | tee tests.log - for name in platform_test fs_test process_test ipc_test unit_test dirs_terminal_test linux_test watch_test; do + for name in platform_test fs_test process_test ipc_test unit_test dirs_terminal_test linux_test watch_test windows_test macos_test; do grep -q "^$name \.\.\. ok" tests.log || { echo "::error::$name did not run or did not pass"; exit 1; } done @@ -219,7 +219,7 @@ jobs: run: | profile='${{ matrix.profile }}' mcpp test --profile "$profile" ${{ matrix.target }} 2>&1 | tee tests.log - for name in platform_test fs_test process_test ipc_test unit_test dirs_terminal_test linux_test library_test watch_test; do + for name in platform_test fs_test process_test ipc_test unit_test dirs_terminal_test linux_test library_test watch_test windows_test macos_test; do grep -q "^$name \.\.\. ok" tests.log || { echo "::error::$name did not run or did not pass"; exit 1; } done @@ -271,7 +271,7 @@ jobs: run: | set -euo pipefail mcpp test --target x86_64-openkal 2>&1 | tee tests.log - for name in platform_test fs_test process_test ipc_test unit_test dirs_terminal_test linux_test watch_test; do + for name in platform_test fs_test process_test ipc_test unit_test dirs_terminal_test linux_test watch_test windows_test macos_test; do grep -q "^$name \.\.\. ok" tests.log || { echo "::error::$name did not pass as an image"; exit 1; } done mcpp test --target aarch64-openkal --no-run @@ -424,7 +424,7 @@ jobs: run: | set -euo pipefail mcpp test --target wasm32-emscripten --profile '${{ matrix.profile }}' 2>&1 | tee tests.log - for name in platform_test fs_test dirs_terminal_test linux_test library_test watch_test; do + for name in platform_test fs_test dirs_terminal_test linux_test library_test watch_test windows_test macos_test; do grep -q "^$name \.\.\. ok" tests.log || { echo "::error::$name did not run or did not pass"; exit 1; } done - name: The compile-time constants of Emscripten (R3) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d3bb52..087538b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## 0.4.4 + +- **The system's object models: `windows::com` and `macos::objc`.** A system's + services that are objects rather than functions --- toast notifications, the + shell's dialogs, the Windows Runtime's classes; Foundation, AppKit, + UserNotifications --- are reached through them. On Windows: an apartment, one + reference to an object (`object`, AddRef and Release paired), a method called + by its slot, `query`, `create`, `hstring` (UTF-8 there and back), `activate`, + `factory`, and `delegate`, an object of the program's own that the system + calls back (IUnknown, IAgileObject and an interface whose method is + `Invoke(sender, args)`); an HRESULT becomes an `errc`. On macOS: `id` (one + reference), `send` (objc_msgSend typed as the method), an autorelease pool, + classes and selectors by name, frameworks loaded by name, NSString from and to + UTF-8, a class defined at run time (a delegate), and `block`, a block the + runtime holds and releases. Both are platform modules (R2, R5): declared on + every target, defined on their own platform, not defined in an image, which + loads no library of the system. The libraries are found when first used + (ole32, combase; libobjc, libSystem's blocks through `os::library`), so + nothing is added to a program's link. The tests make a Windows Runtime + `Uri`, call its methods, refuse an interface it lacks and are called back by + a delegate; on macOS they send NSProcessInfo a message, define a class that + receives messages and enumerate an NSArray with a block, each released after; + strings in four scripts go through the system and back on both. The R5 check + covers a member template in a discarded branch. + ## 0.4.3 - **openkal 0.25 and the implementations of its family are admitted.** The diff --git a/README.md b/README.md index 1302981..9faa192 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ dropped (0.2.8). | `mcpplibs.os.machine` | the machine's architecture (through Rosetta and WoW64), memory and the memory available now, processors (logical, physical, of differing speed) | | `mcpplibs.os.random` | bytes from the system's source of unpredictable bytes; `hex` | | `mcpplibs.os.library` | shared libraries at run time: `open` (a path or a name the system searches for), `loaded` (one the process has, without loading it), a handle's `symbol` / `function`, `process_symbol` (as the loader resolves one), `suffix` | -| `mcpplibs.os.linux` / `.macos` / `.windows` / `.posix` | platform modules: declared on every target, defined on their own and, where an image can answer, in an image | +| `mcpplibs.os.linux` / `.macos` / `.windows` / `.posix` | platform modules: declared on every target, defined on their own and, where an image can answer, in an image; the system's object models, through which its services that are not functions are reached: `windows::com` (COM and the Windows Runtime) and `macos::objc` (the Objective-C runtime) | ## Rules @@ -185,12 +185,33 @@ would name another system's module on every build. The platform modules in an im | `posix::effective_user()` | `/proc/self/status` on Linux, `/usr/bin/id -u` on macOS; the largest number when neither answers | | `macos::translated()` | `false`: macOS runs images through an aarch64 okrun only | | `windows::code_page()`, `console_output_code_page()`, `long_paths_enabled()`, `machine_registry_path()`, `writable_fixed_drive()`, `posix::set_creation_mask()` | not defined: okrun carries an image's streams and names, and the others belong to a native installation | +| `windows::com`, `macos::objc` | not defined: an image loads no library of the system; ask `if constexpr (os::is_windows)` / `os::is_macos` | The system's programs are started by absolute name (`%SystemRoot%\System32`, `/usr/bin`), with a bound of ten seconds, and each answer is asked once per process. `host()` decides which system's question applies, which is that system's convention; whether a program can be started is openkal's capability and is answered by the start itself (openkal SPEC clause 30). +## The system's object models (0.4.4) + +Some of a system's services are not functions but objects: the shell's dialogs, notifications, +the Windows Runtime's classes, AppKit and the frameworks above Foundation. `windows::com` and +`macos::objc` are how they are reached, and nothing more: a program states the interfaces it +calls (a method by its slot in COM, a message by its selector and its types in Objective-C), as +it would from the system's headers, which this package does not include. What a service *is* +belongs to the library above (a notification's contract, a file dialog's). + +| `windows::com` | `macos::objc` | +|---|---| +| `apartment::enter` (CoInitializeEx), `object` (one reference: AddRef and Release paired), `object::call(slot, args...)`, `query` (QueryInterface), `create` (CoCreateInstance) | `id` (one reference: objc_retain and objc_release paired), `send(receiver, selector, args...)` (objc_msgSend typed as the method is), `autorelease_pool`, `class_named`, `selector` | +| `hstring` (UTF-8 there and back, R7), `activate` (RoActivateInstance), `factory` (RoGetActivationFactory) | `string` and `utf8` (NSString, R7), `load_framework` | +| `delegate(interface, invoked)`: an object the system calls back (`Invoke(sender, args)`, the shape of every Windows Runtime event handler), agile | `define_class` (a delegate's methods), `block(function)`: a block the runtime holds, released with its last reference | +| `id("…")`: an interface's identity at compile time; `checked(HRESULT)`: E_NOINTERFACE is `not_supported`, a class not registered `not_found` | `available()`: `not_supported` in a program that loads no library | + +The libraries are loaded when first used (ole32 and combase; libobjc and libSystem's blocks, +through `os::library`), so a program links nothing for them and a toolchain without the +system's import libraries builds them unchanged. + ## As an ok image Built for `-openkal` (openkal SPEC clause 12), a program over this library diff --git a/checks/discarded/src/main.cpp b/checks/discarded/src/main.cpp index 9d91158..bbe5eed 100644 --- a/checks/discarded/src/main.cpp +++ b/checks/discarded/src/main.cpp @@ -13,6 +13,9 @@ int main() { if constexpr (os::is_linux) n += static_cast(os::linux::translator().has_value()); if constexpr (os::is_macos) n += static_cast(os::macos::translated()); if constexpr (os::is_windows) n += static_cast(os::windows::elevated()); + // A member template too (0.4.4): its definition is not instantiated where it is not used. + if constexpr (os::is_windows) n += static_cast(os::windows::com::object{}.call(3, 0).has_value()); + if constexpr (os::is_macos) n += static_cast(os::macos::objc::send(nullptr, "hash")); // The same calls in the form that also holds in an image (0.4.0). if constexpr (os::can_run_on(os::platform::linux)) if (os::host() == os::platform::linux) n += static_cast(os::linux::translator().has_value()); diff --git a/checks/emscripten/tests/macos_test.cpp b/checks/emscripten/tests/macos_test.cpp new file mode 100644 index 0000000..3cd96fc --- /dev/null +++ b/checks/emscripten/tests/macos_test.cpp @@ -0,0 +1,6 @@ +// macos_test.cpp of mcpplibs.os, compiled for Emscripten. The imports are restated +// because the dependency scan reads this file and not the one it includes. +import std; +import mcpplibs.os; +import mcpplibs.mtest; +#include "../../../tests/macos_test.cpp" diff --git a/checks/emscripten/tests/windows_test.cpp b/checks/emscripten/tests/windows_test.cpp new file mode 100644 index 0000000..d15e901 --- /dev/null +++ b/checks/emscripten/tests/windows_test.cpp @@ -0,0 +1,6 @@ +// windows_test.cpp of mcpplibs.os, compiled for Emscripten. The imports are restated +// because the dependency scan reads this file and not the one it includes. +import std; +import mcpplibs.os; +import mcpplibs.mtest; +#include "../../../tests/windows_test.cpp" diff --git a/checks/hosted/tests/macos_test.cpp b/checks/hosted/tests/macos_test.cpp new file mode 100644 index 0000000..ccf75d5 --- /dev/null +++ b/checks/hosted/tests/macos_test.cpp @@ -0,0 +1,6 @@ +// macos_test.cpp of mcpplibs.os, compiled in this arrangement. The imports are restated +// because the dependency scan reads this file and not the one it includes. +import std; +import mcpplibs.os; +import mcpplibs.mtest; +#include "../../../tests/macos_test.cpp" diff --git a/checks/hosted/tests/windows_test.cpp b/checks/hosted/tests/windows_test.cpp new file mode 100644 index 0000000..caaef27 --- /dev/null +++ b/checks/hosted/tests/windows_test.cpp @@ -0,0 +1,6 @@ +// windows_test.cpp of mcpplibs.os, compiled in this arrangement. The imports are restated +// because the dependency scan reads this file and not the one it includes. +import std; +import mcpplibs.os; +import mcpplibs.mtest; +#include "../../../tests/windows_test.cpp" diff --git a/mcpp.toml b/mcpp.toml index f4a463d..38a4f67 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "os" -version = "0.4.3" +version = "0.4.4" description = "mcpplibs.os: a C++ operating-system library over openkal --- processes, files, environment, terminals, directories and local IPC, with compile-time platform constants and a few platform modules." license = "Apache-2.0" authors = ["mcpplibs"] diff --git a/src/macos.cppm b/src/macos.cppm index 70cc282..9328c7d 100644 --- a/src/macos.cppm +++ b/src/macos.cppm @@ -2,6 +2,7 @@ export module mcpplibs.os.macos; import std; +import mcpplibs.os.error; export namespace mcpplibs::os::macos { @@ -11,3 +12,136 @@ export namespace mcpplibs::os::macos { bool translated(); } // namespace mcpplibs::os::macos + +// THE OBJECTIVE-C RUNTIME (0.4.4): how the system's frameworks are called +// (Foundation, AppKit, UserNotifications). No other system has it (R2). +// libobjc and the frameworks are loaded when first used (`os::library'), so a +// program links nothing for them; an image loads no library of the system, +// and these are not defined there (R5): a program that is to run as an image +// asks `if constexpr (os::is_macos)', the question of a native build. +// +// objc::autorelease_pool pool; +// void* info = objc::send(objc::class_named("NSProcessInfo"), "processInfo"); +// std::string name = objc::utf8(objc::send(info, "processName")); +// +// A message is objc_msgSend typed as the method is: `send(receiver, +// selector, args...)'. A result returned in memory rather than in registers +// (a structure larger than two words on x86-64) is not sent this way. +export namespace mcpplibs::os::macos::objc { + +// One reference to an object, released with this and retained by a copy +// (objc_retain and objc_release are paired here, R6). +class id { +public: + id() noexcept = default; + id(const id& other) noexcept; + id(id&& other) noexcept : raw_(std::exchange(other.raw_, nullptr)) {} + id& operator=(id other) noexcept; // out of line: it releases, defined on macOS only (R5) + ~id(); + + // A reference the caller owns (what alloc, new, copy and init return) becomes this one. + static id adopt(void* raw) noexcept; + // A reference of its own to an object another holds (an autoreleased result). + static id borrow(void* raw) noexcept; + + [[nodiscard]] void* raw() const noexcept { return raw_; } + explicit operator bool() const noexcept { return raw_ != nullptr; } + +private: + void* raw_ = nullptr; +}; + +// Whether the runtime is here, with Foundation (NSString, NSArray, ...): +// not_supported where this program cannot load a library (a static program). +result available(); +// A framework of the system, by name ("UserNotifications"): its classes exist after. +result load_framework(std::string_view name); +// A class by name (null: none), a selector by name, objc_msgSend (null: no runtime). +[[nodiscard]] void* class_named(std::string_view name); +[[nodiscard]] void* selector(std::string_view name); +[[nodiscard]] void* message_function(); + +// A message to `receiver'; with no runtime, nothing is sent and R's empty value returned. +template +R send(void* receiver, std::string_view name, A... args) { + using function = R (*)(void*, void*, A...); + void* const sender = message_function(); + if (!sender) { + if constexpr (std::is_void_v) return; + else return R{}; + } + return reinterpret_cast(sender)(receiver, selector(name), args...); +} +template +R send(const id& receiver, std::string_view name, A... args) { + return send(receiver.raw(), name, args...); +} + +// The objects autoreleased while this lives are released with it. +class autorelease_pool { +public: + autorelease_pool(); + autorelease_pool(const autorelease_pool&) = delete; + autorelease_pool& operator=(const autorelease_pool&) = delete; + ~autorelease_pool(); + +private: + void* token_ = nullptr; +}; + +// An NSString from UTF-8, and the UTF-8 of one (R7). +[[nodiscard]] id string(std::string_view utf8); +[[nodiscard]] std::string utf8(void* string); +[[nodiscard]] std::string utf8(const id& string); + +// A class made here (a delegate): NSObject's or another's subclass with these +// methods. A method is its selector, a function `R(void* self, void* selector, +// A...)' and its type encoding ("v@:q"). A protocol the runtime does not know +// is passed over (a delegate is asked by its methods). A name already taken: +// exists. +struct method { + std::string_view selector; + void* implementation; + std::string_view types; +}; +result define_class(std::string_view name, std::string_view superclass, std::span methods, + std::span protocols = {}); + +namespace detail { +// What a block holds: the function, counted across the runtime's copies of the block. +struct block_body { + std::atomic references{1}; + void (*destroy)(block_body*) = nullptr; +}; +// The block's layout (the Blocks ABI): a literal, then what it captures. +struct block_literal { + void* isa; + std::int32_t flags; + std::int32_t reserved; + void* invoke; + const void* descriptor; + block_body* body; +}; +id make_block(void* invoke, block_body* body); +} // namespace detail + +// A block (a completion handler, an enumeration's body) that runs `body': a +// copy the runtime holds, which the receiver may keep; `body' goes with the +// last reference to it. It carries no signature (BLOCK_HAS_SIGNATURE), so a +// receiver that reads one (NSInvocation) does not take it; one that calls it, +// as a completion handler is called, does. +template +id block(std::function body) { + struct typed : detail::block_body { + std::function function; + }; + auto* held = new typed{}; + held->function = std::move(body); + held->destroy = [](detail::block_body* self) { delete static_cast(self); }; + R (*invoke)(detail::block_literal*, A...) = [](detail::block_literal* self, A... args) -> R { + return static_cast(self->body)->function(args...); + }; + return detail::make_block(reinterpret_cast(invoke), held); +} + +} // namespace mcpplibs::os::macos::objc diff --git a/src/macos/objc.cpp b/src/macos/objc.cpp new file mode 100644 index 0000000..f92c29e --- /dev/null +++ b/src/macos/objc.cpp @@ -0,0 +1,215 @@ +// The Objective-C runtime (mcpplibs.os.macos, 0.4.4): libobjc's functions and +// libSystem's blocks, found when first used through this package's own +// `library', so that a program links nothing for them and the unit includes +// no header of the SDK. +module mcpplibs.os.macos; + +import std; +import mcpplibs.os.error; +import mcpplibs.os.library; + +namespace mcpplibs::os::macos::objc { + +namespace { + +// The runtime's functions, each found once; all null where nothing loads. +struct runtime_functions { + library::handle objc, system, foundation; + void* (*get_class)(const char*) = nullptr; + void* (*register_selector)(const char*) = nullptr; + void* message = nullptr; + void* (*retain)(void*) = nullptr; + void (*release)(void*) = nullptr; + void* (*pool_push)() = nullptr; + void (*pool_pop)(void*) = nullptr; + void* (*allocate_class)(void*, const char*, std::size_t) = nullptr; + void (*register_class)(void*) = nullptr; + void (*dispose_class)(void*) = nullptr; + bool (*add_method)(void*, void*, void*, const char*) = nullptr; + void* (*get_protocol)(const char*) = nullptr; + bool (*add_protocol)(void*, void*) = nullptr; + void* stack_block = nullptr; // _NSConcreteStackBlock: the class of a block a program lays out itself + void* (*block_copy)(const void*) = nullptr; + std::optional missing; + + runtime_functions() { + if (!library::supported()) { + missing = error{errc::not_supported, "the Objective-C runtime: this program loads no library (a static program)", 0}; + return; + } + auto opened = library::open("/usr/lib/libobjc.A.dylib"); + auto with_blocks = library::open("/usr/lib/libSystem.B.dylib"); + if (!opened || !with_blocks) { + missing = error{errc::not_found, "the Objective-C runtime: libobjc or libSystem did not load", 0}; + return; + } + // Foundation's classes are the runtime's base (NSString, NSArray, NSProcessInfo): loaded with it. + auto base = library::open("/System/Library/Frameworks/Foundation.framework/Foundation"); + if (!base) { + missing = error{errc::not_found, "the Objective-C runtime: Foundation did not load", 0}; + return; + } + objc = std::move(*opened); + system = std::move(*with_blocks); + foundation = std::move(*base); + get_class = objc.function("objc_getClass"); + register_selector = objc.function("sel_registerName"); + message = objc.symbol("objc_msgSend"); + retain = objc.function("objc_retain"); + release = objc.function("objc_release"); + pool_push = objc.function("objc_autoreleasePoolPush"); + pool_pop = objc.function("objc_autoreleasePoolPop"); + allocate_class = objc.function("objc_allocateClassPair"); + register_class = objc.function("objc_registerClassPair"); + dispose_class = objc.function("objc_disposeClassPair"); + add_method = objc.function("class_addMethod"); + get_protocol = objc.function("objc_getProtocol"); + add_protocol = objc.function("class_addProtocol"); + stack_block = system.symbol("_NSConcreteStackBlock"); + block_copy = system.function("_Block_copy"); + if (!get_class || !register_selector || !message || !retain || !release || !pool_push || !pool_pop || !allocate_class || + !register_class || !dispose_class || !add_method || !get_protocol || !add_protocol || !stack_block || !block_copy) { + missing = error{errc::not_found, "the Objective-C runtime: a function it should have is absent", 0}; + message = nullptr; // nothing is sent + } + } +}; + +const runtime_functions& runtime() { + static const runtime_functions found; + return found; +} + +// The Blocks ABI's descriptor of a block with copy and dispose helpers. +struct block_descriptor { + unsigned long reserved, size; + void (*copy)(void*, const void*); + void (*dispose)(const void*); +}; + +void let_go(detail::block_body* body) { + if (body->references.fetch_sub(1, std::memory_order_acq_rel) == 1) body->destroy(body); +} + +// The runtime copied the block's bytes: the copy holds the body too. +void copy_helper(void* destination, const void*) { + static_cast(destination)->body->references.fetch_add(1, std::memory_order_relaxed); +} +void dispose_helper(const void* block) { let_go(static_cast(block)->body); } + +const block_descriptor descriptor{0, sizeof(detail::block_literal), ©_helper, &dispose_helper}; + +constexpr std::int32_t has_copy_dispose = 1 << 25; // BLOCK_HAS_COPY_DISPOSE + +} // namespace + +id::id(const id& other) noexcept : raw_(other.raw_) { + if (raw_ && runtime().retain) runtime().retain(raw_); +} + +id& id::operator=(id other) noexcept { + std::swap(raw_, other.raw_); + return *this; +} + +id::~id() { + if (raw_ && runtime().release) runtime().release(raw_); +} + +id id::adopt(void* raw) noexcept { + id out; + out.raw_ = raw; + return out; +} + +id id::borrow(void* raw) noexcept { + if (raw && runtime().retain) runtime().retain(raw); + return adopt(raw); +} + +result available() { + if (runtime().missing) return std::unexpected(*runtime().missing); + return {}; +} + +result load_framework(std::string_view name) { + if (auto here = available(); !here) return here; + const std::string file = std::format("/System/Library/Frameworks/{0}.framework/{0}", name); + auto opened = library::open(file); + if (!opened) return std::unexpected(error{errc::not_found, std::format("the framework {}", name), opened.error().native}); + // Kept loaded for the program's life: its classes are registered with the runtime. + static std::mutex guard; + static std::vector loaded; + const std::scoped_lock lock(guard); + loaded.push_back(std::move(*opened)); + return {}; +} + +void* class_named(std::string_view name) { + if (!runtime().get_class) return nullptr; + return runtime().get_class(std::string(name).c_str()); +} + +void* selector(std::string_view name) { + if (!runtime().register_selector) return nullptr; + return runtime().register_selector(std::string(name).c_str()); +} + +void* message_function() { return runtime().message; } + +autorelease_pool::autorelease_pool() : token_(runtime().pool_push ? runtime().pool_push() : nullptr) {} + +autorelease_pool::~autorelease_pool() { + if (token_ && runtime().pool_pop) runtime().pool_pop(token_); +} + +id string(std::string_view utf8) { + void* allocated = send(class_named("NSString"), "alloc"); + // initWithBytes:length:encoding: (NSUTF8StringEncoding = 4): a reference this owns. + return id::adopt(send(allocated, "initWithBytes:length:encoding:", static_cast(utf8.data()), + static_cast(utf8.size()), 4UL)); +} + +std::string utf8(void* string) { + if (!string) return {}; + const autorelease_pool pool; // UTF8String's buffer may be autoreleased + const auto length = send(string, "lengthOfBytesUsingEncoding:", 4UL); + const char* text = send(string, "UTF8String"); + return text ? std::string(text, length) : std::string(); +} + +std::string utf8(const id& string) { return utf8(string.raw()); } + +result define_class(std::string_view name, std::string_view superclass, std::span methods, + std::span protocols) { + if (auto here = available(); !here) return std::unexpected(here.error()); + void* parent = class_named(superclass); + if (!parent) return std::unexpected(error{errc::not_found, std::format("the class {}", superclass), 0}); + if (class_named(name)) return std::unexpected(error{errc::exists, std::format("the class {}", name), 0}); + void* made = runtime().allocate_class(parent, std::string(name).c_str(), 0); + if (!made) return std::unexpected(error{errc::exists, std::format("the class {}", name), 0}); + for (const method& each : methods) + if (!runtime().add_method(made, selector(each.selector), each.implementation, std::string(each.types).c_str())) { + runtime().dispose_class(made); // never registered: taken back whole + return std::unexpected(error{errc::invalid, std::format("the method {} of {}", each.selector, name), 0}); + } + for (const std::string_view protocol : protocols) + if (void* known = runtime().get_protocol(std::string(protocol).c_str())) (void)runtime().add_protocol(made, known); + runtime().register_class(made); + return made; +} + +id detail::make_block(void* invoke, block_body* body) { + if (!runtime().block_copy) { + let_go(body); + return {}; + } + // Laid out here as a block on the stack is, then copied by the runtime: the + // copy is the runtime's (its memory, its count), and this one goes. + const block_literal laid{runtime().stack_block, has_copy_dispose, 0, invoke, &descriptor, body}; + void* copy = runtime().block_copy(&laid); + let_go(body); // the copy took its own (copy_helper) + return id::adopt(copy); +} + +} // namespace mcpplibs::os::macos::objc diff --git a/src/windows.cppm b/src/windows.cppm index 440a210..01793aa 100644 --- a/src/windows.cppm +++ b/src/windows.cppm @@ -2,6 +2,7 @@ export module mcpplibs.os.windows; import std; +import mcpplibs.os.error; export namespace mcpplibs::os::windows { @@ -30,3 +31,162 @@ std::optional machine_registry_path(std::wstring_view key bool writable_fixed_drive(char drive, std::wstring_view child); } // namespace mcpplibs::os::windows + +// COM AND THE WINDOWS RUNTIME (0.4.4): the system's object model, through +// which the services that are not functions are reached (the shell's dialogs, +// toast notifications, the Windows Runtime's classes). No other system has it +// (R2). The libraries are found when first used (ole32, combase), so a program +// links nothing for it; an image loads no library of the system, and these are +// not defined there (R5): a program that is to run as an image asks +// `if constexpr (os::is_windows)', the question of a native build. +// +// auto here = com::apartment::enter(com::apartment::kind::multi_threaded); +// auto made = com::factory("Windows.Foundation.Uri", uri_factory); +// void* uri = nullptr; +// made->call(6, com::hstring::of("https://example.org").value().raw(), &uri); // CreateUri +// auto held = com::object::adopt(uri); +// +// A method is called by its slot in the object's table (IUnknown's are 0 to 2, +// IInspectable's 3 to 5), with the arguments its interface states; the result +// a method returns through a pointer is read from that pointer. The calling +// convention is the system's on every Windows target (x64, arm64). +export namespace mcpplibs::os::windows::com { + +// An interface's or a class's identity, as the system lays it out. +struct guid { + std::uint32_t data1 = 0; + std::uint16_t data2 = 0, data3 = 0; + std::array data4{}; +}; +constexpr bool operator==(const guid& left, const guid& right) noexcept { + return left.data1 == right.data1 && left.data2 == right.data2 && left.data3 == right.data3 && left.data4 == right.data4; +} + +namespace detail { +// Not a constant expression: reached while parsing, it makes a malformed text a compile error. +inline void not_a_guid() {} +consteval std::uint32_t hex(std::string_view text) { + std::uint32_t value = 0; + for (const char c : text) { + const int digit = c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : c >= 'A' && c <= 'F' ? c - 'A' + 10 : -1; + if (digit < 0) not_a_guid(); + value = value * 16 + static_cast(digit); + } + return value; +} +} // namespace detail + +// `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', with or without braces, at compile time. +consteval guid id(std::string_view text) { + if (text.size() == 38 && text.front() == '{' && text.back() == '}') text = text.substr(1, 36); + if (text.size() != 36 || text[8] != '-' || text[13] != '-' || text[18] != '-' || text[23] != '-') detail::not_a_guid(); + guid out; + out.data1 = detail::hex(text.substr(0, 8)); + out.data2 = static_cast(detail::hex(text.substr(9, 4))); + out.data3 = static_cast(detail::hex(text.substr(14, 4))); + out.data4[0] = static_cast(detail::hex(text.substr(19, 2))); + out.data4[1] = static_cast(detail::hex(text.substr(21, 2))); + for (std::size_t index = 0; index < 6; ++index) out.data4[2 + index] = static_cast(detail::hex(text.substr(24 + 2 * index, 2))); + return out; +} + +inline constexpr guid unknown = id("00000000-0000-0000-C000-000000000046"); // IUnknown +inline constexpr guid inspectable = id("AF86E2E0-B12D-4C6A-9C5A-D7AA65101E90"); // IInspectable +inline constexpr guid agile = id("94EA2B94-E9CC-49E0-C0FF-EE64CA8F5B90"); // IAgileObject + +// What an HRESULT says: success, or the condition it names (E_NOINTERFACE is +// not_supported, REGDB_E_CLASSNOTREG not_found, E_ACCESSDENIED permission, ...), +// the HRESULT kept as the error's native value. +result checked(std::int32_t status, std::string_view context); + +// The calling thread in an apartment for as long as this lives +// (CoInitializeEx, then CoUninitialize). A thread already in an apartment of +// the same kind enters it again; of the other kind, `invalid'. +class apartment { +public: + enum class kind : std::uint8_t { single_threaded, multi_threaded }; + static result enter(kind which); + + apartment(apartment&& other) noexcept : entered_(std::exchange(other.entered_, false)) {} + apartment& operator=(apartment&& other) noexcept; + apartment(const apartment&) = delete; + apartment& operator=(const apartment&) = delete; + ~apartment(); + +private: + explicit apartment(bool entered) noexcept : entered_(entered) {} + bool entered_ = false; +}; + +// One reference to an object: released with this, taken again by a copy +// (AddRef and Release are paired here, R6). +class object { +public: + object() noexcept = default; + object(const object& other) noexcept; + object(object&& other) noexcept : raw_(std::exchange(other.raw_, nullptr)) {} + object& operator=(object other) noexcept; // out of line: it releases, defined on Windows only (R5) + ~object(); + + // The reference a call gave through its out parameter becomes this one. + static object adopt(void* raw) noexcept; + // A reference of its own to an object another holds (a parameter it was given). + static object borrow(void* raw) noexcept; + + [[nodiscard]] void* raw() const noexcept { return raw_; } + explicit operator bool() const noexcept { return raw_ != nullptr; } + + // The same object as another of its interfaces (QueryInterface). + [[nodiscard]] result query(const guid& interface) const; + + // The method at `slot' of its table, called with `args'. + template + result call(std::size_t slot, A... args) const { + using method = std::int32_t (*)(void*, A...); + if (!raw_) return std::unexpected(error{errc::invalid, "a COM method of no object", 0}); + void* const* table = *static_cast(raw_); + return checked(reinterpret_cast(table[slot])(raw_, args...), "a COM method"); + } + +private: + void* raw_ = nullptr; +}; + +// An instance of a registered class, as one of its interfaces (CoCreateInstance). +result create(const guid& class_id, const guid& interface); + +// The Windows Runtime's string (HSTRING), made from UTF-8 and read back as +// UTF-8 (R7). +class hstring { +public: + hstring() noexcept = default; + hstring(hstring&& other) noexcept : raw_(std::exchange(other.raw_, nullptr)) {} + hstring& operator=(hstring&& other) noexcept; + hstring(const hstring&) = delete; + hstring& operator=(const hstring&) = delete; + ~hstring(); + + static result of(std::string_view utf8); + // The string a method gave through its out parameter becomes this one. + static hstring adopt(void* raw) noexcept; + [[nodiscard]] std::string utf8() const; + [[nodiscard]] void* raw() const noexcept { return raw_; } + +private: + void* raw_ = nullptr; // null: the empty string, as the system has it +}; + +// A Windows Runtime class's instance by its name, made with no arguments +// (RoActivateInstance), as IInspectable. +result activate(std::string_view class_name); +// A Windows Runtime class's factory or its statics, as `interface' +// (RoGetActivationFactory). +result factory(std::string_view class_name, const guid& interface); + +// An object the system calls back: IUnknown, IAgileObject and `interface', +// whose one method beyond IUnknown's is Invoke(sender, args) --- the shape of +// every Windows Runtime event handler and completion. `invoked' runs on the +// thread the system calls on; the two are borrowed for the call. +result delegate(const guid& interface, std::function invoked); + +} // namespace mcpplibs::os::windows::com diff --git a/src/windows/com.cpp b/src/windows/com.cpp new file mode 100644 index 0000000..d0a4b09 --- /dev/null +++ b/src/windows/com.cpp @@ -0,0 +1,233 @@ +// COM and the Windows Runtime (mcpplibs.os.windows, 0.4.4): ole32's and +// combase's functions, found when first used (LoadLibraryW and +// GetProcAddress, as advapi32's are in src/windows/windows.cpp), so that a +// program links nothing beyond kernel32 for them. +module; +#include "win32.h" + +module mcpplibs.os.windows; + +import std; +import mcpplibs.os.error; +import mcpplibs.os.fs; + +#include "text.h" + +namespace mcpplibs::os::windows::com { + +namespace { + +using hresult = std::int32_t; + +constexpr hresult no_interface = static_cast(0x80004002u); // E_NOINTERFACE + +template +F in(const char16_t* library, const char* name) { + const okos_handle module = LoadLibraryW(library); + return module ? reinterpret_cast(GetProcAddress(module, name)) : nullptr; +} + +// The functions, each found once; one a system lacks is null. +struct functions { + hresult (__stdcall* initialize)(void*, okos_dword) = in(u"ole32.dll", "CoInitializeEx"); + void (__stdcall* uninitialize)() = in(u"ole32.dll", "CoUninitialize"); + hresult (__stdcall* create_instance)(const guid*, void*, okos_dword, const guid*, void**) = + in(u"ole32.dll", "CoCreateInstance"); + hresult (__stdcall* activate_instance)(void*, void**) = in(u"combase.dll", "RoActivateInstance"); + hresult (__stdcall* activation_factory)(void*, const guid*, void**) = + in(u"combase.dll", "RoGetActivationFactory"); + hresult (__stdcall* create_string)(const char16_t*, std::uint32_t, void**) = + in(u"combase.dll", "WindowsCreateString"); + hresult (__stdcall* delete_string)(void*) = in(u"combase.dll", "WindowsDeleteString"); + const char16_t* (__stdcall* string_buffer)(void*, std::uint32_t*) = + in(u"combase.dll", "WindowsGetStringRawBuffer"); +}; + +const functions& system() { + static const functions found; + return found; +} + +error absent(std::string_view what) { return error{errc::not_supported, std::format("{}: this system has no such function", what), 0}; } + +// IUnknown's three, the first of every object's table. +void* const* table_of(void* raw) { return *static_cast(raw); } +hresult query_of(void* raw, const guid& interface, void** out) { + return reinterpret_cast(table_of(raw)[0])(raw, &interface, out); +} +void add_ref(void* raw) { (void)reinterpret_cast(table_of(raw)[1])(raw); } +void release(void* raw) { (void)reinterpret_cast(table_of(raw)[2])(raw); } + +// An object of this package's own that the system calls (a delegate). Its +// table is the first word, as the system reads every object's. +struct callback { + void* const* table; + std::atomic references{1}; + guid interface; + std::function invoked; + + static hresult query(void* self, const guid* asked, void** out) { + auto* held = static_cast(self); + if (*asked == unknown || *asked == agile || *asked == held->interface) { + held->references.fetch_add(1, std::memory_order_relaxed); + *out = self; + return 0; + } + *out = nullptr; + return no_interface; + } + static std::uint32_t add(void* self) { return static_cast(self)->references.fetch_add(1, std::memory_order_relaxed) + 1; } + static std::uint32_t drop(void* self) { + auto* held = static_cast(self); + const std::uint32_t left = held->references.fetch_sub(1, std::memory_order_acq_rel) - 1; + if (left == 0) delete held; + return left; + } + static hresult invoke(void* self, void* sender, void* args) { + static_cast(self)->invoked(object::borrow(sender), object::borrow(args)); + return 0; + } +}; + +void* const callback_table[] = {reinterpret_cast(&callback::query), reinterpret_cast(&callback::add), + reinterpret_cast(&callback::drop), reinterpret_cast(&callback::invoke)}; + +} // namespace + +result checked(std::int32_t status, std::string_view context) { + if (status >= 0) return {}; + const auto code = static_cast(status); + errc kind = errc::io; + switch (code) { + case 0x80004002u: case 0x80004001u: kind = errc::not_supported; break; // E_NOINTERFACE, E_NOTIMPL + case 0x80040154u: case 0x80070002u: case 0x80070490u: kind = errc::not_found; break; // REGDB_E_CLASSNOTREG, not found + case 0x80070005u: kind = errc::permission; break; // E_ACCESSDENIED + case 0x80070057u: case 0x80010106u: case 0x800401F0u: kind = errc::invalid; break; // E_INVALIDARG, RPC_E_CHANGED_MODE, CO_E_NOTINITIALIZED + case 0x8007000Eu: kind = errc::no_memory; break; // E_OUTOFMEMORY + default: break; + } + return std::unexpected(error{kind, std::format("{} (HRESULT 0x{:08X})", context, code), status}); +} + +result apartment::enter(kind which) { + if (!system().initialize) return std::unexpected(absent("CoInitializeEx")); + // COINIT_APARTMENTTHREADED 0x2 or COINIT_MULTITHREADED 0x0, and COINIT_DISABLE_OLE1DDE 0x4. + const okos_dword flags = (which == kind::single_threaded ? 0x2u : 0x0u) | 0x4u; + if (auto entered = checked(system().initialize(nullptr, flags), "entering a COM apartment"); !entered) + return std::unexpected(entered.error()); + return apartment(true); // S_OK, or S_FALSE (entered again): either is left once +} + +apartment& apartment::operator=(apartment&& other) noexcept { + if (this != &other) { + if (entered_) system().uninitialize(); + entered_ = std::exchange(other.entered_, false); + } + return *this; +} + +apartment::~apartment() { + if (entered_) system().uninitialize(); +} + +object::object(const object& other) noexcept : raw_(other.raw_) { + if (raw_) add_ref(raw_); +} + +object& object::operator=(object other) noexcept { + std::swap(raw_, other.raw_); + return *this; +} + +object::~object() { + if (raw_) release(raw_); +} + +object object::adopt(void* raw) noexcept { + object out; + out.raw_ = raw; + return out; +} + +object object::borrow(void* raw) noexcept { + if (raw) add_ref(raw); + return adopt(raw); +} + +result object::query(const guid& interface) const { + if (!raw_) return std::unexpected(error{errc::invalid, "an interface of no object", 0}); + void* out = nullptr; + if (auto asked = checked(query_of(raw_, interface, &out), "asking an object for an interface"); !asked) return std::unexpected(asked.error()); + return adopt(out); +} + +result create(const guid& class_id, const guid& interface) { + if (!system().create_instance) return std::unexpected(absent("CoCreateInstance")); + void* out = nullptr; + // CLSCTX_INPROC_SERVER | CLSCTX_LOCAL_SERVER. + if (auto made = checked(system().create_instance(&class_id, nullptr, 0x1u | 0x4u, &interface, &out), "creating a COM object"); !made) + return std::unexpected(made.error()); + return object::adopt(out); +} + +hstring& hstring::operator=(hstring&& other) noexcept { + if (this != &other) { + if (raw_ && system().delete_string) system().delete_string(raw_); + raw_ = std::exchange(other.raw_, nullptr); + } + return *this; +} + +hstring::~hstring() { + if (raw_ && system().delete_string) system().delete_string(raw_); +} + +result hstring::of(std::string_view utf8) { + if (!system().create_string) return std::unexpected(absent("WindowsCreateString")); + const std::u16string wide = utf16_from_utf8(utf8); + void* raw = nullptr; + if (auto made = checked(system().create_string(wide.data(), static_cast(wide.size()), &raw), "making a Windows Runtime string"); !made) + return std::unexpected(made.error()); + return adopt(raw); +} + +hstring hstring::adopt(void* raw) noexcept { + hstring out; + out.raw_ = raw; + return out; +} + +std::string hstring::utf8() const { + if (!raw_ || !system().string_buffer) return {}; + std::uint32_t length = 0; + const char16_t* text = system().string_buffer(raw_, &length); + return text ? utf8_from_utf16(std::u16string_view(text, length)) : std::string(); +} + +result activate(std::string_view class_name) { + if (!system().activate_instance) return std::unexpected(absent("RoActivateInstance")); + auto name = hstring::of(class_name); + if (!name) return std::unexpected(name.error()); + void* out = nullptr; + if (auto made = checked(system().activate_instance(name->raw(), &out), std::format("activating {}", class_name)); !made) + return std::unexpected(made.error()); + return object::adopt(out); +} + +result factory(std::string_view class_name, const guid& interface) { + if (!system().activation_factory) return std::unexpected(absent("RoGetActivationFactory")); + auto name = hstring::of(class_name); + if (!name) return std::unexpected(name.error()); + void* out = nullptr; + if (auto found = checked(system().activation_factory(name->raw(), &interface, &out), std::format("the factory of {}", class_name)); !found) + return std::unexpected(found.error()); + return object::adopt(out); +} + +result delegate(const guid& interface, std::function invoked) { + auto* made = new callback{callback_table, {}, interface, std::move(invoked)}; + made->references.store(1, std::memory_order_relaxed); + return object::adopt(made); +} + +} // namespace mcpplibs::os::windows::com diff --git a/tests/macos_test.cpp b/tests/macos_test.cpp new file mode 100644 index 0000000..8f61089 --- /dev/null +++ b/tests/macos_test.cpp @@ -0,0 +1,76 @@ +// mcpplibs.os.macos: the Objective-C runtime (0.4.4) --- strings in four +// scripts there and back, a framework's class asked and a message sent, a +// class defined here receiving messages, a block called back and let go. A +// program that loads no library (a static one) is told so. Elsewhere, and in +// an image, there is nothing to ask. +import std; +import mcpplibs.os; +import mcpplibs.mtest; + +namespace os = mcpplibs::os; +using namespace mcpplibs::mtest; + +UTest macos_tests{"macos", [] { + if constexpr (os::is_macos) { + namespace objc = os::macos::objc; + + "strings in four scripts there and back; a framework's class asked and a message sent"_test = [] { + if (!os::library::supported()) { + const auto here = objc::available(); + expect(!here && here.error().code == os::errc::not_supported) << "a static program: said, not pretended"; + return; + } + expect(fatal(objc::available().has_value())); + const objc::autorelease_pool pool; + for (const std::string_view text : {"plain", "naïve façade", "Ελληνικά", "日本語のテキスト", "😀 emoji"}) + expect(objc::utf8(objc::string(text)) == text) << text; + expect(objc::load_framework("Foundation").has_value()); + const auto missing = objc::load_framework("McpplibsOsNoSuchFramework"); + expect(!missing && missing.error().code == os::errc::not_found); + void* info = objc::send(objc::class_named("NSProcessInfo"), "processInfo"); + expect(info != nullptr); + const std::string name = objc::utf8(objc::send(info, "processName")); + expect(name.contains("macos_test")) << name; + expect(objc::class_named("McpplibsOsNoSuchClass") == nullptr); + }; + + "a class defined here receives messages; a block is called back, then let go"_test = [] { + if (!os::library::supported()) return; + static long added = 0; + void (*add)(void*, void*, long) = [](void*, void*, long amount) { added += amount; }; + const std::array methods{objc::method{"add:", reinterpret_cast(add), "v@:q"}}; + const auto made = objc::define_class("McpplibsOsCounter", "NSObject", methods); + expect(fatal(made.has_value())) << (made ? std::string() : made.error().message()); + const auto again = objc::define_class("McpplibsOsCounter", "NSObject", methods); + expect(!again && again.error().code == os::errc::exists); + { + const objc::autorelease_pool pool; + const auto counter = objc::id::adopt(objc::send(objc::send(*made, "alloc"), "init")); + objc::send(counter, "add:", 3L); + objc::send(counter, "add:", 4L); + expect(eq(added, 7L)); + expect(objc::send(counter, "respondsToSelector:", objc::selector("add:"))) << "the runtime knows the method"; + } + const auto alive = std::make_shared(0); + const std::weak_ptr watch = alive; + int calls = 0; + unsigned long indices = 0; + { + const objc::autorelease_pool pool; + const std::array strings{objc::string("a"), objc::string("b"), objc::string("c")}; + const std::array objects{strings[0].raw(), strings[1].raw(), strings[2].raw()}; + void* list = objc::send(objc::class_named("NSArray"), "arrayWithObjects:count:", objects.data(), 3UL); + const auto body = objc::block(std::function( + [&, held = alive](void* object, unsigned long index, signed char*) { + ++calls; + indices += index; + expect(objc::utf8(object).size() == 1); + })); + expect(fatal(static_cast(body))); + objc::send(list, "enumerateObjectsUsingBlock:", body.raw()); + } + expect(eq(calls, 3) && eq(indices, 3UL)) << "each element, in order"; + expect(watch.use_count() == 1) << "the block let go with its last reference: only this test's own remains"; + }; + } +}}; diff --git a/tests/windows_test.cpp b/tests/windows_test.cpp new file mode 100644 index 0000000..13b901d --- /dev/null +++ b/tests/windows_test.cpp @@ -0,0 +1,82 @@ +// mcpplibs.os.windows: COM and the Windows Runtime (0.4.4) --- an apartment, +// a Windows Runtime class's factory and an instance it makes, methods called +// by their slots, strings in four scripts there and back, an interface an +// object lacks refused, and a delegate of this program's own called back and +// let go. Elsewhere, and in an image, there is nothing to ask. +import std; +import mcpplibs.os; +import mcpplibs.mtest; + +namespace os = mcpplibs::os; +using namespace mcpplibs::mtest; + +UTest windows_tests{"windows", [] { + if constexpr (os::is_windows) { + namespace com = os::windows::com; + + "an identity read at compile time, laid out as the system's"_test = [] { + constexpr auto unknown = com::id("{00000000-0000-0000-C000-000000000046}"); + static_assert(unknown == com::unknown); + static_assert(com::id("94ea2b94-e9cc-49e0-c0ff-ee64ca8f5b90").data4[7] == 0x90); + expect(eq(sizeof(com::guid), std::size_t{16})); + }; + + "a Windows Runtime class made, its methods called, its strings in four scripts there and back"_test = [] { + auto apartment = com::apartment::enter(com::apartment::kind::multi_threaded); + expect(fatal(apartment.has_value())) << (apartment ? std::string() : apartment.error().message()); + for (const std::string_view text : {"plain", "naïve façade", "Ελληνικά", "日本語のテキスト", "😀 emoji"}) { + auto made = com::hstring::of(text); + expect(made.has_value() && made->utf8() == text) << text; + } + // Windows.Foundation.Uri: IUriRuntimeClassFactory::CreateUri (slot 6), then + // IUriRuntimeClass's Host (6 + 5), RawUri (6 + 10) and Port (6 + 13). + constexpr auto uri_factory = com::id("44A9796F-723E-4FDF-A218-033E75B0C084"); + auto factory = com::factory("Windows.Foundation.Uri", uri_factory); + expect(fatal(factory.has_value())) << (factory ? std::string() : factory.error().message()); + const std::string given = "https://example.org:8080/路径/ψ?q=1"; + auto text = com::hstring::of(given); + expect(fatal(text.has_value())); + void* made = nullptr; + expect(fatal(factory->call(6, text->raw(), &made).has_value())) << "CreateUri"; + const auto uri = com::object::adopt(made); + void* host = nullptr; + expect(uri.call(11, &host).has_value()); + expect(com::hstring::adopt(host).utf8() == "example.org"); + void* raw = nullptr; + expect(uri.call(16, &raw).has_value()); + expect(com::hstring::adopt(raw).utf8() == given) << "the text as given, through the system and back"; + std::int32_t port = 0; + expect(uri.call(19, &port).has_value() && port == 8080); + const auto as_inspectable = uri.query(com::inspectable); + expect(as_inspectable.has_value()) << "every Windows Runtime object is IInspectable"; + const auto lacking = uri.query(com::id("11111111-2222-3333-4444-555555555555")); + expect(!lacking && lacking.error().code == os::errc::not_supported) << "an interface it lacks: not_supported"; + const auto unknown_class = com::activate("Mcpplibs.Os.NoSuchClass"); + expect(!unknown_class && unknown_class.error().code == os::errc::not_found); + }; + + "a delegate of this program's own: asked for, called back with what it is given, let go"_test = [] { + constexpr auto handler = com::id("A4C1F2B3-0D1E-4F5A-8B6C-7D8E9FA0B1C2"); + int called = 0; + bool given_sender = false; + const auto alive = std::make_shared(0); + std::weak_ptr watch = alive; + { + auto made = com::delegate(handler, [&, held = alive](const com::object& sender, const com::object& args) { + ++called; + given_sender = static_cast(sender) && !args; + }); + expect(fatal(made.has_value())); + expect(made->query(com::unknown).has_value() && made->query(com::agile).has_value() && made->query(handler).has_value()); + expect(!made->query(com::inspectable).has_value()) << "only what it is"; + // The system's call: Invoke(sender, args), slot 3; the sender an object of the system's. + auto apartment = com::apartment::enter(com::apartment::kind::multi_threaded); + auto sender = com::factory("Windows.Foundation.Uri", com::id("44A9796F-723E-4FDF-A218-033E75B0C084")); + expect(fatal(sender.has_value())); + expect(made->call(3, sender->raw(), static_cast(nullptr)).has_value()); + expect(eq(called, 1) && given_sender); + } + expect(watch.use_count() == 1) << "let go with its last reference: only this test's own remains"; + }; + } +}};