diff --git a/.agents/ARCHITECTURE.md b/.agents/ARCHITECTURE.md index a5804627..5d3412ce 100644 --- a/.agents/ARCHITECTURE.md +++ b/.agents/ARCHITECTURE.md @@ -25,8 +25,11 @@ crates/ │ ├── anim.rs Keyed animation pool │ ├── physics.rs Spring physics for smooth animations │ ├── persistence.rs Config parse/migrate/atomic write (the config path is injected) +│ ├── plugin_settings.rs Plugin settings page model │ └── widgets.rs Plugin widget model (PluginWidget, WidgetManager) -├── winisland-plugin-api/ Plugin C ABI types + optional packager +├── winisland-plugin-api/ ABI v2 types, draw protocol, SDK, and optional packager +├── winisland-plugin-package/ Manifest, ZIP activation, signing, and marketplace catalog +├── winisland-plugin-host/ Per-instance service tables, loader, lifecycle, resource registry, draw validation and replay ├── winisland-render/ Rendering values, Painter, images, text, D3D12 targets, and frame lifecycle ├── winisland-platform/ OS-neutral capability traits, window/event contracts, and value types; no dependencies or unsafe └── winisland-platform-windows/ Windows window/event loop, backdrop, shell, metrics, display, audio, media, notification, and input implementations @@ -35,14 +38,9 @@ src/ Application crate "WinIsland"; it depends on winisland-core ├── core/ Application-side scheduling and state │ ├── audio.rs FFT spectrum and capture scheduling through AudioProvider │ ├── persistence.rs Config path adapter — resolves ~/.winisland/config.toml, forwards to winisland-core -│ ├── plugin_settings.rs Plugin settings page model │ └── smtc.rs Media state, lyrics, selection, and polling through MediaProvider ├── icons/ Custom vector path icons (arrows, controls, music, settings) -├── plugin/ Native plugin system -│ ├── loader.rs NativePlugin — wraps DLL via libloading, C ABI vtable -│ ├── manager.rs PluginManager — RwLock registry, discover/install/unload -│ ├── types.rs Host-side Rust types mirroring C ABI structs -│ └── zip_loader.rs Plugin package extraction + manifest validation +├── plugin/inventory.rs Installed-plugin list, enable state, and file removal for settings UI ├── ui/island.rs Main draw_island() composition and island views ├── ui/expanded/ Expanded island views │ ├── music_view.rs Music player page (album art, controls, progress) @@ -60,7 +58,8 @@ src/ Application crate "WinIsland"; it depends on winisland-core └── window/ ├── app.rs Main App state, input, frame scheduling, and orchestration ├── app/events.rs AppHandler implementation consuming PlatformEvent - ├── app/system.rs Tray polling and shell notifications through platform traits + ├── app/system.rs Tray, plugin installation, and shell notifications + ├── app/v2.rs ABI v2 resource snapshots and prepared widget frames └── settings/ Separate settings window ``` @@ -111,8 +110,9 @@ Each style draws its background differently: - **default**: Solid black D3D12 is the only rendering backend. `winisland-render` owns Skia, image handles, font caches, -the D3D12 device, and frame presentation. The plugin ABI v1 adapter retains a hidden Skia -re-export until its drawing bridge is replaced. Each frame starts with an unclipped transparent clear and +the D3D12 device, and frame presentation. Plugin drawing reaches it only through validated ABI v2 +draw lists replayed by `winisland-plugin-host`; plugin callbacks run on their worker threads. +Each frame starts with an unclipped transparent clear and isolates the drawing callback's canvas state. Resizing waits for GPU work and releases back-buffer references before calling ResizeBuffers. Renderer failures invalidate both windows' GPU caches and recreate their targets together. The companion backdrop window remains independent. @@ -133,44 +133,49 @@ and recreate their targets together. The companion backdrop window remains indep ## Plugin system -Plugins are trusted native DLLs loaded via `libloading` with versioned C ABI v1: - -``` -DLL exports: winisland_plugin_entry_v1() -> *const PluginDescriptorV1 - -PluginDescriptorV1: - ABI version + struct size - metadata: PluginMetadataC (id, name, version, author, description) - capability bitset (Context, Media, I18n, HostState, Widget, LyricsTransform) - create(create_info, out_handle) -> PluginResultC - shutdown(handle) -> PluginResultC - destroy(handle) - -PluginCreateInfoV1: - host-issued PluginToken - HostApiV1 with query_interface() - -Host services issue ResourceId values. Context, Media, translation, and Widget -resources are owned by PluginToken, validated on every operation, and revoked -after a successful shutdown. Plugins may call host services from worker threads; -resource changes wake the platform event loop. shutdown must stop and join all plugin -threads before the DLL can be destroyed and unloaded. - -LyricsTransform resources register bounded UTF-8 line callbacks. The host runs -them once after lyrics are fetched and preserves word-synchronised timing byte -boundaries when the transformed Unicode character count is unchanged. - -Widget rendering is synchronous and render-thread only: `draw_widget_page` -(src/ui/expanded/widget_view.rs) places plugin widgets into free grid slots and -invokes their `on_draw` callback on every frame. The plugin draws exclusively -through the host-provided `DrawApiV1` drawing operations (src/plugin/manager.rs) — logical -coordinates relative to the slot, host-applied scale/alpha, and a plugin-local -transform stack — so plugins never touch the host Skia canvas directly. -``` - -Plugin packages are `.zip` files with a YAML manifest, one declared entry DLL, -optional dependencies/assets, and optional signature metadata. Installation uses -bounded staging extraction and backup/rollback directory activation. +Plugins are trusted in-process DLLs loaded by `winisland-plugin-host` through +`libloading`. Each DLL exports `winisland_plugin_entry_v2()`, returning a +`PluginDescriptorV2` with metadata, capabilities, `create`, `shutdown`, +`destroy`, and optional `on_tick`. The descriptor receives a host-issued token +and an instance-owned `PluginHostV2` table. Its `query_interface` exposes eleven +capability-gated tables: Context, Media, I18n, HostState, Widget, +LyricsTransform, Settings, Text, Image, Store, and Log. The wire contract lives +in `winisland-plugin-api`; the host owns the registry, resource table, and +implementation. `winisland-plugin-api` has no default external dependencies. + +`App` creates one `PluginHost` and loads enabled ABI v2 plugins on startup. +`src/plugin/inventory.rs` supplies the settings list and enable/uninstall file +operations. ZIP extraction, manifest validation, marketplace data, signing, +staging, and backup/rollback activation live in `winisland-plugin-package`. +Installation validates `abi-version: 2` and DLL descriptor metadata before +activation. V1 has no runtime compatibility path. + +Plugin resources belong to their token. Host services validate capability, +ownership, generation, and quotas; shutdown revokes the token's resources. +The host's worker runs tick, media-command, host-state, settings-change, and +lyric-transform callbacks. Lyrics are transformed after fetch and retain word +timing boundaries only if the replacement has the same character count. +Context, media, settings, and widget snapshots feed the existing application +models. Album art is decoded and supplied through the Image service. + +A widget worker submits a complete draw list. The host validates the entire +list, resolves owned images, and prepares immutable drawing commands before +rendering. `src/ui/expanded/widget_view.rs` and the settings preview replay +those commands with host-side clipping, scaling, and alpha. No plugin callback +runs on the render thread. Invalid lists are rejected; repeated malformed +widget frames can disable that widget. The widget's logical size comes from +the configured expanded grid; collapse animation scales the replay without +changing that size, so text layout remains stable in the settings preview. + +Unload joins the host worker, calls plugin `shutdown`, then `destroy`, and only +then unloads the DLL. A failed shutdown, including cleanup of a partial +`create`, keeps the DLL and host service tables allocated until process exit so +remaining plugin threads can finish safely. +The plugin must join its own threads before reporting successful shutdown. +Release builds still use `panic = "abort"`: a panic in an `extern "C"` plugin callback can +terminate the process. The host writes an active-plugin marker before callbacks; +on the next start it disables plugins named by leftover markers and reports +them. The first process still exits on callback panic. --- diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index d6707dc4..31603b82 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -1,4 +1,4 @@ -name: Publish plugin API crate +name: Publish plugin crates on: push: @@ -9,6 +9,8 @@ on: - crates/winisland-plugin-api/src/** - crates/winisland-plugin-api/README.md - crates/winisland-plugin-api/ChangeLog.md + - crates/winisland-plugin-package/Cargo.toml + - crates/winisland-plugin-package/src/** - .github/workflows/publish-crates.yml workflow_dispatch: inputs: @@ -23,14 +25,69 @@ concurrency: env: CARGO_TERM_COLOR: always - PACKAGE: winisland-plugin-api jobs: - publish: + publish-package: if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/master' runs-on: ubuntu-latest permissions: contents: read + env: + PACKAGE: winisland-plugin-package + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Read package version + id: package + shell: bash + run: | + VERSION=$(cargo metadata --format-version 1 --no-deps | \ + python3 -c "import json, os, sys; data=json.load(sys.stdin); print(next(p['version'] for p in data['packages'] if p['name'] == os.environ['PACKAGE']))") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Check crates.io + id: registry + shell: bash + env: + VERSION: ${{ steps.package.outputs.version }} + run: | + STATUS=$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --connect-timeout 10 --max-time 30 --retry 3 --retry-delay 2 --retry-all-errors \ + --user-agent "WinIsland publish workflow ($GITHUB_SERVER_URL/$GITHUB_REPOSITORY)" \ + --header 'Accept: application/json' \ + "https://crates.io/api/v1/crates/$PACKAGE/$VERSION") + if [ "$STATUS" = "200" ]; then + echo "Version $VERSION is already published; skipping publish." + echo "published=true" >> "$GITHUB_OUTPUT" + elif [ "$STATUS" = "404" ]; then + echo "published=false" >> "$GITHUB_OUTPUT" + else + echo "Unable to verify crates.io version status (HTTP $STATUS)." + exit 1 + fi + + - name: Validate package + if: steps.registry.outputs.published == 'false' + run: cargo package -p "$PACKAGE" + + - name: Publish plugin package + if: steps.registry.outputs.published == 'false' + run: cargo publish -p "$PACKAGE" + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + publish-api: + needs: publish-package + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + permissions: + contents: read + env: + PACKAGE: winisland-plugin-api steps: - name: Check out repository uses: actions/checkout@v4 @@ -83,9 +140,20 @@ jobs: - name: Validate package if: steps.registry.outputs.published == 'false' - run: cargo package -p "$PACKAGE" + shell: bash + run: | + for attempt in {1..10}; do + if cargo package -p "$PACKAGE"; then + exit 0 + fi + echo "Waiting for the package dependency to reach the crates.io index ($attempt/10)" + if [ "$attempt" -lt 10 ]; then + sleep 15 + fi + done + exit 1 - - name: Publish package + - name: Publish plugin API if: steps.registry.outputs.published == 'false' run: cargo publish -p "$PACKAGE" env: diff --git a/Cargo.lock b/Cargo.lock index 73058b9b..333dafb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,8 @@ dependencies = [ "winisland-platform", "winisland-platform-windows", "winisland-plugin-api", + "winisland-plugin-host", + "winisland-plugin-package", "winisland-render", "winres", "zip", @@ -5219,19 +5221,46 @@ dependencies = [ [[package]] name = "winisland-plugin-api" -version = "0.7.0" +version = "0.8.0" dependencies = [ "ed25519-dalek 2.2.0", - "hex", "libloading 0.9.0", "log", + "tempfile", + "toml 1.1.4+spec-1.1.0", + "winisland-plugin-package", +] + +[[package]] +name = "winisland-plugin-host" +version = "1.3.9" +dependencies = [ + "libloading 0.9.0", + "log", + "sha2 0.10.9", + "winisland-core", + "winisland-plugin-api", + "winisland-plugin-package", + "winisland-render", +] + +[[package]] +name = "winisland-plugin-package" +version = "0.8.0" +dependencies = [ + "base64 0.23.1", + "dirs", + "ed25519-dalek 2.2.0", + "hex", + "image", + "log", + "reqwest", "serde", "serde_json", "serde_yaml", "sha2 0.11.0", - "tempfile", "thiserror 2.0.20", - "toml 1.1.4+spec-1.1.0", + "tokio", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index 9793a6b3..ebb14f48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition = "2024" [workspace] -members = ["crates/winisland-core", "crates/winisland-plugin-api", "crates/winisland-render", "crates/winisland-platform", "crates/winisland-platform-windows"] +members = ["crates/winisland-core", "crates/winisland-plugin-api", "crates/winisland-plugin-package", "crates/winisland-plugin-host", "crates/winisland-render", "crates/winisland-platform", "crates/winisland-platform-windows"] [workspace.package] version = "1.3.9" @@ -34,6 +34,8 @@ libloading = "0.9" log = { version = "0.4", features = ["std"] } lrc = "0.2.0" winisland-plugin-api = { path = "crates/winisland-plugin-api" } +winisland-plugin-package = { path = "crates/winisland-plugin-package", features = ["marketplace"] } +winisland-plugin-host = { path = "crates/winisland-plugin-host" } winisland-core = { path = "crates/winisland-core" } winisland-render = { path = "crates/winisland-render" } winisland-platform = { path = "crates/winisland-platform" } diff --git a/Page/plugin-dev.md b/Page/plugin-dev.md index 91cdc3d9..0429ed96 100644 --- a/Page/plugin-dev.md +++ b/Page/plugin-dev.md @@ -1,79 +1,61 @@ # Plugin development -WinIsland plugin API `0.6` publishes native ABI v1. A plugin is a Windows DLL loaded directly into the WinIsland process. It can publish compact contexts and configurable widgets, replace the displayed media source, transform parsed lyrics, register translations, and inspect the current host state. +WinIsland loads trusted Windows DLLs using ABI v2. The current `winisland-plugin-api` crate is `0.8`. ABI v1 packages and entry points are rejected. Plugins can provide contexts, media sources, widgets, translations, lyric transforms, settings pages, and persistent values. -> Plugins are trusted native code. There is no sandbox, process boundary, permission prompt, or crash isolation. Install and distribute plugins with the same care as desktop executables. +> Plugins run inside WinIsland without a sandbox. A panic in an `extern "C"` callback can terminate the app. -The old `0.2` `PluginVTable`, `PluginType`, `plugin_get_instance`, and `plugin_set_host_api` interfaces are not supported by ABI v1. +## Guides -## Documentation map - -| Guide | Use it for | +| Guide | What it covers | |---|---| -| [Quickstart](/plugin-dev/quickstart) | Create, build, and load a complete Context plugin | -| [ABI and lifecycle](/plugin-dev/abi-lifecycle) | Descriptor validation, capabilities, threads, FFI rules, shutdown, and 0.2 migration | -| [Host services](/plugin-dev/services) | Context, Media, lyrics transformation, i18n, Host State, resource limits, and callback behavior | -| [Packaging and installation](/plugin-dev/packaging) | `PluginPackager`, `plugin.yml`, ZIP validation, updates, rollback, and troubleshooting | -| [API changelog](/api-changelog) | Published API versions and breaking changes | +| [Quickstart](/plugin-dev/quickstart) | Build, load, and package an ABI v2 plugin | +| [ABI and lifecycle](/plugin-dev/abi-lifecycle) | Descriptor validation, ownership, callbacks, shutdown, and migration | +| [Host services](/plugin-dev/services) | All eleven service tables, drawing, settings, and limits | +| [Packaging and installation](/plugin-dev/packaging) | `plugin.yml`, ZIPs, signing, installation, and updates | +| [API changelog](/api-changelog) | Historical published crate release notes | -Start with the quickstart even if you intend to build a Media or i18n plugin. It establishes the entry descriptor and lifecycle contract that every plugin must implement. +See the [SDK README](https://github.com/WinIslandProject/WinIsland/tree/master/crates/winisland-plugin-api) and [ABI definitions](https://github.com/WinIslandProject/WinIsland/tree/master/crates/winisland-plugin-api/src/abi) for exact Rust signatures. -## Runtime architecture +## Runtime model ```text -plugin DLL exports winisland_plugin_entry_v1() - -> PluginDescriptorV1 - -> WinIsland validates ABI, capabilities, metadata, and callbacks - -> WinIsland issues PluginToken and calls create(PluginCreateInfoV1) - -> plugin queries versioned HostApiV1 service tables - -> plugin creates host-owned resources identified by ResourceId - -> WinIsland calls shutdown(handle) - -> WinIsland revokes remaining resources - -> WinIsland calls destroy(handle) and unloads the DLL +DLL exports winisland_plugin_entry_v2() -> static PluginDescriptorV2 + -> host validates ABI, capabilities, callbacks, and metadata + -> host calls create(PluginCreateInfoV2) with token and PluginHostV2 + -> plugin queries versioned service tables and creates resources + -> optional descriptor.on_tick runs on a plugin worker + -> widget submits a complete draw list; host validates and replays it + -> host calls shutdown(handle), then destroy(handle), then unloads DLL ``` -The lifecycle is strictly `create -> shutdown -> destroy`. `shutdown` must synchronously stop every worker and join every thread that can execute plugin code. WinIsland does not call `destroy` or unload the DLL when `shutdown` reports an error. - -## Choose capabilities deliberately +`PluginDescriptorV2.capabilities` declares access to services. Every resource belongs to a host-issued `PluginToken`. Service tables use `PluginHostV2.query` and `IFACE_VERSION_1`, a separate version from `ABI_VERSION_2`. -`PluginDescriptorV1.capabilities` is both a declaration and an authorization boundary. Declare only services the plugin uses. - -| Capability | Service | Typical use | +| Capability | Table | Purpose | |---|---|---| -| `CAPABILITY_CONTEXT` | `ContextApiV1` | Build status, timers, ongoing activities, compact text | -| `CAPABILITY_MEDIA` | `MediaApiV1` | A custom now-playing source and optional playback controls | -| `CAPABILITY_I18N` | `I18nApiV1` | Plugin-owned translation keys for supported languages | -| `CAPABILITY_HOST_STATE` | `HostStateApiV1` | Read the displayed media and current light/dark theme | -| `CAPABILITY_WIDGET` | `WidgetApiV1` | Render widgets managed by the Settings layout editor | -| `CAPABILITY_LYRICS_TRANSFORM` | `LyricsTransformApiV1` | Transform parsed lyric text while preserving timing | - -Querying a service does not grant access by itself. Every resource call also carries the host-issued `PluginToken`, and the host rejects calls made without the declared capability. - -## Ownership model - -- WinIsland issues one nonzero `PluginToken` per loaded instance. -- Service create/register calls issue nonzero `ResourceId` values. -- A token can update or release only its own resources of the correct service type. -- Plugins should release resources during `shutdown`; WinIsland revokes leftovers after successful shutdown. -- Worker threads may call host services. Resource changes wake the WinIsland event loop. -- A DLL and its function pointers must remain valid until shutdown completes and all callbacks have returned. - -## Development workflow +| `CAP_CONTEXT` | `ContextApiV2` | Activity text | +| `CAP_MEDIA` | `MediaApiV2` | Now-playing source, cover, and controls | +| `CAP_I18N` | `I18nApiV2` | Translation bundles | +| `CAP_HOST_STATE` | `HostStateApiV2` | Media/theme snapshot and subscriptions | +| `CAP_WIDGET` | `WidgetApiV2` | Widgets and draw-list submission | +| `CAP_LYRICS` | `LyricsTransformApiV2` | Parsed lyric transforms | +| `CAP_SETTINGS` | `SettingsApiV2` | Declarative settings pages | +| `CAP_TEXT` | `TextApiV2` | Text measurement and font families | +| `CAP_IMAGE` | `ImageApiV2` | Images and current album art | +| `CAP_STORE` | `StoreApiV2` | Plugin-scoped persistent bytes | -1. Define a `cdylib` crate and depend on `winisland-plugin-api = "0.6"`. -2. Export one `winisland_plugin_entry_v1` function returning a static descriptor. -3. Validate `PluginCreateInfoV1`, query declared services, and return an opaque instance handle. -4. Keep all host-issued resource IDs in plugin-owned state. -5. Stop workers and release resources in `shutdown`, then free only plugin memory in `destroy`. -6. Run `cargo check`, strict Clippy, and `cargo build --release`. -7. Package one entry DLL plus optional dependencies/assets and install the ZIP by dropping it onto WinIsland. +`LogApiV2` is available without a capability bit. Declare only what the plugin uses. -## Compatibility contract +## Development flow -Crate version `0.6.x` exposes ABI version `1`. Runtime compatibility is selected by `ABI_VERSION_1`, each structure's `struct_size`, and service table versions, not by Rust crate metadata at DLL load time. +1. Create a Rust `cdylib` with `winisland-plugin-api = "0.8"`. +2. Export `winisland_plugin_entry_v2` with a static `PluginDescriptorV2`. +3. Validate `PluginCreateInfoV2` in `create` and use SDK `Host::from_raw` or raw service tables. +4. Keep resource handles until `shutdown`; release them while host tables are valid. +5. Stop and join plugin workers before successful `shutdown`; free the opaque instance in `destroy`. +6. Package a ZIP with root-level `plugin.yml`, `abi-version: 2`, and the declared DLL. Drop it onto the island to install or update. -All public ABI structures use `#[repr(C)]`. Plugins should initialize versioned structures with `Default` where available and must not assume fields beyond the advertised `struct_size` exist. A new incompatible ABI requires a new entry symbol and ABI version rather than changing ABI v1 in place. +The SDK wraps common calls and builds draw lists. Advanced controls and settings changes use the raw ABI. Plugin drawing does not run on the render thread. -## Where to look next +## Compatibility -Build the [minimal plugin](/plugin-dev/quickstart), then read [ABI and lifecycle](/plugin-dev/abi-lifecycle) before adding worker threads or callbacks. The [service reference](/plugin-dev/services) documents exact limits and ownership rules, while [packaging and installation](/plugin-dev/packaging) covers distribution and update failures. +Crate `0.8`, top-level `ABI_VERSION_2`, and service-table `IFACE_VERSION_1` are different version numbers. Check `struct_size` and `version` before reading a table. ABI v1 DLLs require source migration and repackaging; changing `plugin.yml` alone cannot convert one. diff --git a/Page/plugin-dev/abi-lifecycle.md b/Page/plugin-dev/abi-lifecycle.md index df0415ad..35d5f85e 100644 --- a/Page/plugin-dev/abi-lifecycle.md +++ b/Page/plugin-dev/abi-lifecycle.md @@ -1,193 +1,62 @@ # ABI and lifecycle -ABI v1 is a native, in-process contract. The host and plugin exchange only C-compatible values, opaque handles, copied service tables, and borrowed pointers with documented lifetimes. Correct shutdown is part of memory safety: unloading a DLL while one of its threads or callbacks is executing would jump into unmapped code. +ABI v2 is a native, in-process contract. Only C-compatible values, sized structures, opaque handles, and borrowed byte ranges cross the boundary. The host cannot make an invalid native pointer safe; the plugin must keep every pointer valid for its documented lifetime. -## Entry point and descriptor +## Entry and descriptor -Every ABI v1 plugin exports exactly this symbol: +Export `winisland_plugin_entry_v2` and return an immutable descriptor that remains live until the DLL unloads: ```rust +/// # Safety +/// WinIsland calls this symbol using the ABI v2 entry signature. #[unsafe(no_mangle)] -pub unsafe extern "C" fn winisland_plugin_entry_v1() -> *const PluginDescriptorV1 { +pub unsafe extern "C" fn winisland_plugin_entry_v2() -> *const PluginDescriptorV2 { &DESCRIPTOR } ``` -The returned descriptor must remain readable and unchanged for the complete DLL lifetime. A `static` descriptor is the normal implementation. +`PluginDescriptorV2` contains `struct_size`, `abi_version = ABI_VERSION_2`, capabilities, `PluginMetadataC`, required `create`/`shutdown`/`destroy`, and optional `on_tick`. The host rejects a missing or null entry, short descriptor, wrong ABI, unknown capability bits, missing lifecycle callback, or invalid plugin ID. A packaged DLL's ID, name, version, author, and description must match `plugin.yml`. -WinIsland rejects a descriptor when: +## Creation and service lookup -- the entry symbol is missing or returns null; -- `struct_size` is smaller than the ABI v1 descriptor prefix; -- `abi_version` is not `ABI_VERSION_1`; -- unknown capability bits are set; -- `create`, `shutdown`, or `destroy` is absent; -- the plugin ID is empty or contains characters outside `[a-zA-Z0-9_-]`; -- a package manifest is present and its ID, name, version, author, or description differs from the DLL descriptor. +The host gives `create` a `PluginCreateInfoV2` with a nonzero `PluginToken` and an instance-owned `PluginHostV2`. Validate both pointers, `struct_size`, `abi_version`, and token before use. `Host::from_raw(info.host_api, info.plugin_token)` performs host-table checks for SDK users. -Fields after a known `struct_size` prefix may be added in a compatible future revision. A plugin must never read fields beyond the size supplied by the other side. +Raw callers use `PluginHostV2.query(context, interface_id, IFACE_VERSION_1)`. Every returned table begins with `TablePrefix { struct_size, version, context }`. Validate the prefix and required optional function slots. Declaring a capability permits a service call; merely obtaining a table does not. The log table needs no capability bit. -## Capability negotiation +A successful `create` writes one non-null `PluginHandleV2` and returns `PluginStatus::Ok`. If creation fails with a non-null partial handle, the host calls `shutdown` and then `destroy` if shutdown succeeds. If partial cleanup fails, the DLL and its host tables remain allocated until process exit. Return a null handle when there is no initialized state to clean. -Set capability bits in `PluginDescriptorV1` before loading: +## Resource ownership -```rust -capabilities: CAPABILITY_CONTEXT | CAPABILITY_MEDIA, -``` - -During `create`, copy the service tables you need: - -```rust -let host = unsafe { &*info.host_api }; -let context = unsafe { host.context_api() }; -let media = unsafe { host.media_api() }; -``` - -The helpers validate the host ABI header, call `query_interface`, and validate the returned service table's size and version. They return `None` if any part is unavailable. Function slots are still optional, so validate each required slot before creating plugin state. - -Declaring a capability does not require every function to be used. Failing to declare it causes corresponding host calls to return an error even if the table was queried successfully. - -## Create contract - -The host registers the plugin token before calling `create`, so host service calls are valid during initialization. - -`create` must: - -1. Reject null `create_info` and `out_handle` pointers. -2. Check the `PluginCreateInfoV1` prefix size and ABI version. -3. Reject null `host_api` and `INVALID_ID` tokens. -4. Query and validate every required service and function slot. -5. Construct all state needed by callbacks and worker threads. -6. Write one non-null opaque handle to `out_handle` and return `PluginResultC::ok()`. - -An `Ok` result with a null handle is invalid and the plugin is rejected. - -If initialization fails before a handle exists, leave `out_handle` null and return an error. If partial initialization requires cleanup, the plugin may write a cleanup handle and return an error. WinIsland then follows the normal `shutdown -> destroy` cleanup sequence for that handle. Do not publish a handle unless `shutdown` knows how to clean every initialized field. - -`PluginResultC::err` copies an error message into a fixed UTF-8-safe buffer. Return actionable errors; they are included in the WinIsland log and installation failure message. - -## Opaque instance handle - -`PluginHandle` is `*mut c_void`. WinIsland stores it but never dereferences it. A common Rust representation is: - -```rust -let instance = Box::new(Instance { /* ... */ }); -unsafe { out_handle.write(Box::into_raw(instance).cast()) }; -``` - -Borrow it without taking ownership during callbacks and shutdown: - -```rust -let instance = unsafe { &mut *handle.cast::() }; -``` - -Take ownership exactly once in `destroy`: - -```rust -unsafe { drop(Box::from_raw(handle.cast::())) }; -``` - -Never reconstruct a `Box` in `shutdown`; shutdown may return an error and be retried, and WinIsland still needs the handle for `destroy` after a later success. - -## Shutdown contract - -WinIsland marks the plugin as stopping before entering `shutdown`. New Media command dispatch is rejected, and unload is rejected while a Media or Lyrics Transform callback is in flight. - -`shutdown` must perform work in this order: - -1. Signal every plugin worker to stop. -2. Join every thread that can execute plugin code, invoke a plugin callback, or call a host service. -3. Prevent external callbacks from retaining plugin function or data pointers. -4. Release host resources using their original token and resource IDs. -5. Return success only when no plugin code can run asynchronously. - -The function must be safe to retry if a previous shutdown returned an error. WinIsland keeps the DLL and handle loaded after failure and may attempt unload again later. Keep enough state to distinguish already-stopped workers and already-released resources. - -Do not wait for a worker while holding a mutex that the worker needs to exit. A typical pattern is to take the join handle out of plugin state, release the state lock, then join. - -If a host resource release fails, either retain its ID and return the error for a retry, or prove that continuing is safe. Do not mark an ID invalid before a successful release. - -## Destroy contract - -`destroy` is called once after shutdown succeeds. It has no result channel and should only release plugin-owned memory that remains in the opaque instance. - -At this point: - -- workers are joined; -- callbacks can no longer enter the plugin; -- host resources have been explicitly released or revoked by WinIsland; -- calling host services is unnecessary and should be avoided. - -After `destroy` returns, WinIsland unloads the library. Every pointer to plugin code, static data, vtables, thread-local state, or callback data becomes invalid. - -## Threading and callbacks - -Host resource functions are synchronized and may be called from plugin worker threads. They copy borrowed inputs before returning and wake the WinIsland event loop when visible state changes. - -Media command callbacks are different: - -- WinIsland calls them synchronously on its event-loop thread. -- `callback_data` must remain valid until the Media resource is successfully released. -- A callback may call host services. -- Updating or releasing the same Media resource while its callback is active returns an error. -- A callback should enqueue work and return quickly; blocking it stalls WinIsland input and rendering. - -Do not call plugin UI or framework code that assumes the callback runs on a worker thread. If the plugin needs asynchronous work, copy the command into a channel owned by a worker that shutdown can stop and join. - -Lyrics Transform callbacks run synchronously on a lyrics-fetch worker, twice per parsed line: a -size query followed by the actual write. Their callback data must remain valid until release -succeeds. Keep conversion bounded, thread-safe, and deterministic between both calls. Release and -unload are rejected while a lyric callback is active. - -## FFI data rules - -- Every public ABI structure is `#[repr(C)]`. -- Initialize versioned input/output structures with `Default` when available. -- Required pointers must be non-null, correctly aligned, and readable or writable for the entire synchronous call. -- Fixed byte arrays are NUL-terminated UTF-8 fields; `str_to_fixed` truncates without splitting a UTF-8 code point. -- `ByteSliceV1` and `Utf8SliceV1` are borrowed `(ptr, len)` ranges, not NUL-terminated strings. -- Borrowed slices need to live only until the host function returns, unless a field explicitly states otherwise. -- Do not pass Rust references, `String`, `Vec`, trait objects, unwinding panics, or compiler-specific layouts across the ABI. -- Do not allow panic to unwind through an `extern "C"` boundary. Catch it inside the plugin or use an aborting panic strategy. +`PluginToken`, `ResourceId`, `WidgetId`, and `ImageId` are opaque identities. Each resource belongs to the token that created it; stale, foreign, or wrong-kind handles are rejected. The host copies borrowed request data during synchronous service calls. The SDK's `Resource`, `Widget`, and `ImageHandle` wrappers release resources on drop. Drop them before `shutdown` returns; raw callers must release their IDs explicitly. The host revokes leftovers after successful shutdown. -The trusted-plugin model cannot validate whether a non-null plugin pointer actually refers to enough accessible memory. Pointer validity remains the plugin's responsibility. +`PluginStatus` values include `Ok`, `InvalidArgument`, `StaleHandle`, `CapabilityMissing`, `LimitExceeded`, `UnsupportedVersion`, `IoError`, and `Internal`. A successful draw-list submission only confirms copying; validation and rendering occur later. -## Versioning strategy +## Tick, callbacks, and shutdown -There are three related versions: +The host invokes optional `PluginDescriptorV2.on_tick(handle, widget_id, dt_seconds)` on the plugin worker, never on the render thread. Widget drawing is submitted as bytes through `WidgetApiV2.submit_draw_list`. The host validates and replays complete lists. Media commands, host-state notifications, settings changes, and lyric transforms also use host-managed callback dispatch; callback data must remain alive until release is safe. A lyric transform receives a size query followed by a write call, so both passes must agree. -| Value | Meaning | -|---|---| -| crate `0.6.x` | Rust package release containing ABI v1 definitions | -| `ABI_VERSION_1` | Top-level descriptor and create-info ABI | -| `INTERFACE_VERSION_1` | Version of an individual host service table | +`shutdown` must stop and join every plugin-owned thread, finish callback activity, and release resources before returning `Ok`. Design it to tolerate a retry. `destroy` is called once after successful shutdown and frees the opaque instance. The DLL unloads only after that. If shutdown fails, the host retains the DLL and service tables through process exit. It cannot detect a plugin thread that falsely reports completion. -A compatible service-table extension appends fields and increases `struct_size` without changing the v1 prefix. An incompatible layout or lifecycle change requires a new ABI number and entry symbol. +Release builds use aborting panics. A panic inside a plugin C callback can terminate the process. On the next start, WinIsland detects an active-plugin marker, disables the named plugin, and shows a recovery notice. It cannot keep the first process alive after such a panic. -## Migrating from 0.2 +## FFI rules -ABI v1 is a rewrite, not an in-place upgrade. +- Use the ABI's `#[repr(C)]` types and exact callback signatures. Never pass Rust `String`, `Vec`, references, trait objects, or unwinding panics across the boundary. +- Fixed metadata buffers are NUL-terminated UTF-8. `Utf8Slice` and `ByteSlice` are borrowed pointer/length pairs, not C strings. +- Keep borrowed input alive until the synchronous host call returns. Keep callback data alive until no callback can enter it. +- Check `struct_size` and table version before reading fields. Service table version `IFACE_VERSION_1` is separate from top-level `ABI_VERSION_2`. +- Do not retain a host table or callback pointer after successful shutdown. -| 0.2 pattern | ABI v1 replacement | -|---|---| -| `plugin_get_instance` | `winisland_plugin_entry_v1` returning `PluginDescriptorV1` | -| `PluginVTable` / `PluginInstanceC` | Descriptor lifecycle callbacks plus opaque `PluginHandle` | -| `plugin_set_host_api` / `HostApiC` | `PluginCreateInfoV1.host_api` and `query_interface` | -| `PluginType` providers | Explicit capability bitset | -| plugin-defined Context IDs | Host-issued `ResourceId` | -| global push/clear calls | Service-specific create, update, and release operations | -| unfinished Theme/Shortcut APIs | No ABI v1 equivalent | +## Moving from ABI v1 -Remove all old exports. A DLL should export only ABI v1 entry points and use 0.6 types throughout; mixing layouts is not supported. +ABI v1 has no compatibility path in the current host. Rebuild source with `winisland-plugin-api` v2 types, export `winisland_plugin_entry_v2`, change capability names to `CAP_*`, replace `PluginResultC` with `PluginStatus`, and submit widget draw lists instead of drawing through a render-thread callback. Put `abi-version: 2` in the new ZIP. Renaming the old DLL or changing only its manifest is insufficient. ## Review checklist -- Descriptor is static and all required lifecycle functions are present. -- Metadata matches `Cargo.toml` and packaged `plugin.yml` exactly. -- Every queried service has a matching capability bit. -- Every host result is checked before updating local ownership state. -- Callback data outlives its registered resource. -- Workers are signaled and joined before shutdown success. -- Shutdown can be retried without double-free or joining a thread twice. -- Destroy frees the instance once and does not run plugin work. -- No panic, Rust-owned layout, or unbounded pointer crosses the ABI. +- Descriptor and package metadata match, and only supported capability bits are set. +- All required tables and function slots are checked. +- Resource and callback storage outlives every host call that uses it. +- Draw lists are complete and bounded. +- Plugin threads are joined before successful shutdown. +- Shutdown can be retried; destroy frees the handle exactly once. +- No panic or Rust-owned layout crosses a C boundary. diff --git a/Page/plugin-dev/packaging.md b/Page/plugin-dev/packaging.md index 8ddf729e..91875059 100644 --- a/Page/plugin-dev/packaging.md +++ b/Page/plugin-dev/packaging.md @@ -1,51 +1,14 @@ # Packaging and installation -WinIsland distributes plugins as ZIP archives with one root-level `plugin.yml` and one declared entry DLL. The archive may contain dependency DLLs and assets, but WinIsland treats only `entry` as a plugin. +A distributable ABI v2 plugin is a ZIP with root-level `plugin.yml` and the DLL named by `entry`. Dependency DLLs and assets may also be included. WinIsland loads only the declared entry as a plugin. -## Publish to the plugin marketplace +## Build with PluginPackager -Marketplace plugins must be open source in a public GitHub repository with a GitHub-detected SPDX license. Add `.github/workflows/release.yml` to that repository: - -```yaml -name: Release WinIsland plugin - -on: - push: - tags: - - "v*" - -permissions: - contents: write - id-token: write - attestations: write - -jobs: - release: - uses: WinIslandProject/PluginMarketplace/.github/workflows/build-plugin.yml@main -``` - -The reusable workflow runs check, strict Clippy, formatting verification and the official packager, then publishes a GitHub Release with build provenance. Publish by pushing a version tag such as `v1.0.0`. - -After the first release succeeds, add one `plugins/.toml` file in a pull request to [WinIslandProject/PluginMarketplace](https://github.com/WinIslandProject/PluginMarketplace): - -```toml -schema = 1 -id = "example-clock" -repository = "owner/example-clock" -asset = "*.winisland-plugin.zip" -categories = ["widget", "utility"] -min_winisland_version = "1.2.9" -``` - -The ID must match both the file name and `plugin.yml`. Later plugin updates need no marketplace pull request: publish a new valid GitHub Release and the catalog refresh will discover it. See the marketplace [contribution guide](https://github.com/WinIslandProject/PluginMarketplace/blob/main/CONTRIBUTING.md) for the complete review rules. - -## Add the packager - -Enable the optional `packager` feature as a development dependency: +Enable the packager feature for a small build tool. Use the current repository source until the ABI v2 crate is available from the registry; the package version is `0.8.0`. ```toml [dev-dependencies] -winisland-plugin-api = { version = "0.6", features = ["packager"] } +winisland-plugin-api = { git = "https://github.com/WinIslandProject/WinIsland", features = ["packager"] } [[example]] name = "pack" @@ -61,292 +24,53 @@ fn main() { PluginPackager::from_cargo() .expect("read Cargo.toml") .build() - .expect("build plugin package"); + .expect("build plugin ZIP"); } ``` -Run it from the plugin project root: - -```powershell -cargo run --example pack -``` - -The packager performs these steps: - -1. Runs `cargo build --release`. -2. Locates the built library using `[lib].name`, or the package name with `-` replaced by `_`. -3. Copies the entry DLL and requested extra directories into a temporary staging directory. -4. Computes DLL hashes. -5. Generates and validates `plugin.yml`. -6. Optionally signs the manifest payload. -7. Writes `target/-.zip` unless an output path was configured. +Run `cargo run --example pack` from the plugin project root. The packager runs `cargo build --release --locked`, locates `target/release/.dll`, copies requested assets, generates `plugin.yml` with DLL hashes, optionally signs it, validates the DLL descriptor, and writes `target/-.zip` unless configured otherwise. Commit the plugin's `Cargo.lock` before packaging. -If the project root contains `icon.png`, `icon.jpg`, `icon.jpeg`, or `icon.webp`, the packager includes the first match as the plugin icon. It also includes the first matching `README.md`, `README.markdown`, or `README.txt`. Use `.icon("path")` and `.readme("path")` to override automatic discovery. +`from_cargo()` reads `package.name`, `version`, `authors` (joined with `:`), `description`, `repository`, optional `package.metadata.winisland.id`/`name`, and `lib.name`. The first matching root icon (`icon.png`, `.jpg`, `.jpeg`, `.webp`) and README (`README.md`, `.markdown`, `.txt`) are included automatically. `icon()`, `readme()`, `include_dir()`, `dll_path()`, and `output()` override these choices. -## Cargo metadata and descriptor matching +The descriptor's ID, name, version, author, and description must match the generated manifest exactly. The packager and installer both load the DLL for descriptor validation before activation. Choose a stable ID of 1–63 ASCII letters, digits, underscores, or hyphens. -`PluginPackager::from_cargo()` reads: - -| Cargo field | Manifest/descriptor field | -|---|---| -| `package.name` | Default `id` and `name` | -| `package.version` | `version` | -| First `package.authors` value | `author` | -| `package.description` | `description` | -| `package.repository` | `github-link` | -| `lib.name` | Entry DLL filename | - -The generated manifest's `id`, `name`, `version`, `author`, and `description` must match `PluginMetadataC` exactly. WinIsland loads the DLL in staging and rejects the package before stopping an installed version when these values differ. - -If human-readable name or ID should differ from Cargo defaults, configure both sides explicitly: - -```rust -PluginPackager::from_cargo() - .unwrap() - .id("example-clock") - .name("Example Clock") - .author("Example Author") - .description("Shows the current time in WinIsland") - .build() - .unwrap(); -``` - -```rust -metadata: PluginMetadataC::new( - "example-clock", - "Example Clock", - env!("CARGO_PKG_VERSION"), - "Example Author", - "Shows the current time in WinIsland", -), -``` - -Plugin IDs must be 1 to 63 ASCII bytes and match `[a-zA-Z0-9_-]+`. Treat the ID as stable after release because it identifies the installation directory and update target. - -## Manifest format - -A generated manifest looks like this: +## Manifest ```yaml id: hello-winisland-plugin name: hello-winisland-plugin author: Example Author version: 0.1.0 -description: Minimal WinIsland ABI v1 plugin +description: Minimal WinIsland ABI v2 plugin github-link: https://github.com/example/hello-winisland-plugin -abi-version: 1 +abi-version: 2 entry: hello_winisland_plugin.dll -icon: icon.png -readme: README.md -dll_hashes: - - 75f1cd58a8bbf6dd32a68415c13e4065a827b603ab70542032fdd722d98a4f4d -``` - -Required host fields: - -| Field | Rule | -|---|---| -| `id` | Stable ASCII plugin ID matching the descriptor | -| `name` | Nonempty, at most 127 UTF-8 bytes, matching the descriptor | -| `author` | Nonempty, at most 127 UTF-8 bytes, matching the descriptor | -| `version` | Nonempty, at most 31 UTF-8 bytes, matching the descriptor | -| `description` | Nonempty, at most 255 UTF-8 bytes, matching the descriptor | -| `github-link` | Nonempty, at most 2,048 UTF-8 bytes | -| `abi-version` | Must be `1` | -| `entry` | One root-level `.dll` filename, at most 255 bytes | - -Optional presentation fields: - -| Field | Rule | -|---|---| -| `icon` | Safe relative `.png`, `.jpg`, `.jpeg`, or `.webp` path; displayed in the plugin list and details panel | -| `readme` | Safe relative `.md`, `.markdown`, or `.txt` path; displayed in the details panel, at most 1 MiB when read by the host | - -Declared presentation files must exist in the archive. When either field is omitted, WinIsland also looks for the conventional root filenames listed above, so existing packages can add an icon or README without changing their manifest. - -Packager metadata may also include `dll_hashes` and `signature`. The current WinIsland host deserializes the required fields but does not enforce hashes, signer identity, or Ed25519 signature verification. A signature therefore records build metadata; it is not currently an installation trust decision. Distributors must state this clearly. - -## Include dependencies and assets - -Add an extra directory with `include_dir`: - -```rust -PluginPackager::from_cargo() - .unwrap() - .icon("branding/plugin-icon.png") - .readme("docs/PLUGIN.md") - .include_dir("assets") - .include_dir("runtime") - .build() - .unwrap(); ``` -Included paths must be nonempty relative paths composed of normal path components. Absolute paths, `.` components, and `..` traversal are rejected. The directory is copied recursively with its relative path preserved. - -Put dependency DLLs beside the entry DLL or in the location expected by the plugin's own loading logic. WinIsland calls `LoadLibrary` only for `entry`; dependency resolution remains a Windows loader/plugin responsibility. +`id`, `name`, `author`, `version`, `description`, `github-link`, `abi-version`, and `entry` are required. The entry must be a single root-level `.dll` filename. Optional `icon` and `readme` are safe relative paths to files in the archive. The packager may add `dll_hashes` and `signature`. WinIsland currently does not enforce a local ZIP's manifest signature, signer identity, or `dll_hashes` during installation. Do not present that optional signature as an installation trust guarantee. -Do not include signing keys, `.env` files, build credentials, debug databases containing sensitive paths, or unrelated build output. +## Install and update -## Override build inputs +Drop the ZIP onto the island while WinIsland is running. The installer validates the archive and manifest, extracts to staging, loads the new DLL for descriptor/metadata validation, stops an existing packaged instance, swaps the directory, and starts the new instance. A failed replacement restores the previous directory and attempts to reload the old plugin. A successful install is available immediately; no restart is required. The Plugins page can enable, disable, and uninstall it. -The builder supports explicit paths when defaults do not fit: +For manual development, a root-level `.dll` in the plugin directory is loaded on startup without a manifest. Remove a manual DLL with the same plugin ID before installing the ZIP; a packaged update cannot replace that root DLL. -```rust -PluginPackager::new("Example Clock") - .id("example-clock") - .version("1.0.0") - .author("Example Author") - .description("Shows the current time in WinIsland") - .github_link("https://github.com/example/example-clock") - .dll_name("example_clock") - .dll_path("target/release/example_clock.dll") - .output("target/example-clock-1.0.0.zip") - .build() - .unwrap(); -``` +Archive checks include at most 4096 entries, 256 MiB per entry, 512 MiB total uncompressed, and a root `plugin.yml` of at most 1 MiB. The exact `entry` must exist. Symlinks, traversal, absolute or device paths, unsafe Windows names, and case-insensitive collisions are rejected. Failed extraction removes staging data. -`build()` still invokes `cargo build --release`. `dll_path` changes which output is copied; it does not skip compilation. +## Signatures and marketplace -## Optional signing +`PluginPackager::signing_key_env("WINISLAND_PLUGIN_SIGNING_KEY")` or `signing_key_path(...)` adds an Ed25519 signature over the manifest's canonical fields and DLL hash list. A failed key load currently logs a warning and produces an unsigned ZIP; inspect the resulting manifest if signing is required. Never commit private keys. -The packager can load an Ed25519 PKCS#8 PEM key from a file or environment variable: +Marketplace installation has a separate trust path: WinIsland verifies the signed marketplace catalog and the selected package size and SHA-256 against that catalog. Entries with incompatible ABI versions are filtered. A package's own optional `signature` field is not the marketplace catalog signature. -```rust -PluginPackager::from_cargo() - .unwrap() - .signing_key_env("WINISLAND_PLUGIN_SIGNING_KEY") - .build() - .unwrap(); -``` +To submit to the marketplace, follow the [marketplace contribution guide](https://github.com/WinIslandProject/PluginMarketplace/blob/main/CONTRIBUTING.md) and confirm its current requirements. The plugin repository and release asset must provide a valid ABI v2 package before it can appear as compatible. -or: - -```rust -.signing_key_path("signing_key.pem") -``` - -Prefer an environment secret in CI. Never commit a private key. Key-loading builder methods currently log a warning and continue unsigned when loading fails, so inspect build output and the generated manifest when a signature is required by your release process. - -The signature covers canonical JSON containing manifest fields and DLL hash values, excluding `signature` itself. Again, WinIsland does not currently verify it during installation. - -## Archive validation limits - -Before extraction, WinIsland validates the archive from the same open file handle used for extraction: - -- at most 4,096 ZIP entries; -- at most 256 MiB uncompressed per entry; -- at most 512 MiB total uncompressed data; -- `plugin.yml` at the archive root and no larger than 1 MiB; -- the exact case-sensitive root `entry` named by the manifest; -- no symlinks, absolute paths, drive/ADS colons, empty components, `.` or `..` components; -- no path component longer than 255 bytes or ending in a dot/space; -- no Windows device names such as `CON`, `NUL`, `COM1`, or `LPT1`; -- no paths that collide after Windows-style separator and case normalization. - -Actual bytes written are counted again during extraction. Validation failure removes the staging directory. - -## Installation transaction - -Open **Settings > Plugins** and drop the ZIP onto the installation area. Installation proceeds as follows: - -1. A background thread validates and extracts the package into a hidden staging directory. -2. On the WinIsland event-loop thread, the staged entry DLL is loaded only for descriptor validation. -3. Manifest and descriptor metadata are compared before the installed plugin is touched. -4. The staged validation DLL is unloaded. -5. The old plugin, if loaded from the package destination, must complete shutdown. -6. The old directory is renamed to a hidden backup. -7. Staging is renamed atomically to the final `/` directory. -8. The entry DLL is loaded again from its final path and initialized. -9. On success, the backup is removed. On failure, the new directory is removed/moved, the backup is restored, and the old plugin is reloaded. - -The validation and final load are intentionally separate. On Windows, renaming the directory of a loaded DLL succeeds, but the module path retained by the loader still points to the old staging path. Loading the final instance from its destination ensures relative resources and delayed dependencies resolve against the installed location. - -If a newly created plugin instance cannot stop after initialization fails, WinIsland keeps its DLL loaded rather than deleting code that may still be executing. The installation reports a non-stoppable instance error and does not pretend rollback completed. - -After installation, WinIsland refreshes the installed-plugin list and asks for an application restart. Plugins can be enabled or disabled from either the list or details panel; that state is persisted by plugin ID and takes effect after restart. Disabled packaged plugins are skipped before their DLL is opened. A manually installed root DLL must still be opened far enough to read its descriptor and determine its ID. - -Clicking a plugin opens its details panel with the icon, metadata, repository link, enable switch, and README. README files are parsed as CommonMark with GFM tables, task lists, and strikethrough enabled. Headings, paragraphs, emphasis, lists, block quotes, code, tables, separators, and web links are rendered by WinIsland. Remote images are not downloaded by the settings page; their alternative text and source link remain available. - -## Manual DLL versus package install - -WinIsland also discovers root-level `.dll` files in the plugin directory. These are manual installations without `plugin.yml`. - -- Manual DLLs are useful for local development. -- A ZIP update cannot replace a loaded manual root DLL with the same plugin ID. -- Remove the manual DLL and restart WinIsland before installing the packaged version. -- Packaged plugins live under `//` and are updateable transactionally. - -## Inspect the output - -List archive contents: +## Inspect and troubleshoot ```powershell tar -tf target/hello-winisland-plugin-0.1.0.zip -``` - -Inspect the manifest without extracting: - -```powershell tar -xOf target/hello-winisland-plugin-0.1.0.zip plugin.yml -``` - -Expected minimum contents: - -```text -hello_winisland_plugin.dll -plugin.yml -``` - -Build and lint the plugin itself before publishing: - -```powershell -cargo check --all-targets -cargo clippy --all-targets -- -D warnings -cargo build --release -cargo run --example pack -``` - -Test installation, restart discovery, update from an older package, failed initialization rollback, all declared Media controls, and shutdown while workers are active. - -## Troubleshooting - -### `winisland_plugin_entry_v1` is missing - -Confirm the exact function name, `extern "C"`, `#[unsafe(no_mangle)]`, and `cdylib` crate type. Inspect exports with Visual Studio tools if needed: - -```powershell dumpbin /exports target/release/hello_winisland_plugin.dll ``` -### Descriptor and manifest metadata do not match - -Compare all five matched fields: ID, name, version, author, and description. Cargo's first author value and `[lib].name` may differ from assumptions. Rebuild the DLL before rebuilding the ZIP. - -### Entry DLL is missing - -Check `[lib].name`, `.dll_name(...)`, `.dll_path(...)`, and the `entry` value. The entry must be at the archive root and its case must match exactly. - -### Existing plugin cannot shut down - -The update is stopped before directory replacement. Fix worker cancellation, callback lifetime, and retryable resource release in `shutdown`. WinIsland intentionally keeps the old DLL loaded when shutdown fails. - -### New plugin cannot initialize - -Read the full rollback message. It distinguishes initialization failure, failed-directory removal, backup restoration failure, and failure to reload the previous plugin. Fix the first reported plugin error before retrying. - -### Package is signed but WinIsland gives no trust indication - -This is expected in ABI v1: host-side signer trust and signature/hash enforcement are not implemented. Distribute through a trusted channel and publish independent checksums when authenticity matters. - -## Release checklist - -- Increment plugin version and update descriptor metadata together. -- Verify Cargo metadata and `PluginMetadataC` match exactly. -- Run check, strict Clippy, and release build. -- Build the package from a clean source checkout. -- Inspect ZIP contents and `plugin.yml`. -- Ensure no secrets or unrelated files are included. -- Test clean install, restart, update, rollback, and uninstall/shutdown behavior. -- Document requested capabilities and why the plugin needs them. -- State that the plugin executes in-process without sandboxing. -- Publish the ZIP and checksum through a controlled release channel. +Check that the DLL exports exactly `winisland_plugin_entry_v2`, `abi-version` is `2`, `entry` names the root DLL, and all five descriptor metadata fields match. If an update fails, read the installation error: the old package should stay active after a recoverable replacement failure. A shutdown that cannot finish keeps its DLL loaded to protect outstanding callbacks. Test install, update, disable/enable, rollback, and uninstall before distribution. diff --git a/Page/plugin-dev/quickstart.md b/Page/plugin-dev/quickstart.md index 677179b0..58c2476f 100644 --- a/Page/plugin-dev/quickstart.md +++ b/Page/plugin-dev/quickstart.md @@ -1,30 +1,22 @@ # Plugin quickstart -This guide builds a complete ABI v1 DLL that publishes one persistent Context. The example validates every required input, keeps the host-issued resource ID, releases it during shutdown, and frees the opaque instance only in `destroy`. +This example builds a complete ABI v2 DLL that publishes one Context. It validates the host input, keeps the resource alive, releases it during shutdown, and frees the opaque instance in destroy. ## Prerequisites - Windows 10 version 2004 or later, or Windows 11 -- Stable Rust with the `x86_64-pc-windows-msvc` toolchain -- Visual Studio Build Tools with the Desktop development with C++ workload -- A WinIsland build that supports plugin API `0.6` / ABI v1 +- Stable Rust with the `x86_64-pc-windows-msvc` target +- Visual Studio C++ build tools and Windows SDK +- A WinIsland build with ABI v2 support -Check the toolchain: - -```powershell -rustup show -rustc --version -cargo --version -``` - -## Create the project +## Create a library ```powershell cargo new --lib hello-winisland-plugin cd hello-winisland-plugin ``` -Replace `Cargo.toml` with the relevant package, library, and dependency settings: +Use this `Cargo.toml`. Until an ABI v2 crate is published to the registry, use the repository source shown here. After publication, a matching `winisland-plugin-api = "0.8"` release can replace the Git dependency. ```toml [package] @@ -32,7 +24,7 @@ name = "hello-winisland-plugin" version = "0.1.0" edition = "2024" authors = ["Example Author"] -description = "Minimal WinIsland ABI v1 plugin" +description = "Minimal WinIsland ABI v2 plugin" repository = "https://github.com/example/hello-winisland-plugin" [lib] @@ -40,190 +32,119 @@ name = "hello_winisland_plugin" crate-type = ["cdylib"] [dependencies] -winisland-plugin-api = "0.6" +winisland-plugin-api = { git = "https://github.com/WinIslandProject/WinIsland" } ``` -`cdylib` is required: it produces a native DLL with the exported ABI entry point. The package metadata will also be reused by the packager, so keep it aligned with `PluginMetadataC` below. - -## Implement the plugin +The package ID, name, version, author, and description must agree with the descriptor and the packaged manifest. The repository URL supplies `github-link`. -Use this as `src/lib.rs`: +## Implement `src/lib.rs` ```rust use std::ffi::c_void; -use winisland_plugin_api::*; +use winisland_plugin_api::abi::{ + ABI_VERSION_2, CAP_CONTEXT, PluginCreateInfoV2, PluginDescriptorV2, + PluginHandleV2, PluginStatus, +}; +use winisland_plugin_api::sdk::{Host, Resource}; +use winisland_plugin_api::PluginMetadataC; struct Instance { - token: PluginToken, - context_api: ContextApiV1, - context_id: ResourceId, + context: Option, } -static DESCRIPTOR: PluginDescriptorV1 = PluginDescriptorV1 { - struct_size: std::mem::size_of::() as u32, - abi_version: ABI_VERSION_1, - capabilities: CAPABILITY_CONTEXT, +static DESCRIPTOR: PluginDescriptorV2 = PluginDescriptorV2 { + struct_size: std::mem::size_of::() as u32, + abi_version: ABI_VERSION_2, + capabilities: CAP_CONTEXT, metadata: PluginMetadataC::new( "hello-winisland-plugin", "hello-winisland-plugin", - "0.1.0", + env!("CARGO_PKG_VERSION"), "Example Author", - "Minimal WinIsland ABI v1 plugin", + "Minimal WinIsland ABI v2 plugin", ), create: Some(create), shutdown: Some(shutdown), destroy: Some(destroy), + on_tick: None, }; unsafe extern "C" fn create( - create_info: *const PluginCreateInfoV1, - out_handle: *mut PluginHandle, -) -> PluginResultC { - if create_info.is_null() || out_handle.is_null() { - return PluginResultC::err("null create argument"); + info: *const PluginCreateInfoV2, + out_handle: *mut PluginHandleV2, +) -> PluginStatus { + if info.is_null() || out_handle.is_null() { + return PluginStatus::InvalidArgument; } - - // SAFETY: WinIsland supplies a readable ABI create-info prefix. - let info = unsafe { &*create_info }; - if info.struct_size < std::mem::size_of::() as u32 - || info.abi_version != ABI_VERSION_1 - || info.host_api.is_null() - || info.plugin_token == INVALID_ID + // SAFETY: WinIsland supplies a readable create-info header. + let info = unsafe { &*info }; + if info.struct_size < std::mem::size_of::() as u32 + || info.abi_version != ABI_VERSION_2 + || info.plugin_token == winisland_plugin_api::PluginToken::INVALID { - return PluginResultC::err("unsupported create info"); + return PluginStatus::UnsupportedVersion; } - - // SAFETY: The validated host API pointer remains valid while WinIsland runs. - let host = unsafe { &*info.host_api }; - // SAFETY: `host` originated from WinIsland and its ABI header was checked by the helper. - let Some(context_api) = (unsafe { host.context_api() }) else { - return PluginResultC::err("context API is unavailable"); - }; - let Some(create_context) = context_api.create else { - return PluginResultC::err("context create is unavailable"); + // SAFETY: The host table remains allocated throughout this instance's lifetime. + let host = match unsafe { Host::from_raw(info.host_api, info.plugin_token) } { + Ok(host) => host, + Err(_) => return PluginStatus::InvalidArgument, }; - if context_api.release.is_none() { - return PluginResultC::err("context release is unavailable"); - } - - let context = ContextDataV1 { - priority: PRIORITY_MEDIUM, - flags: CONTEXT_FLAG_SHOW_COMPACT, - timeout_ms: 0, - title: str_to_fixed("Hello WinIsland"), - body: str_to_fixed("ABI v1 plugin is running"), - compact_text: str_to_fixed("Hello"), - ..Default::default() + let context = match host + .context() + .and_then(|api| api.create("Hello WinIsland", "ABI v2 plugin is running")) + { + Ok(context) => context, + Err(_) => return PluginStatus::Internal, }; - let mut context_id = INVALID_ID; - // SAFETY: Inputs and output remain valid until the synchronous call returns. - let result = unsafe { create_context(info.plugin_token, &context, &mut context_id) }; - if result.status != 0 { - return result; - } - let instance = Box::new(Instance { - token: info.plugin_token, - context_api, - context_id, + context: Some(context), }); - // SAFETY: WinIsland treats this pointer as opaque until `destroy`. + // SAFETY: WinIsland treats this pointer as opaque until destroy. unsafe { out_handle.write(Box::into_raw(instance).cast::()) }; - PluginResultC::ok() + PluginStatus::Ok } -unsafe extern "C" fn shutdown(handle: PluginHandle) -> PluginResultC { +unsafe extern "C" fn shutdown(handle: PluginHandleV2) -> PluginStatus { if handle.is_null() { - return PluginResultC::ok(); + return PluginStatus::InvalidArgument; } - - // SAFETY: `handle` was created from `Box` and has not been destroyed. + // SAFETY: This handle was created above and has not been destroyed. let instance = unsafe { &mut *handle.cast::() }; - if instance.context_id != INVALID_ID { - let Some(release) = instance.context_api.release else { - return PluginResultC::err("context release is unavailable"); - }; - // SAFETY: The resource belongs to this instance's host-issued token. - let result = unsafe { release(instance.token, instance.context_id) }; - if result.status != 0 { - return result; - } - instance.context_id = INVALID_ID; - } - PluginResultC::ok() + drop(instance.context.take()); + PluginStatus::Ok } -unsafe extern "C" fn destroy(handle: PluginHandle) { +unsafe extern "C" fn destroy(handle: PluginHandleV2) { if !handle.is_null() { - // SAFETY: WinIsland calls destroy once, after shutdown succeeds. + // SAFETY: WinIsland calls destroy once after successful shutdown. unsafe { drop(Box::from_raw(handle.cast::())) }; } } -#[unsafe(no_mangle)] /// # Safety -/// WinIsland calls this function using the documented ABI v1 signature. -pub unsafe extern "C" fn winisland_plugin_entry_v1() -> *const PluginDescriptorV1 { +/// WinIsland calls this exported symbol using the ABI v2 entry signature. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn winisland_plugin_entry_v2() -> *const PluginDescriptorV2 { &DESCRIPTOR } ``` -## Validate the DLL +`PluginStatus` is a numeric status; it does not carry an error string. Log diagnostic details through `LogApiV2` when needed. Do not unwind through any exported C callback. -Run checks before producing a release binary: +## Build and load ```powershell -cargo check --all-targets -cargo clippy --all-targets -- -D warnings +cargo check +cargo clippy -- -D warnings cargo build --release ``` -The DLL is written to: - -```text -target/release/hello_winisland_plugin.dll -``` - -If no DLL is produced, confirm `[lib].crate-type = ["cdylib"]`. If the linker is missing, install or repair the Visual Studio MSVC build tools. - -## Load it during development - -For a quick local check, close WinIsland, place the DLL directly in WinIsland's plugin directory, and restart WinIsland. Root-level DLLs are treated as manually installed plugins. Check the WinIsland log for either: - -```text -Loaded ABI v1 plugin: hello-winisland-plugin ... -``` - -or a validation error naming the DLL. - -Manual root DLLs are useful during development, but distributable plugins should use the ZIP format. A packaged update cannot replace a manually installed root DLL automatically; remove the root DLL first. - -## Make a change safely - -Use `ContextApiV1::update` with the saved token and resource ID. Do not create a new Context for every refresh. - -```rust -let updated = ContextDataV1 { - title: str_to_fixed("Task complete"), - body: str_to_fixed("The release build finished"), - compact_text: str_to_fixed("Complete"), - timeout_ms: 5_000, - ..Default::default() -}; - -let result = unsafe { - instance.context_api.update.unwrap()( - instance.token, - instance.context_id, - &updated, - ) -}; -``` +The DLL is `target/release/hello_winisland_plugin.dll`. During local development, place it in the root of WinIsland's plugin directory and restart the app. Root-level DLLs are manual installations and have no `plugin.yml`. Remove a manual DLL before installing a packaged copy with the same ID. -An update refreshes ordering and starts a new timeout. A timed-out Context is hidden but remains owned by the plugin until release or successful plugin shutdown. +For a distributable ZIP, follow [Packaging and installation](/plugin-dev/packaging). Set `abi-version: 2` and make `entry` equal to the DLL filename. You can also drop the ZIP onto the island while WinIsland is running. -## Next steps +## Extend the example -- Read [ABI and lifecycle](/plugin-dev/abi-lifecycle) before adding threads or storing callback pointers. -- Use [Host services](/plugin-dev/services) to add Media, lyric transforms, translation bundles, or Host State. -- Follow [Packaging and installation](/plugin-dev/packaging) to generate a validated ZIP. +- Use [Host services](/plugin-dev/services) for Media, Widgets, Settings, Images, Store, and lyrics. +- Use [ABI and lifecycle](/plugin-dev/abi-lifecycle) before adding callbacks or threads. +- See the [SDK widget example](https://github.com/WinIslandProject/WinIsland/blob/master/crates/winisland-plugin-api/examples/minimal_widget.rs) for `DrawListBuilder` usage. diff --git a/Page/plugin-dev/services.md b/Page/plugin-dev/services.md index 5b36af7c..5197264e 100644 --- a/Page/plugin-dev/services.md +++ b/Page/plugin-dev/services.md @@ -1,386 +1,49 @@ # Host services -ABI v1 exposes six versioned host services. Context, Media, i18n, Widget, and Lyrics Transform create resources owned by the plugin token. Host State returns a snapshot and creates no resource. +ABI v2 exposes eleven versioned service tables through `PluginHostV2.query`. The SDK `Host` wrapper covers common operations; the raw tables in `winisland_plugin_api::abi` expose every function. Each raw table starts with `TablePrefix` and currently uses `IFACE_VERSION_1`. Validate required function slots before calling them. Except for Log, declare the matching `CAP_*` bit in `PluginDescriptorV2`. -## Query and validate a service +| Capability | Raw table | SDK access | Main operations | +|---|---|---|---| +| `CAP_CONTEXT` | `ContextApiV2` | `host.context()?` | Create, update, release activity text | +| `CAP_MEDIA` | `MediaApiV2` | `host.media()?` | Publish a media source and read current title | +| `CAP_I18N` | `I18nApiV2` | `host.i18n()?` (query only) | Register and release translation bundles | +| `CAP_HOST_STATE` | `HostStateApiV2` | `host.host_state()?` | Read or subscribe to media/theme state | +| `CAP_WIDGET` | `WidgetApiV2` | `host.widgets()?` | Create, update, release, draw, and size widgets | +| `CAP_LYRICS` | `LyricsTransformApiV2` | `host.lyrics()?` | Register/release a lyric transformer | +| `CAP_SETTINGS` | `SettingsApiV2` | `host.settings()?` | Create/update/release a settings page | +| `CAP_TEXT` | `TextApiV2` | `host.text()?` | Measure text and query font family | +| `CAP_IMAGE` | `ImageApiV2` | `host.images()?` | Decode, upload, use album art, release | +| `CAP_STORE` | `StoreApiV2` | `host.store()?` | Get, set, delete plugin-scoped bytes | +| None | `LogApiV2` | `host.log()` | Write plugin log messages | -Declare the capability in `PluginDescriptorV1`, query the table during `create`, then validate every function slot the plugin requires: +## Context and Media -```rust -let host = unsafe { &*info.host_api }; -let Some(context_api) = (unsafe { host.context_api() }) else { - return PluginResultC::err("context API is unavailable"); -}; -let (Some(create), Some(update), Some(release)) = ( - context_api.create, - context_api.update, - context_api.release, -) else { - return PluginResultC::err("context API is incomplete"); -}; -``` +`ContextDataV2` has a priority, compact flag, timeout, title, body, and compact text. Zero timeout keeps it until release. Update the same resource rather than creating a new one on each refresh. The SDK's `host.context()?.create(title, body)` returns an owned `Resource`. -Service tables are copied values containing function pointers. Keep the copied table in plugin state. The function pointers remain valid while WinIsland runs, but they must not be called after plugin shutdown has completed. +`MediaSourceDataV2` carries title, artist, album, duration/position in milliseconds, playing flag, optional PNG/JPEG cover bytes, declared controls, and an optional command callback. The SDK `create_source(title, artist)` publishes a display-only source; use `MediaApiV2` directly for cover, timeline, or controls. Releasing or disabling a plugin media source lets WinIsland fall back to another source or SMTC. Keep callback data valid until release is safe. -Every fallible service function returns `PluginResultC`. Check `status` before storing, replacing, or discarding local ownership state: +## Widget drawing -```rust -let result = unsafe { update(token, resource_id, &data) }; -if result.status != 0 { - return result; -} -``` +`host.widgets()?.create(WidgetSpec::new("key").span(2, 1))` creates a layout-managed widget. Stable keys identify placement across restarts. `Widget::logical_size()` returns the current logical dimensions; skip a frame if it returns `(0, 0)`. The size follows the expanded grid even while the island collapses. -## Current host limits +Build a complete list with `DrawListBuilder::new(Size::new(width, height))`, add commands such as `fill_round_rect`, `text`, `text_runs`, or `image`, then call `widget.submit(list.finish())`. The draw protocol supports clips, transforms, alpha, shapes, gradients, strokes, shadows, images, and UTF-8 text with a font-family field. The host copies the list and validates it before replay. Submission success does not guarantee display; malformed frames can eventually disable that widget. No plugin callback runs on the render thread. -Limits protect the WinIsland process from accidental unbounded resource use. They are host policy, not ABI constants, and may change in later WinIsland releases. +The draw list is limited to 4 MiB, 4096 commands, and 64 KiB of text per command. `Widget::request_redraw` is best effort. `PluginDescriptorV2.on_tick` receives `WidgetId` and elapsed seconds on a plugin worker. -| Resource | Per-plugin limit | Data limit | -|---|---:|---:| -| Context | 64 active resources | Fixed fields: title 255, body 511, compact text 127 UTF-8 bytes plus NUL | -| Media | 4 active resources | Cover up to 16 MiB each; 32 MiB total cover data per plugin | -| Translation bundle | 16 active bundles | 1 MiB per bundle; 4 MiB total per plugin | -| Translation pairs | 4,096 per bundle | Key/value up to 64 KiB each; language code up to 64 bytes | -| Widget | 16 active resources | Span up to 6 columns by 3 rows; stable key up to 63 ASCII bytes | -| Lyrics transformer | 4 active resources | Transformed output up to 256 KiB per line | +## Lyrics, i18n, and Host State -An update is included in the same quota as the resource it replaces. Releasing a resource returns its count and memory budget. +A lyric transformer runs after lyrics are parsed. It uses a size query and then a write pass. Preserve the same Unicode character count on word-synchronised lines to keep timing boundaries. The SDK `host.lyrics()?.register` accepts a `Send + Sync` transformation closure and retains its callback storage while registered. -## Context service +`I18nApiV2` registers a language-tagged bundle of key/value pairs. `HostStateApiV2.get` returns media title, artist, playing state, and light/dark theme. `subscribe` registers a callback; release the subscription before unloading. -Context is for compact, glanceable plugin state such as a build result, timer, recording status, or ongoing task. +## Settings, Text, Image, Store, and Log -### Data model +`SettingsApiV2` accepts a declarative `SettingsPageDataV2` with a stable page key, title, optional icon, and items: section, group, label, switch, select, stepper, and button. Its `on_change` callback can accept or reject a user action. SDK `create_label_page` creates a simple label page; use the raw table for interactive items and callbacks. Persist values explicitly with Store, then repopulate item values on create. -```rust -let context = ContextDataV1 { - priority: PRIORITY_HIGH, - flags: CONTEXT_FLAG_SHOW_COMPACT, - timeout_ms: 10_000, - title: str_to_fixed("Deployment finished"), - body: str_to_fixed("Production is healthy"), - compact_text: str_to_fixed("Deployed"), - ..Default::default() -}; -``` +`TextApiV2.measure` accepts `TextStyleV2` with size, weight, italic flag, and UTF-8 font family; `font_family` reports host families. The SDK `measure` convenience call uses weight 400 and upright style. `ImageApiV2` can decode PNG/JPEG/WebP, upload RGBA pixels, or capture current album art; image IDs remain owned until release. A captured album-art handle keeps its image after the cover changes. -Priorities are ordered `LOW < MEDIUM < HIGH`. WinIsland displays the highest-priority compact Context; equal-priority resources are ordered by their latest update time. Unknown priority values and flag bits are rejected. +`StoreApiV2` stores bytes under plugin-local keys across restarts; a value is limited to 1 MiB. `LogApiV2.write` uses a numeric level and has no capability gate. SDK `LogApi::write` and `Widget::request_redraw` discard errors; use raw tables if the status matters. -`CONTEXT_FLAG_SHOW_COMPACT` makes the resource eligible for compact-island display. If `compact_text` is empty, the title is used. The body is rendered as secondary text when present. +## Resource limits and errors -`timeout_ms = 0` means no timeout. A nonzero timeout starts when the resource is created or updated. Expiry hides the Context but does not release it or return its quota; the plugin still owns the ID. - -### Create, update, and release - -```rust -let mut id = INVALID_ID; -let result = unsafe { context_api.create.unwrap()(token, &context, &mut id) }; -if result.status != 0 { - return result; -} - -let updated = ContextDataV1 { - title: str_to_fixed("Deployment verified"), - compact_text: str_to_fixed("Healthy"), - ..context -}; -let result = unsafe { context_api.update.unwrap()(token, id, &updated) }; - -let result = unsafe { context_api.release.unwrap()(token, id) }; -``` - -Keep the ID unchanged when update fails. Mark it `INVALID_ID` only after release succeeds. Creating and immediately releasing before WinIsland renders is supported; event coalescing prevents stale text from appearing. - -### Context errors - -Typical failures are an empty title, unknown priority/flags, a wrong token, an ID owned by another plugin or service, and the 64-resource limit. - -## Media service - -A plugin Media resource supplies the media information WinIsland actually displays. It is independent of the SMTC setting. The most recently created or updated plugin Media resource is active; releasing it selects the next most recent plugin resource, then falls back to SMTC when none remain. - -### Display data - -```rust -let cover = std::fs::read("cover.png").unwrap_or_default(); -let media = MediaSourceDataV1 { - flags: MEDIA_FLAG_PLAYING, - duration_ms: 180_000, - position_ms: 12_000, - title: str_to_fixed("Plugin Track"), - artist: str_to_fixed("Plugin Artist"), - album: str_to_fixed("Plugin Album"), - cover: ByteSliceV1::from_slice(&cover), - ..Default::default() -}; -``` - -The title is required. Artist and album may be empty. Cover bytes must contain a decodable PNG or JPEG for display; WinIsland copies them before returning. An empty slice clears the cover. - -When `MEDIA_FLAG_PLAYING` is set, WinIsland advances the displayed position from `position_ms` using elapsed time. Send an update after seek, pause/resume, track changes, or authoritative position corrections. `duration_ms = 0` represents unknown duration. - -### Optional controls - -Controls are opt-in. Set only commands the plugin can handle and provide a callback whenever any control bit is nonzero: - -```rust -media.available_controls = MEDIA_CONTROL_TOGGLE_PLAY - | MEDIA_CONTROL_PREVIOUS - | MEDIA_CONTROL_NEXT - | MEDIA_CONTROL_SEEK; -media.on_command = Some(on_media_command); -media.callback_data = state_ptr; -``` - -```rust -unsafe extern "C" fn on_media_command( - callback_data: *mut std::ffi::c_void, - resource_id: ResourceId, - command: *const MediaCommandV1, -) { - if callback_data.is_null() || command.is_null() { - return; - } - let command = unsafe { &*command }; - if command.struct_size < std::mem::size_of::() as u32 { - return; - } - - match command.command { - MEDIA_COMMAND_TOGGLE_PLAY => { /* enqueue toggle */ } - MEDIA_COMMAND_PREVIOUS => { /* enqueue previous */ } - MEDIA_COMMAND_NEXT => { /* enqueue next */ } - MEDIA_COMMAND_SEEK => { - let target_ms = command.position_ms; - // enqueue seek for `resource_id` - } - _ => {} - } -} -``` - -WinIsland calls the callback synchronously on its event-loop thread. Keep it short and enqueue slow work. `callback_data` must stay valid until release succeeds. A seek drag remains bound to the Media resource that was active when dragging started, so use the callback's `resource_id` rather than assuming the plugin's newest resource. - -Updating or releasing the same resource from inside its callback returns `media callback is in progress`. Other host service calls are allowed. Unload also waits until no Media callback is in flight. - -### Media errors - -Media calls reject an empty title, unknown flags or controls, controls without a callback, null cover data with nonzero length, covers larger than 16 MiB, total cover quota overflow, a wrong owner/type, and update/release during a callback. - -## Lyrics Transform service - -Lyrics Transform post-processes lyrics fetched by WinIsland. Declare -`CAPABILITY_LYRICS_TRANSFORM`, query `lyrics_transform_api()`, and register a callback resource: - -```rust -let transformer = LyricsTransformerDataV1 { - on_transform: Some(transform_lyrics), - callback_data: state_ptr, - ..Default::default() -}; -let mut transformer_id = INVALID_ID; -let result = unsafe { - lyrics_api.register.unwrap()(token, &transformer, &mut transformer_id) -}; -``` - -WinIsland calls the transformer once per parsed line, in registration order, after a lyrics fetch -completes and before the result is cached. This makes a Simplified-to-Traditional converter a -small text-only plugin: pass the input line through OpenCC and return the converted UTF-8 text. - -The callback uses two passes. On the first call, `output` is null and `output_capacity` is zero; -write the required byte length to `out_len`. On the second call, copy at most `output_capacity` -bytes to `output` and update `out_len` with the actual length. Return an error on invalid pointers, -conversion failure, or insufficient capacity. - -`LyricsTextV1` includes `line_time_ms`, the borrowed UTF-8 line, and -`LYRICS_TEXT_FLAG_WORD_SYNCED`. Word-synchronised output must retain the input's Unicode character -count. WinIsland then maps the original per-word boundaries into the transformed UTF-8 bytes, so -highlight timing remains intact even when byte sequences change. A different character count is -rejected for that line; other lines continue through the transformer chain. - -Callbacks run synchronously on a lyrics-fetch worker and may call other host services. Keep them -bounded: every line is limited to 256 KiB of output. `callback_data` must remain valid until release -succeeds. Release and plugin unload return an error while the callback is active. Release the -transformer during `shutdown`; newly registered transformers apply from the next lyrics fetch. - -## Widget service - -Widget resources render through WinIsland's `DrawApiV1`, so plugins do not link Skia or another -graphics library. Declare `CAPABILITY_WIDGET`, query `widget_api()`, and give every configurable -widget a stable key: - -```rust -let widget = WidgetDataV1 { - key: str_to_fixed("status"), - span_cols: 2, - span_rows: 1, - title: str_to_fixed("Status"), - on_draw: Some(draw_widget), - ..Default::default() -}; - -let mut widget_id = INVALID_ID; -let result = unsafe { widget_api.create.unwrap()(token, &widget, &mut widget_id) }; -``` - -The key is combined with the descriptor's plugin ID for saved layout identity. It must contain -1-63 ASCII letters, digits, `_`, or `-`, be unique within the plugin, and remain unchanged in -`update` and future plugin releases. Keyed widgets appear in **Settings > Widgets** with a live -preview from their real draw callback. Users can drag them into the island grid, rearrange them, -or remove them back to the widget library; the selected slot persists across restarts. - -An empty key is accepted for compatibility with plugins built before API 0.5. Those widgets keep -legacy automatic placement in the first free slot and do not appear in the Settings library. - -Implement the draw callback like this: - -```rust -unsafe extern "C" fn draw_widget( - callback_data: *mut std::ffi::c_void, - ctx: *const WidgetDrawContextV1, -) { - if ctx.is_null() { - return; - } - // SAFETY: WinIsland supplies the context and keeps it valid for this call. - let ctx = unsafe { &*ctx }; - let Some(draw) = (unsafe { ctx.draw_api() }) else { - return; - }; - let (Some(round_rect), Some(text), Some(circle)) = - (draw.draw_round_rect, draw.draw_text, draw.draw_circle) - else { - return; - }; - let _ = callback_data; - - // Coordinates are logical; the host applies the island scale and alpha. - unsafe { round_rect(ctx, 0.0, 0.0, ctx.width, ctx.height, 12.0, 0x28FFFFFF) }; - unsafe { text(ctx, 16.0, 16.0, Utf8SliceV1::borrowed("Ready"), 18.0, 1, 0xFFFFFFFF) }; - unsafe { circle(ctx, ctx.width - 14.0, 14.0, 4.0, 0xFF34C759) }; - - // save/restore/translate keep a plugin-local transform stack. - unsafe { draw.save.unwrap()(ctx) }; - unsafe { draw.translate.unwrap()(ctx, 8.0, 0.0) }; - unsafe { draw.draw_rect.unwrap()(ctx, 0.0, ctx.height - 3.0, 24.0, 2.0, 0x40FFFFFF) }; - unsafe { draw.restore.unwrap()(ctx) }; -} -``` - -Colors are `0xAARRGGBB`, `draw_text`'s `y` is the text top (ascent line), and `draw_image` -takes non-premultiplied RGBA8 pixels with the host applying the context alpha. The full draw -surface is `draw_rect`, `draw_round_rect`, `draw_circle`, `draw_line`, `draw_arc`, `draw_text`, -`measure_text`, `draw_image`, and the `save` / `restore` / `translate` transform stack. - -The draw callback runs synchronously on the render thread. Use logical coordinates relative to -the widget, keep the callback short, never retain the context, and release the resource during -shutdown. Widget calls reject invalid spans, unknown flags, missing callbacks, duplicate or -invalid keys, key changes during update, wrong ownership, and the per-plugin resource limit. - -## Translation service - -Translation bundles add plugin-owned keys to WinIsland's existing translation lookup. Register one bundle per language and retain every returned ID. - -```rust -let pairs = [ - TranslationPairV1 { - key: Utf8SliceV1::borrowed("hello.title"), - value: Utf8SliceV1::borrowed("Hello"), - }, - TranslationPairV1 { - key: Utf8SliceV1::borrowed("hello.status"), - value: Utf8SliceV1::borrowed("Running"), - }, -]; - -let mut bundle_id = INVALID_ID; -let result = unsafe { - i18n_api.register_bundle.unwrap()( - token, - Utf8SliceV1::borrowed("en_us"), - pairs.as_ptr(), - pairs.len() as u32, - &mut bundle_id, - ) -}; -``` - -WinIsland copies the language, keys, and values during registration. The slices may be dropped after the call returns. Keys must be nonempty; values may be empty. Use unique, plugin-prefixed keys to avoid overriding another plugin or application string accidentally. - -Built-in language codes currently include `en_us`, `zh_cn`, and `es_es`. A bundle affects lookup only while its language matches the selected WinIsland language. For the same key and language, the most recently registered active plugin bundle wins. Releasing it reveals an older bundle or the built-in translation again. - -```rust -let result = unsafe { i18n_api.release_bundle.unwrap()(token, bundle_id) }; -``` - -Release bundles in reverse registration order during shutdown. WinIsland also removes remaining bundles after successful plugin shutdown. - -### Translation errors - -Registration rejects an empty bundle, null pair array, more than 4,096 pairs, an empty language or key, invalid UTF-8, oversized strings, a bundle larger than 1 MiB, per-plugin count/total quota overflow, and missing capability. - -## Host State service - -Host State is a snapshot, not a subscription. Initialize the output structure so `struct_size` is present, then call `get`: - -```rust -let mut state = HostStateV1::default(); -let result = unsafe { host_state_api.get.unwrap()(token, &mut state) }; -if result.status == 0 { - let is_playing = state.is_playing != 0; - let title = read_fixed(&state.media_title); - let theme = read_fixed(&state.theme); -} -``` - -A plugin can use a local fixed-buffer reader: - -```rust -fn read_fixed(bytes: &[u8]) -> String { - let end = bytes.iter().position(|byte| *byte == 0).unwrap_or(bytes.len()); - String::from_utf8_lossy(&bytes[..end]).into_owned() -} -``` - -The snapshot contains the media currently displayed by WinIsland, including active plugin Media, and the current theme string (`light` or `dark`). Media fields may be empty when nothing is displayed. Do not cache the result as an event stream; query again when the plugin needs a fresh value. - -Host State validates the token, capability, output pointer, and output `struct_size`. It creates no `ResourceId` and requires no release. - -## Resource cleanup pattern - -Keep IDs in the instance and clear each only after successful release: - -```rust -fn release_resource( - id: &mut ResourceId, - release: unsafe extern "C" fn(PluginToken, ResourceId) -> PluginResultC, - token: PluginToken, -) -> PluginResultC { - if *id == INVALID_ID { - return PluginResultC::ok(); - } - let result = unsafe { release(token, *id) }; - if result.status == 0 { - *id = INVALID_ID; - } - result -} -``` - -For multiple resources, stop callbacks/workers first, then release resources in reverse dependency order. If one release fails, preserve the unreleased IDs and return the error so shutdown can be retried. - -## Diagnosing host errors - -`PluginResultC::into_result()` converts a result to `Result<(), String>` on the Rust side. Useful error text includes: - -| Error | Meaning | -|---|---| -| `invalid plugin token` | Token is zero, stale, or not issued to this loaded instance | -| `capability was not declared` | Descriptor omitted the service capability | -| `resource was not found` | ID was released, revoked during shutdown, or never existed | -| `resource is owned by another plugin` | Token or service type does not match the ID | -| `media callback is in progress` | Defer update/release until the callback returns | -| `... limit reached` / `... exceed ... limit` | Release existing resources or reduce copied data | - -Log failures with the operation and resource ID, but do not log secrets or raw cover/translation payloads. +Current per-plugin resource limits are 64 Contexts, 4 Media sources (32 MiB total), 16 i18n bundles (4 MiB), 8 Widgets (4 MiB), 4 lyric transformers, 1 Settings page (2 MiB), 64 Images (64 MiB), and 16 Host State subscriptions. The host rejects stale or foreign handles and quota overflows using `PluginStatus`. Releases should occur before successful shutdown; the host revokes remaining resources afterward. diff --git a/Page/zh/plugin-dev.md b/Page/zh/plugin-dev.md index 1cf09e68..af30de11 100644 --- a/Page/zh/plugin-dev.md +++ b/Page/zh/plugin-dev.md @@ -1,79 +1,61 @@ # 插件开发 -WinIsland 插件 API `0.6` 发布了原生 ABI v1。插件是直接加载进 WinIsland 进程的 Windows DLL,可以发布紧凑 Context 和可配置小组件、替换当前显示的媒体源、转换解析后的歌词、注册翻译,以及读取宿主当前状态。 +WinIsland 使用 ABI v2 加载受信任的 Windows 原生 DLL。当前 `winisland-plugin-api` crate 版本为 `0.8`。宿主会拒绝 ABI v1 安装包与入口。插件可以提供 Context、媒体源、小组件、翻译、歌词转换、设置页和持久化数据。 -> 插件属于受信任的原生代码。它没有沙箱、进程隔离、权限弹窗或崩溃隔离。安装和分发插件时,应当像对待桌面可执行程序一样谨慎。 - -ABI v1 不兼容旧版 `0.2` 的 `PluginVTable`、`PluginType`、`plugin_get_instance` 和 `plugin_set_host_api` 接口。 +> 插件与 WinIsland 在同一进程运行,没有沙箱。`extern "C"` 回调中的 panic 可能导致应用退出。 ## 文档导航 -| 文档 | 适用场景 | +| 指南 | 内容 | |---|---| -| [快速开始](/plugin-dev/quickstart) | 创建、构建并加载一个完整的 Context 插件 | -| [ABI 与生命周期](/plugin-dev/abi-lifecycle) | Descriptor 校验、能力、线程、FFI 规则、shutdown 和 0.2 迁移 | -| [宿主服务](/plugin-dev/services) | Context、Media、歌词转换、国际化、Host State、资源限制和回调行为 | -| [打包与安装](/plugin-dev/packaging) | `PluginPackager`、`plugin.yml`、ZIP 校验、更新、回滚和排障 | -| [API 更新日志](/api-changelog) | 已发布的 API 版本与破坏性变更 | +| [快速开始](/plugin-dev/quickstart) | 构建、加载并打包 ABI v2 插件 | +| [ABI 与生命周期](/plugin-dev/abi-lifecycle) | Descriptor 校验、所有权、回调、卸载和迁移 | +| [宿主服务](/plugin-dev/services) | 十一张服务表、绘制、设置与限制 | +| [打包与安装](/plugin-dev/packaging) | `plugin.yml`、ZIP、签名、安装和更新 | +| [API 更新日志](/api-changelog) | 已发布 crate 的历史记录 | -即使最终要开发 Media 或国际化插件,也建议先完成快速开始。所有插件都必须遵守其中相同的入口 Descriptor 和生命周期契约。 +精确 Rust 签名请查看 [SDK README](https://github.com/WinIslandProject/WinIsland/tree/master/crates/winisland-plugin-api) 和 [ABI 定义](https://github.com/WinIslandProject/WinIsland/tree/master/crates/winisland-plugin-api/src/abi)。 -## 运行架构 +## 运行模型 ```text -插件 DLL 导出 winisland_plugin_entry_v1() - -> PluginDescriptorV1 - -> WinIsland 校验 ABI、能力、元数据和回调 - -> WinIsland 签发 PluginToken 并调用 create(PluginCreateInfoV1) - -> 插件查询版本化的 HostApiV1 服务表 - -> 插件创建由宿主管理、以 ResourceId 标识的资源 - -> WinIsland 调用 shutdown(handle) - -> WinIsland 回收剩余资源 - -> WinIsland 调用 destroy(handle) 并卸载 DLL +DLL 导出 winisland_plugin_entry_v2() -> 静态 PluginDescriptorV2 + -> 宿主校验 ABI、能力、回调和元数据 + -> 宿主携带 token 与 PluginHostV2 调用 create(PluginCreateInfoV2) + -> 插件查询版本化服务表并创建资源 + -> 可选的 descriptor.on_tick 在插件工作线程执行 + -> 小组件提交完整绘制列表;宿主校验并重放 + -> 宿主调用 shutdown(handle),再调用 destroy(handle),最后卸载 DLL ``` -生命周期严格为 `create -> shutdown -> destroy`。`shutdown` 必须同步停止所有 worker,并 join 每一个可能继续执行插件代码的线程。只要 `shutdown` 返回错误,WinIsland 就不会调用 `destroy` 或卸载 DLL。 - -## 按需声明能力 +`PluginDescriptorV2.capabilities` 声明要使用的服务。每项资源归宿主签发的 `PluginToken` 所有。服务表通过 `PluginHostV2.query` 获取,版本为 `IFACE_VERSION_1`,与顶层 `ABI_VERSION_2` 不同。 -`PluginDescriptorV1.capabilities` 同时是功能声明和授权边界。只声明插件实际使用的服务。 - -| 能力 | 服务 | 典型用途 | +| 能力 | 服务表 | 用途 | |---|---|---| -| `CAPABILITY_CONTEXT` | `ContextApiV1` | 构建状态、计时器、持续活动、紧凑文本 | -| `CAPABILITY_MEDIA` | `MediaApiV1` | 自定义正在播放来源和可选播放控制 | -| `CAPABILITY_I18N` | `I18nApiV1` | 为支持的语言注册插件翻译键 | -| `CAPABILITY_HOST_STATE` | `HostStateApiV1` | 读取当前显示的媒体和明暗主题 | -| `CAPABILITY_WIDGET` | `WidgetApiV1` | 渲染可在设置布局编辑器中管理的小组件 | -| `CAPABILITY_LYRICS_TRANSFORM` | `LyricsTransformApiV1` | 转换解析后的歌词文本并保留时间轴 | - -仅查询到服务并不代表获得授权。每次资源调用还会携带宿主签发的 `PluginToken`,未声明相应能力时,宿主会拒绝调用。 - -## 所有权模型 - -- WinIsland 为每个已加载实例签发一个非零 `PluginToken`。 -- 服务的 create/register 调用返回非零 `ResourceId`。 -- 一个 token 只能更新或释放自己拥有、且类型匹配的资源。 -- 插件应在 `shutdown` 中释放资源;shutdown 成功后,WinIsland 会回收遗漏资源。 -- 插件工作线程可以调用宿主服务;资源变化会唤醒 WinIsland 事件循环。 -- DLL 及其中的函数指针必须持续有效,直到 shutdown 完成且所有回调均已返回。 +| `CAP_CONTEXT` | `ContextApiV2` | 活动状态文字 | +| `CAP_MEDIA` | `MediaApiV2` | 媒体源、封面和控制 | +| `CAP_I18N` | `I18nApiV2` | 翻译资源 | +| `CAP_HOST_STATE` | `HostStateApiV2` | 媒体/主题快照与订阅 | +| `CAP_WIDGET` | `WidgetApiV2` | 小组件与绘制列表 | +| `CAP_LYRICS` | `LyricsTransformApiV2` | 已解析歌词转换 | +| `CAP_SETTINGS` | `SettingsApiV2` | 声明式设置页 | +| `CAP_TEXT` | `TextApiV2` | 文字测量和字族 | +| `CAP_IMAGE` | `ImageApiV2` | 图片及当前专辑封面 | +| `CAP_STORE` | `StoreApiV2` | 插件独立命名空间的持久化数据 | + +`LogApiV2` 无需能力位。只声明插件实际需要的能力。 ## 开发流程 -1. 创建 `cdylib` crate,并依赖 `winisland-plugin-api = "0.6"`。 -2. 只导出一个返回静态 Descriptor 的 `winisland_plugin_entry_v1` 函数。 -3. 校验 `PluginCreateInfoV1`,查询已声明服务,并返回不透明实例 handle。 -4. 在插件状态中保存所有由宿主签发的资源 ID。 -5. 在 `shutdown` 中停止 worker、释放资源;`destroy` 只负责释放插件自身内存。 -6. 执行 `cargo check`、严格 Clippy 和 `cargo build --release`。 -7. 将一个入口 DLL 与可选依赖/资源打进 ZIP,把 ZIP 拖到 WinIsland 上安装。 - -## 兼容性契约 - -crate 版本 `0.6.x` 提供 ABI 版本 `1`。运行时兼容性由 `ABI_VERSION_1`、每个结构体的 `struct_size` 和服务表版本决定,而不是 DLL 加载时的 Rust crate 元数据。 +1. 创建 Rust `cdylib`,依赖 `winisland-plugin-api = "0.8"`。 +2. 导出返回静态 `PluginDescriptorV2` 的 `winisland_plugin_entry_v2`。 +3. 在 `create` 中校验 `PluginCreateInfoV2`,使用 SDK `Host::from_raw` 或原始服务表。 +4. 保留资源句柄直到 `shutdown`;在宿主表有效时释放。 +5. `shutdown` 返回成功前停止并 join 插件线程;`destroy` 释放不透明实例。 +6. 使用根目录 `plugin.yml`、`abi-version: 2` 和入口 DLL 制作 ZIP;拖到岛上安装或更新。 -所有公共 ABI 结构体均使用 `#[repr(C)]`。存在 `Default` 时应使用它初始化版本化结构体,插件也不得假设 `struct_size` 之外的字段存在。新的不兼容 ABI 应使用新的入口符号和 ABI 版本,不能直接改坏 ABI v1。 +SDK 封装常用调用并构建绘制列表。复杂控件和设置变更需要原始 ABI。插件绘制代码不会在渲染线程运行。 -## 下一步 +## 兼容性 -先构建[最小插件](/plugin-dev/quickstart)。加入工作线程或回调前,应完整阅读 [ABI 与生命周期](/plugin-dev/abi-lifecycle)。[服务参考](/plugin-dev/services)记录了准确的限制和所有权规则,[打包与安装](/plugin-dev/packaging)则覆盖分发与更新失败处理。 +crate `0.8`、顶层 `ABI_VERSION_2` 与服务表 `IFACE_VERSION_1` 是不同的版本号。读取表字段前检查 `struct_size` 与 `version`。ABI v1 DLL 必须迁移源码并重新打包;只改 `plugin.yml` 无法将其转换为 v2。 diff --git a/Page/zh/plugin-dev/abi-lifecycle.md b/Page/zh/plugin-dev/abi-lifecycle.md index a3ebd17b..8a1b41e0 100644 --- a/Page/zh/plugin-dev/abi-lifecycle.md +++ b/Page/zh/plugin-dev/abi-lifecycle.md @@ -1,192 +1,62 @@ # ABI 与生命周期 -ABI v1 是进程内原生契约。宿主和插件之间只交换 C 兼容值、不透明 handle、复制后的服务表,以及具有明确生命周期的借用指针。正确 shutdown 本身就是内存安全的一部分:如果 DLL 中仍有线程或回调正在执行,卸载后就会跳转到已经解除映射的代码。 +ABI v2 是进程内原生契约。边界上只传递 C 兼容值、带长度的结构体、不透明句柄和借用字节区间。宿主无法验证任意原生指针是否可读;插件必须保证指针在约定期间有效。 -## 入口与 Descriptor +## 入口和 Descriptor -每个 ABI v1 插件只导出以下符号: +导出 `winisland_plugin_entry_v2`,返回在 DLL 卸载前始终有效且不变的 Descriptor: ```rust +/// # Safety +/// WinIsland 使用 ABI v2 入口签名调用该符号。 #[unsafe(no_mangle)] -pub unsafe extern "C" fn winisland_plugin_entry_v1() -> *const PluginDescriptorV1 { +pub unsafe extern "C" fn winisland_plugin_entry_v2() -> *const PluginDescriptorV2 { &DESCRIPTOR } ``` -返回的 Descriptor 必须在整个 DLL 生命周期内保持可读且不变,通常应实现为 `static`。 +`PluginDescriptorV2` 包含 `struct_size`、`abi_version = ABI_VERSION_2`、能力位、`PluginMetadataC`、必需的 `create`/`shutdown`/`destroy` 和可选 `on_tick`。入口缺失或为空、Descriptor 过短、ABI 错误、未知能力位、缺少生命周期回调或插件 ID 无效时,宿主会拒绝加载。打包 DLL 的 ID、名称、版本、作者和描述必须与 `plugin.yml` 相同。 -出现以下情况时,WinIsland 会拒绝 Descriptor: +## 创建与服务查询 -- 缺少入口符号,或入口返回空指针; -- `struct_size` 小于 ABI v1 Descriptor 前缀; -- `abi_version` 不是 `ABI_VERSION_1`; -- 设置了未知 capability bit; -- 缺少 `create`、`shutdown` 或 `destroy`; -- 插件 ID 为空,或包含 `[a-zA-Z0-9_-]` 之外的字符; -- 存在安装包 manifest,但其中的 ID、名称、版本、作者或描述与 DLL Descriptor 不一致。 +宿主向 `create` 提供 `PluginCreateInfoV2`,其中有非零 `PluginToken` 和实例专属 `PluginHostV2`。使用前校验两个指针、`struct_size`、`abi_version` 和 token。SDK 用户可通过 `Host::from_raw(info.host_api, info.plugin_token)` 校验宿主表。 -兼容的未来版本可以在已知 `struct_size` 前缀后追加字段。插件绝不能读取对方声明大小之外的字段。 +原始 ABI 调用者使用 `PluginHostV2.query(context, interface_id, IFACE_VERSION_1)`。返回的每张表都以 `TablePrefix { struct_size, version, context }` 开头。应校验表头及所需的可选函数槽。声明能力位才允许调用对应服务;拿到服务表本身不授予权限。日志服务没有能力位。 -## 能力协商 +`create` 成功时必须写入非空 `PluginHandleV2`,并返回 `PluginStatus::Ok`。如果失败时留有非空的部分实例,宿主会调用 `shutdown`;若清理成功,再调用 `destroy`。部分实例清理失败时,宿主会保留 DLL 和服务表直到进程退出。没有已初始化状态时应留下空 handle。 -加载前在 `PluginDescriptorV1` 中设置能力位: +## 资源所有权 -```rust -capabilities: CAPABILITY_CONTEXT | CAPABILITY_MEDIA, -``` - -在 `create` 中复制所需服务表: - -```rust -let host = unsafe { &*info.host_api }; -let context = unsafe { host.context_api() }; -let media = unsafe { host.media_api() }; -``` - -辅助方法会校验宿主 ABI header、调用 `query_interface`,再校验返回服务表的大小与版本。任何部分不可用时都会返回 `None`。表内函数槽仍然是可选值,因此创建插件状态前还应校验每个必需函数。 - -声明能力不代表必须调用表中全部函数;但如果没有声明能力,即使成功查询到表,对应宿主调用仍会返回错误。 - -## Create 契约 - -宿主会先注册 plugin token,再调用 `create`,所以初始化期间已经可以使用宿主服务。 - -`create` 必须: - -1. 拒绝空 `create_info` 和 `out_handle` 指针。 -2. 校验 `PluginCreateInfoV1` 前缀大小和 ABI 版本。 -3. 拒绝空 `host_api` 和 `INVALID_ID` token。 -4. 查询并校验所有必需服务与函数槽。 -5. 构造回调和 worker 所需的全部状态。 -6. 向 `out_handle` 写入一个非空不透明 handle,再返回 `PluginResultC::ok()`。 - -返回成功但 handle 为空属于无效插件,宿主会拒绝加载。 - -如果失败时尚未创建任何需要清理的状态,保持 `out_handle` 为空并返回错误。如果部分初始化必须执行清理,插件可以写入一个 cleanup handle 后返回错误。WinIsland 会对该 handle 执行正常的 `shutdown -> destroy` 清理流程。只有在 `shutdown` 能清理所有已初始化字段时,才可以发布 handle。 - -`PluginResultC::err` 会把错误信息复制到 UTF-8 安全的定长缓冲区。错误应当可操作,因为它会进入 WinIsland 日志和安装失败提示。 - -## 不透明实例 Handle - -`PluginHandle` 是 `*mut c_void`。WinIsland 只保存它,不会解引用。Rust 插件通常这样创建: - -```rust -let instance = Box::new(Instance { /* ... */ }); -unsafe { out_handle.write(Box::into_raw(instance).cast()) }; -``` - -回调和 shutdown 中只借用,不接管所有权: - -```rust -let instance = unsafe { &mut *handle.cast::() }; -``` - -只在 `destroy` 中恢复一次所有权: - -```rust -unsafe { drop(Box::from_raw(handle.cast::())) }; -``` - -不要在 `shutdown` 中恢复 `Box`。shutdown 可能返回错误并被重试;后续成功后,WinIsland 仍需用同一个 handle 调用 `destroy`。 - -## Shutdown 契约 - -进入 `shutdown` 前,WinIsland 会把插件标记为 stopping。新的 Media 命令不会再派发;如果已有 Media 或 Lyrics Transform 回调正在执行,卸载会被拒绝。 - -`shutdown` 必须按顺序完成: - -1. 通知所有插件 worker 停止。 -2. join 每个可能执行插件代码、调用插件回调或宿主服务的线程。 -3. 确保外部回调不再持有插件函数或数据指针。 -4. 使用原始 token 和资源 ID 释放宿主资源。 -5. 只有确认不会再异步执行插件代码时才返回成功。 - -如果上一次 shutdown 返回错误,该函数必须能够安全重试。WinIsland 会保留 DLL 和 handle,之后可能再次尝试卸载。插件状态必须能区分已经停止的 worker 和已经释放的资源。 - -不要在持有 worker 退出所需 mutex 时等待 join。常见做法是先从状态中取出 join handle,释放状态锁,再执行 join。 - -如果宿主资源 release 失败,应保留资源 ID 并返回错误以便重试,除非能够证明继续执行是安全的。release 成功之前,不要把本地 ID 标成无效。 - -## Destroy 契约 - -shutdown 成功后,`destroy` 只调用一次。它没有结果返回通道,应只释放不透明实例中剩余的插件自有内存。 - -此时: - -- worker 已全部 join; -- 回调不会再进入插件; -- 宿主资源已经由插件释放,或由 WinIsland 回收; -- 不再需要调用宿主服务。 - -`destroy` 返回后,WinIsland 会卸载动态库。所有指向插件代码、静态数据、vtable、线程局部状态或 callback data 的指针都会失效。 - -## 线程与回调 - -宿主资源函数带同步保护,可以从插件 worker 线程调用。宿主会在返回前复制借用输入,并在可见状态变化时唤醒 WinIsland 事件循环。 - -Media 命令回调不同: - -- WinIsland 在事件循环线程同步调用它; -- `callback_data` 必须有效到 Media 资源成功 release; -- 回调可以调用宿主服务; -- 回调执行期间,更新或释放同一 Media 资源会返回错误; -- 回调应当只入队任务并尽快返回,阻塞它会同时阻塞 WinIsland 输入和渲染。 - -不要调用假设自己运行在 worker 线程的 UI 或框架代码。需要异步处理时,把命令复制进 worker 拥有的 channel,并确保 shutdown 能停止和 join 该 worker。 - -Lyrics Transform 回调会在歌词获取 worker 上同步执行,每个解析后的歌词行调用两次:先查询 -所需大小,再实际写入。Callback data 必须有效到 release 成功。两次调用之间的转换结果必须 -确定一致,转换逻辑也必须有界且线程安全。歌词回调执行期间 release 和卸载都会被拒绝。 - -## FFI 数据规则 - -- 所有公共 ABI 结构均为 `#[repr(C)]`。 -- 存在 `Default` 时,用它初始化版本化输入/输出结构。 -- 必需指针必须非空、满足对应类型对齐,并在整个同步调用期间可读或可写。 -- 定长字节数组是 NUL 结尾 UTF-8 字段;`str_to_fixed` 截断时不会切断 UTF-8 code point。 -- `ByteSliceV1` 和 `Utf8SliceV1` 是借用的 `(ptr, len)` 范围,不是 NUL 结尾字符串。 -- 除非字段另有说明,借用切片只需有效到宿主函数返回。 -- 不要跨 ABI 传递 Rust reference、`String`、`Vec`、trait object、可展开 panic 或编译器私有布局。 -- 绝不能让 panic 穿过 `extern "C"` 边界;应在插件内部捕获,或使用 abort panic 策略。 +`PluginToken`、`ResourceId`、`WidgetId` 和 `ImageId` 是不透明身份。资源归创建它的 token 所有;过期、其他插件或类型错误的句柄会被拒绝。同步服务调用会复制借用的请求数据。SDK 的 `Resource`、`Widget` 和 `ImageHandle` 在 drop 时释放资源;应在 `shutdown` 返回前 drop。原始 ABI 调用者须显式释放 ID。成功 shutdown 后宿主撤销剩余资源。 -受信任插件模型无法判断一个非空插件指针是否真的指向足够的可访问内存,指针有效性仍由插件负责。 +`PluginStatus` 包括 `Ok`、`InvalidArgument`、`StaleHandle`、`CapabilityMissing`、`LimitExceeded`、`UnsupportedVersion`、`IoError` 和 `Internal`。绘制列表提交成功只表示字节已复制,校验与渲染稍后进行。 -## 版本策略 +## Tick、回调与卸载 -这里存在三种相关版本: +宿主在插件工作线程调用可选的 `PluginDescriptorV2.on_tick(handle, widget_id, dt_seconds)`,不会在渲染线程调用它。小组件通过 `WidgetApiV2.submit_draw_list` 提交绘制字节;宿主校验并重放完整列表。媒体命令、宿主状态通知、设置变更和歌词转换也由宿主调度;回调数据要保留到安全释放为止。歌词转换先查询输出长度再写入,两次结果必须一致。 -| 值 | 含义 | -|---|---| -| crate `0.6.x` | 包含 ABI v1 定义的 Rust 包版本 | -| `ABI_VERSION_1` | 顶层 Descriptor 和 create-info ABI | -| `INTERFACE_VERSION_1` | 单个宿主服务表的版本 | +`shutdown` 返回 `Ok` 前须停止并 join 插件自己的全部线程,完成回调活动并释放资源。它应支持重试。`destroy` 在成功 shutdown 后调用一次,释放不透明实例;之后才卸载 DLL。shutdown 失败时,宿主将 DLL 和服务表保留到进程退出。插件错误地声称线程已结束,宿主无法识别。 -兼容的服务表扩展会追加字段并增大 `struct_size`,但不改变 v1 前缀。不兼容的布局或生命周期变更必须使用新的 ABI 数字和入口符号。 +Release 构建采用 aborting panic。插件 C 回调中的 panic 可能立即终止进程。下次启动时,WinIsland 检查活动插件标记,禁用对应插件并显示恢复提示;首次崩溃无法在原进程内恢复。 -## 从 0.2 迁移 +## FFI 规则 -ABI v1 是整体重写,不是原地升级。 +- 使用 ABI 的 `#[repr(C)]` 类型与精确回调签名。不能跨边界传递 Rust `String`、`Vec`、引用、trait object 或向外 unwind 的 panic。 +- 固定元数据缓冲区是 NUL 结尾 UTF-8;`Utf8Slice` 和 `ByteSlice` 是借用的指针/长度对,不是 C 字符串。 +- 借用输入须活到同步宿主调用结束;回调数据须活到没有回调再进入它。 +- 读取字段前检查 `struct_size` 和表版本。服务表 `IFACE_VERSION_1` 与顶层 `ABI_VERSION_2` 不同。 +- 成功 shutdown 后不得保留宿主表或回调指针。 -| 0.2 模式 | ABI v1 替代方案 | -|---|---| -| `plugin_get_instance` | 返回 `PluginDescriptorV1` 的 `winisland_plugin_entry_v1` | -| `PluginVTable` / `PluginInstanceC` | Descriptor 生命周期回调和不透明 `PluginHandle` | -| `plugin_set_host_api` / `HostApiC` | `PluginCreateInfoV1.host_api` 和 `query_interface` | -| `PluginType` provider | 显式 capability bitset | -| 插件定义 Context ID | 宿主签发的 `ResourceId` | -| 全局 push/clear 调用 | 各服务的 create、update、release 操作 | -| 未完成的 Theme/Shortcut API | ABI v1 无对应接口 | +## 从 ABI v1 迁移 -删除所有旧导出。一个 DLL 应只导出 ABI v1 入口,并全部使用 0.6 类型;不支持混合两套布局。 +当前宿主没有 ABI v1 兼容路径。使用 `winisland-plugin-api` v2 类型重建源码,导出 `winisland_plugin_entry_v2`,把能力名改为 `CAP_*`,把 `PluginResultC` 改为 `PluginStatus`,并将渲染线程回调绘制改为提交小组件绘制列表。新 ZIP 的 `abi-version` 应为 `2`。只改旧 DLL 文件名或 manifest 不够。 ## 审查清单 -- Descriptor 为 static,并包含全部必需生命周期函数。 -- 元数据与 `Cargo.toml`、打包后的 `plugin.yml` 完全一致。 -- 每个查询的服务都有对应 capability bit。 -- 更新本地所有权状态前检查每个宿主结果。 -- Callback data 的生命周期长于对应注册资源。 -- shutdown 成功前已通知并 join 所有 worker。 -- shutdown 可重试,不会 double-free 或重复 join。 -- destroy 只释放一次实例,不再执行插件工作。 -- 没有 panic、Rust 私有布局或无边界指针跨越 ABI。 +- Descriptor 与安装包元数据一致,能力位均受支持。 +- 检查所需服务表及函数槽。 +- 资源和回调数据活过使用它们的全部宿主调用。 +- 绘制列表完整且有长度边界。 +- 成功 shutdown 前 join 插件线程。 +- Shutdown 可重试;destroy 只释放一次 handle。 +- C 边界上没有 panic 或 Rust 专属布局。 diff --git a/Page/zh/plugin-dev/packaging.md b/Page/zh/plugin-dev/packaging.md index cc0f04a3..d0426125 100644 --- a/Page/zh/plugin-dev/packaging.md +++ b/Page/zh/plugin-dev/packaging.md @@ -1,51 +1,14 @@ # 打包与安装 -WinIsland 使用 ZIP 分发插件。安装包包含一个根目录 `plugin.yml` 和一个声明的入口 DLL。归档可以附带依赖 DLL 与资源,但 WinIsland 只把 `entry` 作为插件加载。 +可分发的 ABI v2 插件是 ZIP,根目录含 `plugin.yml` 及 `entry` 指定的 DLL。也可加入依赖 DLL 和资源。WinIsland 只把指定入口当作插件加载。 -## 发布到插件市场 +## 使用 PluginPackager 构建 -市场插件必须在公开的 GitHub 仓库中完整开源,并让 GitHub 识别出 SPDX 开源许可证。在插件仓库中添加 `.github/workflows/release.yml`: - -```yaml -name: Release WinIsland plugin - -on: - push: - tags: - - "v*" - -permissions: - contents: write - id-token: write - attestations: write - -jobs: - release: - uses: WinIslandProject/PluginMarketplace/.github/workflows/build-plugin.yml@main -``` - -这个复用工作流会执行 check、严格 Clippy、格式检查和官方 Packager,然后发布带有构建来源证明的 GitHub Release。推送 `v1.0.0` 之类的版本标签即可发布。 - -第一次 Release 成功后,向 [WinIslandProject/PluginMarketplace](https://github.com/WinIslandProject/PluginMarketplace) 提交 PR,只添加一个 `plugins/.toml`: - -```toml -schema = 1 -id = "example-clock" -repository = "owner/example-clock" -asset = "*.winisland-plugin.zip" -categories = ["widget", "utility"] -min_winisland_version = "1.2.9" -``` - -ID 必须同时与文件名和 `plugin.yml` 一致。后续更新不需要再次提交市场 PR,只需发布新的有效 GitHub Release,市场目录会自动发现它。完整审核规则见市场仓库的[贡献指南](https://github.com/WinIslandProject/PluginMarketplace/blob/main/CONTRIBUTING.md)。 - -## 添加 Packager - -把可选 `packager` feature 加为开发依赖: +为构建工具启用 packager feature。在 ABI v2 crate 可从注册表取得之前使用当前仓库源码;包版本为 `0.8.0`。 ```toml [dev-dependencies] -winisland-plugin-api = { version = "0.6", features = ["packager"] } +winisland-plugin-api = { git = "https://github.com/WinIslandProject/WinIsland", features = ["packager"] } [[example]] name = "pack" @@ -61,292 +24,53 @@ fn main() { PluginPackager::from_cargo() .expect("read Cargo.toml") .build() - .expect("build plugin package"); + .expect("build plugin ZIP"); } ``` -在插件项目根目录执行: - -```powershell -cargo run --example pack -``` - -Packager 会依次: - -1. 执行 `cargo build --release`。 -2. 使用 `[lib].name` 定位构建产物;没有时使用把包名中 `-` 替换为 `_` 的名称。 -3. 把入口 DLL 和指定附加目录复制到临时 staging 目录。 -4. 计算 DLL hash。 -5. 生成并校验 `plugin.yml`。 -6. 可选地对 manifest payload 签名。 -7. 如果没有指定输出路径,写入 `target/-.zip`。 +在插件项目根目录运行 `cargo run --example pack`。Packager 执行 `cargo build --release --locked`,寻找 `target/release/.dll`,复制资源,生成带 DLL hash 的 `plugin.yml`,可选签名,校验 DLL Descriptor,然后默认写入 `target/-.zip`。打包前应提交插件的 `Cargo.lock`。 -如果插件项目根目录存在 `icon.png`、`icon.jpg`、`icon.jpeg` 或 `icon.webp`,Packager 会把第一个匹配文件作为插件头像加入安装包。它也会自动加入第一个匹配的 `README.md`、`README.markdown` 或 `README.txt`。可以用 `.icon("路径")` 和 `.readme("路径")` 显式覆盖自动识别结果。 +`from_cargo()` 读取 `package.name`、`version`、`authors`(以 `:` 连接)、`description`、`repository`、可选的 `package.metadata.winisland.id`/`name` 和 `lib.name`。根目录第一个匹配的图标(`icon.png`、`.jpg`、`.jpeg`、`.webp`)及 README(`README.md`、`.markdown`、`.txt`)会自动加入。可用 `icon()`、`readme()`、`include_dir()`、`dll_path()` 和 `output()` 覆盖默认值。 -## Cargo 元数据与 Descriptor 一致性 +Descriptor 的 ID、名称、版本、作者和描述必须与生成的 manifest 完全一致。Packager 和安装器在激活前都会加载 DLL 校验 Descriptor。ID 应稳定,并且只能使用 1–63 个 ASCII 字母、数字、下划线或连字符。 -`PluginPackager::from_cargo()` 读取: - -| Cargo 字段 | Manifest/Descriptor 字段 | -|---|---| -| `package.name` | 默认 `id` 和 `name` | -| `package.version` | `version` | -| `package.authors` 第一个值 | `author` | -| `package.description` | `description` | -| `package.repository` | `github-link` | -| `lib.name` | 入口 DLL 文件名 | - -生成 manifest 的 `id`、`name`、`version`、`author` 和 `description` 必须与 `PluginMetadataC` 完全一致。WinIsland 会在 staging 中加载 DLL;如果字段不同,会在停止已安装旧插件之前拒绝新包。 - -如果显示名称或 ID 与 Cargo 默认值不同,应在两侧显式配置: - -```rust -PluginPackager::from_cargo() - .unwrap() - .id("example-clock") - .name("Example Clock") - .author("Example Author") - .description("Shows the current time in WinIsland") - .build() - .unwrap(); -``` - -```rust -metadata: PluginMetadataC::new( - "example-clock", - "Example Clock", - env!("CARGO_PKG_VERSION"), - "Example Author", - "Shows the current time in WinIsland", -), -``` - -插件 ID 必须为 1 到 63 个 ASCII 字节,并匹配 `[a-zA-Z0-9_-]+`。发布后应保持 ID 稳定,因为它决定安装目录和更新目标。 - -## Manifest 格式 - -生成的 manifest 示例: +## Manifest ```yaml id: hello-winisland-plugin name: hello-winisland-plugin author: Example Author version: 0.1.0 -description: Minimal WinIsland ABI v1 plugin +description: Minimal WinIsland ABI v2 plugin github-link: https://github.com/example/hello-winisland-plugin -abi-version: 1 +abi-version: 2 entry: hello_winisland_plugin.dll -icon: icon.png -readme: README.md -dll_hashes: - - 75f1cd58a8bbf6dd32a68415c13e4065a827b603ab70542032fdd722d98a4f4d -``` - -宿主必需字段: - -| 字段 | 规则 | -|---|---| -| `id` | 与 Descriptor 一致的稳定 ASCII 插件 ID | -| `name` | 非空,最多 127 UTF-8 字节,与 Descriptor 一致 | -| `author` | 非空,最多 127 UTF-8 字节,与 Descriptor 一致 | -| `version` | 非空,最多 31 UTF-8 字节,与 Descriptor 一致 | -| `description` | 非空,最多 255 UTF-8 字节,与 Descriptor 一致 | -| `github-link` | 非空,最多 2,048 UTF-8 字节 | -| `abi-version` | 必须为 `1` | -| `entry` | 一个根目录 `.dll` 文件名,最多 255 字节 | - -可选展示字段: - -| 字段 | 规则 | -|---|---| -| `icon` | 安全的相对 `.png`、`.jpg`、`.jpeg` 或 `.webp` 路径;显示在插件列表和详情栏中 | -| `readme` | 安全的相对 `.md`、`.markdown` 或 `.txt` 路径;显示在详情栏中,宿主读取上限为 1 MiB | - -声明的展示文件必须真实存在于安装包中。省略字段时,WinIsland 也会查找上面列出的常见根目录文件名,因此已有插件可以不修改 manifest,直接加入头像或 README。 - -Packager 元数据还可能包含 `dll_hashes` 和 `signature`。当前 WinIsland 宿主会反序列化必需字段,但不会强制检查 hash、签名者身份或 Ed25519 签名。因此,签名目前只记录构建元数据,不是安装信任决策;分发者必须明确说明这一点。 - -## 加入依赖与资源 - -使用 `include_dir` 添加附加目录: - -```rust -PluginPackager::from_cargo() - .unwrap() - .icon("branding/plugin-icon.png") - .readme("docs/PLUGIN.md") - .include_dir("assets") - .include_dir("runtime") - .build() - .unwrap(); ``` -路径必须为非空相对路径,并且只包含 normal path component。绝对路径、`.` component 和 `..` 穿越都会被拒绝。目录会递归复制,并保留相对路径。 - -依赖 DLL 应放在入口 DLL 旁边,或插件自身加载逻辑预期的位置。WinIsland 只对 `entry` 调用 `LoadLibrary`;依赖解析仍由 Windows loader 或插件负责。 +`id`、`name`、`author`、`version`、`description`、`github-link`、`abi-version` 和 `entry` 为必填。入口只能是 ZIP 根目录的单个 `.dll` 文件名。可选的 `icon`、`readme` 必须是归档中安全的相对路径。Packager 还可能添加 `dll_hashes` 与 `signature`。WinIsland 当前安装本地 ZIP 时不会强制校验 manifest 签名、签名者身份或 `dll_hashes`;不能把这个可选签名描述为安装信任保证。 -不要加入签名私钥、`.env`、构建凭证、包含敏感路径的调试数据库或无关构建产物。 +## 安装与更新 -## 覆盖构建输入 +WinIsland 运行时将 ZIP 拖到岛上。安装器校验归档和 manifest,在暂存目录解压,加载新 DLL 校验 Descriptor 与元数据,停止旧的打包插件,切换目录并启动新实例。替换失败时恢复旧目录并尝试重新加载旧插件。成功安装立即生效,无需重启。在“插件”页面可禁用、启用或卸载。 -默认值不适用时,可以显式指定: +本地开发时,也可以把根目录 `.dll` 放入插件目录;它没有 manifest,需在启动时加载。同 ID 的手动 DLL 应先移除,打包更新无法替换这个根目录文件。 -```rust -PluginPackager::new("Example Clock") - .id("example-clock") - .version("1.0.0") - .author("Example Author") - .description("Shows the current time in WinIsland") - .github_link("https://github.com/example/example-clock") - .dll_name("example_clock") - .dll_path("target/release/example_clock.dll") - .output("target/example-clock-1.0.0.zip") - .build() - .unwrap(); -``` +归档最多 4096 项、每项解压后最多 256 MiB、合计最多 512 MiB;根目录 `plugin.yml` 最多 1 MiB。指定 `entry` 必须存在。符号链接、路径穿越、绝对路径、设备路径、不安全 Windows 文件名和忽略大小写后的冲突都会被拒绝。解压失败会清理暂存目录。 -`build()` 仍会执行 `cargo build --release`。`dll_path` 只改变复制哪个产物,不会跳过编译。 +## 签名与插件市场 -## 可选签名 +`PluginPackager::signing_key_env("WINISLAND_PLUGIN_SIGNING_KEY")` 或 `signing_key_path(...)` 使用 Ed25519 对 manifest 规范字段和 DLL hash 列表签名。当前密钥加载失败时只记录警告,继续生成未签名 ZIP;若发布流程要求签名,须检查最终 manifest。不要提交私钥。 -Packager 可以从文件或环境变量加载 Ed25519 PKCS#8 PEM 私钥: +市场安装有独立的信任路径:WinIsland 校验市场目录签名,并核对所选安装包的大小和 SHA-256。ABI 版本不兼容的目录项会被过滤。包内可选 `signature` 与市场目录签名不是一回事。 -```rust -PluginPackager::from_cargo() - .unwrap() - .signing_key_env("WINISLAND_PLUGIN_SIGNING_KEY") - .build() - .unwrap(); -``` +提交市场请查看[市场贡献指南](https://github.com/WinIslandProject/PluginMarketplace/blob/main/CONTRIBUTING.md),并核实其当前要求。插件仓库与 Release 资源必须先提供有效的 ABI v2 包,才能作为兼容项出现。 -或者: - -```rust -.signing_key_path("signing_key.pem") -``` - -CI 中优先使用环境 secret,绝不能提交私钥。当前 key 加载 builder 方法在失败时会记录 warning 并继续生成未签名包,因此发布流程要求签名时,必须检查构建输出和生成的 manifest。 - -签名覆盖由 manifest 字段和 DLL hash 组成的规范 JSON,不包含 `signature` 本身。WinIsland 安装时目前仍不会验证该签名。 - -## 归档校验限制 - -解压前,WinIsland 会使用同一个已打开文件句柄完成归档校验与解压: - -- ZIP 条目最多 4,096 个; -- 每个条目解压后最多 256 MiB; -- 解压总数据最多 512 MiB; -- 根目录必须有 `plugin.yml`,且不超过 1 MiB; -- 必须存在 manifest 精确指定、大小写一致的根目录 `entry`; -- 禁止 symlink、绝对路径、盘符/ADS 冒号、空 component、`.` 或 `..` component; -- 路径 component 不得超过 255 字节,也不得以点或空格结尾; -- 禁止 `CON`、`NUL`、`COM1`、`LPT1` 等 Windows 设备名; -- 禁止在 Windows 分隔符和大小写规范化后发生碰撞的路径。 - -解压时还会再次统计实际写入字节。校验失败会删除 staging 目录。 - -## 安装事务 - -打开“设置 > 插件”,把 ZIP 拖到安装区域,安装按以下流程执行: - -1. 后台线程校验归档并解压到隐藏 staging 目录。 -2. WinIsland 事件循环线程只为校验 Descriptor 加载一次 staging 入口 DLL。 -3. 在触碰已安装插件前,比较 manifest 与 Descriptor 元数据。 -4. 卸载 staging 校验 DLL。 -5. 已安装旧插件(如果来自包目标目录)必须成功 shutdown。 -6. 把旧目录重命名为隐藏 backup。 -7. 原子地把 staging 重命名为最终 `/` 目录。 -8. 从最终路径重新加载并初始化入口 DLL。 -9. 成功后删除 backup;失败时移除/移走新目录、恢复 backup,再重新加载旧插件。 - -校验加载与正式加载是刻意分开的。Windows 允许重命名已加载 DLL 的目录,但 loader 保存的模块路径仍会指向旧 staging 路径。从最终目标重新加载,才能保证相对资源和延迟依赖相对于安装位置解析。 - -如果新实例初始化失败后又无法停止,WinIsland 会保持其 DLL 加载,避免卸载可能仍在执行的代码。安装会报告 non-stoppable instance,不会假装已经完成回滚。 - -安装完成后,WinIsland 会刷新已安装插件列表并提示重启应用。插件可以在列表或详情栏中启用、禁用;状态按插件 ID 持久化,并在重启后生效。被禁用的打包插件会在打开 DLL 之前跳过。手动放置在插件目录根部的 DLL 仍需先读取 Descriptor,宿主才能确定它的插件 ID。 - -点击插件会打开详情栏,显示头像、元数据、仓库链接、启用开关和 README。README 使用 CommonMark 解析,并启用 GFM 表格、任务列表和删除线;WinIsland 会原生渲染标题、段落、强调、列表、引用、代码、表格、分隔线与网页链接。设置页不会下载远程图片,但会保留图片替代文本和来源链接。 - -## 手动 DLL 与安装包 - -WinIsland 还会发现插件目录根部的 `.dll`,它们属于没有 `plugin.yml` 的手动安装: - -- 手动 DLL 适合本地开发; -- ZIP 更新不能替换具有相同插件 ID 的已加载手动根 DLL; -- 安装打包版本前,先删除手动 DLL 并重启 WinIsland; -- 打包插件位于 `//`,可以执行事务更新。 - -## 检查输出 - -列出归档内容: +## 检查与排障 ```powershell tar -tf target/hello-winisland-plugin-0.1.0.zip -``` - -不解压直接查看 manifest: - -```powershell tar -xOf target/hello-winisland-plugin-0.1.0.zip plugin.yml -``` - -最小内容应为: - -```text -hello_winisland_plugin.dll -plugin.yml -``` - -发布前构建并 lint 插件: - -```powershell -cargo check --all-targets -cargo clippy --all-targets -- -D warnings -cargo build --release -cargo run --example pack -``` - -还应测试首次安装、重启发现、从旧包更新、初始化失败回滚、所有声明的 Media 控制,以及 worker 活动时 shutdown。 - -## 排障 - -### 缺少 `winisland_plugin_entry_v1` - -确认函数名完全一致,使用 `extern "C"`、`#[unsafe(no_mangle)]` 和 `cdylib` crate type。必要时用 Visual Studio 工具检查导出: - -```powershell dumpbin /exports target/release/hello_winisland_plugin.dll ``` -### Descriptor 与 manifest 元数据不一致 - -比较五个字段:ID、名称、版本、作者和描述。Cargo 的第一个 author 和 `[lib].name` 可能与预期不同。重新构建 DLL 后再打 ZIP。 - -### 缺少入口 DLL - -检查 `[lib].name`、`.dll_name(...)`、`.dll_path(...)` 和 `entry`。入口必须位于归档根目录,并且大小写完全一致。 - -### 旧插件无法 shutdown - -目录替换前更新会被停止。修复 `shutdown` 中的 worker cancellation、callback 生命周期和可重试资源 release。shutdown 失败时,WinIsland 会刻意保持旧 DLL 加载。 - -### 新插件无法初始化 - -阅读完整回滚信息。它会区分初始化失败、失败目录移除失败、backup 恢复失败,以及旧插件重载失败。先修复最前面的插件错误再重试。 - -### 安装包已签名,但 WinIsland 没有信任提示 - -这是 ABI v1 的预期行为:宿主尚未实现签名者信任和签名/hash 强制校验。真实性重要时,应通过可信渠道分发并独立发布 checksum。 - -## 发布清单 - -- 同时更新插件版本和 Descriptor 元数据。 -- 确认 Cargo 元数据与 `PluginMetadataC` 完全一致。 -- 执行 check、严格 Clippy 和 release build。 -- 从干净源码 checkout 构建安装包。 -- 检查 ZIP 内容和 `plugin.yml`。 -- 确认没有 secret 或无关文件。 -- 测试首次安装、重启、更新、回滚和卸载/shutdown。 -- 记录插件声明的能力及其用途。 -- 明确插件在进程内执行且没有沙箱。 -- 通过受控发布渠道提供 ZIP 和 checksum。 +检查 DLL 是否导出准确的 `winisland_plugin_entry_v2`,`abi-version` 是否为 `2`,`entry` 是否指向根目录 DLL,以及 Descriptor 的五项元数据是否一致。更新失败时查看安装错误:可恢复的替换失败应继续运行旧包。无法完成 shutdown 的 DLL 会保持加载,以保护仍在执行的回调。发布前验证安装、更新、禁用/启用、回滚及卸载。 diff --git a/Page/zh/plugin-dev/quickstart.md b/Page/zh/plugin-dev/quickstart.md index 475b029e..0d96dc80 100644 --- a/Page/zh/plugin-dev/quickstart.md +++ b/Page/zh/plugin-dev/quickstart.md @@ -1,30 +1,22 @@ # 插件快速开始 -本指南会构建一个完整的 ABI v1 DLL,并发布一个持续显示的 Context。示例会校验所有必需输入,保存宿主签发的资源 ID,在 shutdown 中释放资源,并且只在 `destroy` 中释放不透明实例。 +本例构建完整的 ABI v2 DLL,发布一个 Context。示例校验宿主输入,持有资源,在 shutdown 中释放资源,并在 destroy 中释放不透明实例。 ## 前置条件 -- Windows 10 2004 或更高版本,或 Windows 11 -- Stable Rust 和 `x86_64-pc-windows-msvc` 工具链 -- 安装了“使用 C++ 的桌面开发”工作负载的 Visual Studio Build Tools -- 支持插件 API `0.6` / ABI v1 的 WinIsland +- Windows 10 2004 或更新版本,或 Windows 11 +- 安装 `x86_64-pc-windows-msvc` 目标的稳定版 Rust +- Visual Studio C++ 构建工具和 Windows SDK +- 支持 ABI v2 的 WinIsland -检查工具链: - -```powershell -rustup show -rustc --version -cargo --version -``` - -## 创建项目 +## 创建库 ```powershell cargo new --lib hello-winisland-plugin cd hello-winisland-plugin ``` -在 `Cargo.toml` 中写入以下包、库和依赖配置: +使用以下 `Cargo.toml`。在 ABI v2 crate 发布到注册表前,先使用此处的仓库源码;发布后可改用匹配的 `winisland-plugin-api = "0.8"` 版本。 ```toml [package] @@ -32,7 +24,7 @@ name = "hello-winisland-plugin" version = "0.1.0" edition = "2024" authors = ["Example Author"] -description = "Minimal WinIsland ABI v1 plugin" +description = "Minimal WinIsland ABI v2 plugin" repository = "https://github.com/example/hello-winisland-plugin" [lib] @@ -40,190 +32,119 @@ name = "hello_winisland_plugin" crate-type = ["cdylib"] [dependencies] -winisland-plugin-api = "0.6" +winisland-plugin-api = { git = "https://github.com/WinIslandProject/WinIsland" } ``` -必须使用 `cdylib`,它会生成带有 ABI 导出入口的原生 DLL。Packager 也会复用包元数据,因此这些字段必须与下面的 `PluginMetadataC` 保持一致。 - -## 实现插件 +包 ID、名称、版本、作者和描述必须与 Descriptor、安装包 manifest 一致。repository URL 会成为 `github-link`。 -将以下内容作为 `src/lib.rs`: +## 实现 `src/lib.rs` ```rust use std::ffi::c_void; -use winisland_plugin_api::*; +use winisland_plugin_api::abi::{ + ABI_VERSION_2, CAP_CONTEXT, PluginCreateInfoV2, PluginDescriptorV2, + PluginHandleV2, PluginStatus, +}; +use winisland_plugin_api::sdk::{Host, Resource}; +use winisland_plugin_api::PluginMetadataC; struct Instance { - token: PluginToken, - context_api: ContextApiV1, - context_id: ResourceId, + context: Option, } -static DESCRIPTOR: PluginDescriptorV1 = PluginDescriptorV1 { - struct_size: std::mem::size_of::() as u32, - abi_version: ABI_VERSION_1, - capabilities: CAPABILITY_CONTEXT, +static DESCRIPTOR: PluginDescriptorV2 = PluginDescriptorV2 { + struct_size: std::mem::size_of::() as u32, + abi_version: ABI_VERSION_2, + capabilities: CAP_CONTEXT, metadata: PluginMetadataC::new( "hello-winisland-plugin", "hello-winisland-plugin", - "0.1.0", + env!("CARGO_PKG_VERSION"), "Example Author", - "Minimal WinIsland ABI v1 plugin", + "Minimal WinIsland ABI v2 plugin", ), create: Some(create), shutdown: Some(shutdown), destroy: Some(destroy), + on_tick: None, }; unsafe extern "C" fn create( - create_info: *const PluginCreateInfoV1, - out_handle: *mut PluginHandle, -) -> PluginResultC { - if create_info.is_null() || out_handle.is_null() { - return PluginResultC::err("null create argument"); + info: *const PluginCreateInfoV2, + out_handle: *mut PluginHandleV2, +) -> PluginStatus { + if info.is_null() || out_handle.is_null() { + return PluginStatus::InvalidArgument; } - - // SAFETY: WinIsland 提供可读取的 ABI create-info 前缀。 - let info = unsafe { &*create_info }; - if info.struct_size < std::mem::size_of::() as u32 - || info.abi_version != ABI_VERSION_1 - || info.host_api.is_null() - || info.plugin_token == INVALID_ID + // SAFETY: WinIsland supplies a readable create-info header. + let info = unsafe { &*info }; + if info.struct_size < std::mem::size_of::() as u32 + || info.abi_version != ABI_VERSION_2 + || info.plugin_token == winisland_plugin_api::PluginToken::INVALID { - return PluginResultC::err("unsupported create info"); + return PluginStatus::UnsupportedVersion; } - - // SAFETY: 校验后的宿主 API 指针在 WinIsland 运行期间保持有效。 - let host = unsafe { &*info.host_api }; - // SAFETY: `host` 来自 WinIsland,辅助方法会校验 ABI header。 - let Some(context_api) = (unsafe { host.context_api() }) else { - return PluginResultC::err("context API is unavailable"); - }; - let Some(create_context) = context_api.create else { - return PluginResultC::err("context create is unavailable"); + // SAFETY: The host table remains allocated throughout this instance's lifetime. + let host = match unsafe { Host::from_raw(info.host_api, info.plugin_token) } { + Ok(host) => host, + Err(_) => return PluginStatus::InvalidArgument, }; - if context_api.release.is_none() { - return PluginResultC::err("context release is unavailable"); - } - - let context = ContextDataV1 { - priority: PRIORITY_MEDIUM, - flags: CONTEXT_FLAG_SHOW_COMPACT, - timeout_ms: 0, - title: str_to_fixed("Hello WinIsland"), - body: str_to_fixed("ABI v1 plugin is running"), - compact_text: str_to_fixed("Hello"), - ..Default::default() + let context = match host + .context() + .and_then(|api| api.create("Hello WinIsland", "ABI v2 plugin is running")) + { + Ok(context) => context, + Err(_) => return PluginStatus::Internal, }; - let mut context_id = INVALID_ID; - // SAFETY: 输入和输出在同步调用返回前保持有效。 - let result = unsafe { create_context(info.plugin_token, &context, &mut context_id) }; - if result.status != 0 { - return result; - } - let instance = Box::new(Instance { - token: info.plugin_token, - context_api, - context_id, + context: Some(context), }); - // SAFETY: WinIsland 在 `destroy` 之前只把该指针作为不透明 handle 使用。 + // SAFETY: WinIsland treats this pointer as opaque until destroy. unsafe { out_handle.write(Box::into_raw(instance).cast::()) }; - PluginResultC::ok() + PluginStatus::Ok } -unsafe extern "C" fn shutdown(handle: PluginHandle) -> PluginResultC { +unsafe extern "C" fn shutdown(handle: PluginHandleV2) -> PluginStatus { if handle.is_null() { - return PluginResultC::ok(); + return PluginStatus::InvalidArgument; } - - // SAFETY: `handle` 由 `Box` 创建,且尚未 destroy。 + // SAFETY: This handle was created above and has not been destroyed. let instance = unsafe { &mut *handle.cast::() }; - if instance.context_id != INVALID_ID { - let Some(release) = instance.context_api.release else { - return PluginResultC::err("context release is unavailable"); - }; - // SAFETY: 该资源属于当前实例的宿主 token。 - let result = unsafe { release(instance.token, instance.context_id) }; - if result.status != 0 { - return result; - } - instance.context_id = INVALID_ID; - } - PluginResultC::ok() + drop(instance.context.take()); + PluginStatus::Ok } -unsafe extern "C" fn destroy(handle: PluginHandle) { +unsafe extern "C" fn destroy(handle: PluginHandleV2) { if !handle.is_null() { - // SAFETY: shutdown 成功后,WinIsland 只调用一次 destroy。 + // SAFETY: WinIsland calls destroy once after successful shutdown. unsafe { drop(Box::from_raw(handle.cast::())) }; } } -#[unsafe(no_mangle)] /// # Safety -/// WinIsland 使用文档规定的 ABI v1 签名调用该函数。 -pub unsafe extern "C" fn winisland_plugin_entry_v1() -> *const PluginDescriptorV1 { +/// WinIsland calls this exported symbol using the ABI v2 entry signature. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn winisland_plugin_entry_v2() -> *const PluginDescriptorV2 { &DESCRIPTOR } ``` -## 校验 DLL +`PluginStatus` 是数字状态,不包含错误字符串。需要详细诊断时使用 `LogApiV2`。任何导出的 C 回调都不能向外 unwind。 -生成 release 二进制前执行: +## 构建并加载 ```powershell -cargo check --all-targets -cargo clippy --all-targets -- -D warnings +cargo check +cargo clippy -- -D warnings cargo build --release ``` -DLL 输出到: - -```text -target/release/hello_winisland_plugin.dll -``` - -如果没有生成 DLL,检查 `[lib].crate-type = ["cdylib"]`。如果找不到 linker,请安装或修复 Visual Studio MSVC 构建工具。 - -## 开发阶段加载 - -快速本地验证时,可以退出 WinIsland,把 DLL 直接放进 WinIsland 插件目录,再重启 WinIsland。根目录 DLL 会被视为手动安装插件。检查 WinIsland 日志中是否出现: - -```text -Loaded ABI v1 plugin: hello-winisland-plugin ... -``` - -或者包含 DLL 名称的校验错误。 - -手动根 DLL 适合开发,但正式分发应使用 ZIP。打包插件不能自动替换手动安装的根 DLL,更新前需要先删除根 DLL。 - -## 安全地更新 Context - -使用保存的 token 和资源 ID 调用 `ContextApiV1::update`,不要每次刷新都创建新 Context。 - -```rust -let updated = ContextDataV1 { - title: str_to_fixed("Task complete"), - body: str_to_fixed("The release build finished"), - compact_text: str_to_fixed("Complete"), - timeout_ms: 5_000, - ..Default::default() -}; - -let result = unsafe { - instance.context_api.update.unwrap()( - instance.token, - instance.context_id, - &updated, - ) -}; -``` +DLL 位于 `target/release/hello_winisland_plugin.dll`。本地开发时可将它放入 WinIsland 插件目录根部,重启应用加载。根目录 DLL 属于没有 `plugin.yml` 的手动安装;同 ID 的打包版本安装前应移除手动 DLL。 -更新会刷新显示顺序并重新开始超时。超时后的 Context 只是不再显示,仍归插件所有,直到插件主动 release 或成功 shutdown。 +要分发 ZIP,阅读[打包与安装](/plugin-dev/packaging)。设置 `abi-version: 2`,并让 `entry` 等于 DLL 文件名。WinIsland 运行时也可以直接把 ZIP 拖到岛上。 -## 下一步 +## 扩展示例 -- 添加线程或保存回调指针前,阅读 [ABI 与生命周期](/plugin-dev/abi-lifecycle)。 -- 通过[宿主服务](/plugin-dev/services)添加 Media、歌词转换、翻译 bundle 或 Host State。 -- 按照[打包与安装](/plugin-dev/packaging)生成可校验的 ZIP。 +- 在[宿主服务](/plugin-dev/services)中了解 Media、Widget、Settings、Image、Store 和歌词接口。 +- 添加回调或线程前阅读 [ABI 与生命周期](/plugin-dev/abi-lifecycle)。 +- [SDK 小组件示例](https://github.com/WinIslandProject/WinIsland/blob/master/crates/winisland-plugin-api/examples/minimal_widget.rs)展示了 `DrawListBuilder` 的使用。 diff --git a/Page/zh/plugin-dev/services.md b/Page/zh/plugin-dev/services.md index f36d9d9e..ae42316e 100644 --- a/Page/zh/plugin-dev/services.md +++ b/Page/zh/plugin-dev/services.md @@ -1,379 +1,49 @@ # 宿主服务 -ABI v1 提供六个版本化宿主服务。Context、Media、国际化、Widget 和 Lyrics Transform 会创建归 plugin token 所有的资源;Host State 只返回快照,不创建资源。 +ABI v2 通过 `PluginHostV2.query` 提供十一张版本化服务表。SDK 的 `Host` 封装常见操作;`winisland_plugin_api::abi` 中的原始服务表提供全部函数。每张表都以 `TablePrefix` 开头,当前表版本为 `IFACE_VERSION_1`。调用前检查所需函数槽。除 Log 外,需在 `PluginDescriptorV2` 声明对应 `CAP_*` 能力。 -## 查询并校验服务 +| 能力 | 原始服务表 | SDK 入口 | 主要操作 | +|---|---|---|---| +| `CAP_CONTEXT` | `ContextApiV2` | `host.context()?` | 创建、更新、释放活动文字 | +| `CAP_MEDIA` | `MediaApiV2` | `host.media()?` | 发布媒体源、读取当前标题 | +| `CAP_I18N` | `I18nApiV2` | `host.i18n()?`(仅查询) | 注册/释放翻译资源 | +| `CAP_HOST_STATE` | `HostStateApiV2` | `host.host_state()?` | 获取/订阅媒体与主题状态 | +| `CAP_WIDGET` | `WidgetApiV2` | `host.widgets()?` | 创建、更新、释放、绘制和测量小组件 | +| `CAP_LYRICS` | `LyricsTransformApiV2` | `host.lyrics()?` | 注册/释放歌词转换器 | +| `CAP_SETTINGS` | `SettingsApiV2` | `host.settings()?` | 创建、更新、释放设置页 | +| `CAP_TEXT` | `TextApiV2` | `host.text()?` | 测量文字、获取字族 | +| `CAP_IMAGE` | `ImageApiV2` | `host.images()?` | 解码、上传、获取封面、释放图片 | +| `CAP_STORE` | `StoreApiV2` | `host.store()?` | 读写删除插件命名空间中的字节 | +| 无 | `LogApiV2` | `host.log()` | 写入插件日志 | -先在 `PluginDescriptorV1` 中声明能力,再在 `create` 中查询服务表,并校验插件需要的每个函数槽: +## Context 与 Media -```rust -let host = unsafe { &*info.host_api }; -let Some(context_api) = (unsafe { host.context_api() }) else { - return PluginResultC::err("context API is unavailable"); -}; -let (Some(create), Some(update), Some(release)) = ( - context_api.create, - context_api.update, - context_api.release, -) else { - return PluginResultC::err("context API is incomplete"); -}; -``` +`ContextDataV2` 包含优先级、紧凑模式标记、超时、标题、正文和紧凑文字。超时为零时一直保留到释放。刷新时应更新现有资源,不要反复新建。SDK `host.context()?.create(title, body)` 返回持有资源的 `Resource`。 -服务表是包含函数指针的复制值,可以保存在插件状态中。这些函数指针在 WinIsland 运行期间有效,但插件 shutdown 完成后不得继续调用。 +`MediaSourceDataV2` 包含标题、艺术家、专辑、毫秒单位的时长/进度、播放标记、可选 PNG/JPEG 封面字节、可用控制和命令回调。SDK `create_source(title, artist)` 创建仅显示的来源;封面、时间轴或控制要使用原始 `MediaApiV2`。释放或禁用插件媒体源后,WinIsland 会回退到其他来源或 SMTC。回调数据要保留到可以安全释放为止。 -所有可能失败的服务函数都返回 `PluginResultC`。存储、替换或丢弃本地所有权状态之前,必须先检查 `status`: +## 小组件绘制 -```rust -let result = unsafe { update(token, resource_id, &data) }; -if result.status != 0 { - return result; -} -``` +`host.widgets()?.create(WidgetSpec::new("key").span(2, 1))` 创建可由布局管理的小组件。稳定 key 用于重启后的布局定位。`Widget::logical_size()` 返回当前逻辑尺寸;返回 `(0, 0)` 时跳过该帧。岛缩起时逻辑尺寸仍取自展开网格。 -## 当前宿主限制 +使用 `DrawListBuilder::new(Size::new(width, height))` 创建完整列表,加入 `fill_round_rect`、`text`、`text_runs` 或 `image` 等命令,再调用 `widget.submit(list.finish())`。绘制协议还支持裁剪、变换、透明度、形状、渐变、描边、阴影、图片和带字族字段的 UTF-8 文字。宿主复制列表,校验后重放。提交成功不保证最终显示;反复提交畸形帧可能禁用该小组件。渲染线程不会进入插件回调。 -限制用于防止插件意外无限占用 WinIsland 进程资源。它们属于宿主策略,不是 ABI 常量,未来 WinIsland 版本可能调整。 +绘制列表最多 4 MiB、4096 条命令;单条命令的文字最多 64 KiB。`Widget::request_redraw` 是尽力调用。`PluginDescriptorV2.on_tick` 在插件工作线程收到 `WidgetId` 和经过的秒数。 -| 资源 | 每插件限制 | 数据限制 | -|---|---:|---:| -| Context | 64 个活动资源 | 定长字段:title 255、body 511、compact text 127 UTF-8 字节,另加 NUL | -| Media | 4 个活动资源 | 每份封面最大 16 MiB;每插件封面总计 32 MiB | -| 翻译 bundle | 16 个活动 bundle | 每个 1 MiB;每插件总计 4 MiB | -| 翻译键值对 | 每 bundle 4,096 对 | key/value 各 64 KiB;语言代码 64 字节 | -| Widget | 16 个活动资源 | 最大占用 6 列 × 3 行;稳定 key 最长 63 个 ASCII 字节 | -| 歌词转换器 | 4 个活动资源 | 每行转换结果最大 256 KiB | +## 歌词、翻译与宿主状态 -更新会继续占用被替换资源的配额;release 后才会归还数量和内存预算。 +歌词转换器在歌词解析后运行,先查询输出长度,再写入。逐词同步行应保留相同的 Unicode 字符数,才能保持时间边界。SDK `host.lyrics()?.register` 接受 `Send + Sync` 转换闭包,并在注册期间保存回调数据。 -## Context 服务 +`I18nApiV2` 注册带语言标记的键值资源。`HostStateApiV2.get` 返回媒体标题、艺术家、播放状态和明暗主题。`subscribe` 注册状态回调;卸载前要释放订阅。 -Context 适合构建结果、计时器、录制状态或持续任务等一眼可读的紧凑插件状态。 +## 设置、文字、图片、存储和日志 -### 数据模型 +`SettingsApiV2` 接收声明式 `SettingsPageDataV2`,包含稳定页面 key、标题、可选图标和 section、group、label、switch、select、stepper、button 项。`on_change` 可以接受或拒绝用户操作。SDK `create_label_page` 创建简单文字页;交互控件与回调需使用原始服务表。需要跨重启保存时,显式写入 Store,并在创建页面时恢复值。 -```rust -let context = ContextDataV1 { - priority: PRIORITY_HIGH, - flags: CONTEXT_FLAG_SHOW_COMPACT, - timeout_ms: 10_000, - title: str_to_fixed("Deployment finished"), - body: str_to_fixed("Production is healthy"), - compact_text: str_to_fixed("Deployed"), - ..Default::default() -}; -``` +`TextApiV2.measure` 使用带字号、字重、斜体标记和 UTF-8 字族的 `TextStyleV2`;`font_family` 返回宿主字族。SDK `measure` 便捷方法使用 400 字重和正体。`ImageApiV2` 可解码 PNG/JPEG/WebP、上传 RGBA 或获取当前封面;图片 ID 持有至释放。获取的封面句柄在曲目变化后仍保留旧图。 -优先级顺序为 `LOW < MEDIUM < HIGH`。WinIsland 会显示优先级最高的紧凑 Context;优先级相同时,按最近更新时间排序。未知优先级或 flag bit 会被拒绝。 +`StoreApiV2` 在插件独立命名空间持久化字节;单个值最多 1 MiB。`LogApiV2.write` 使用数字级别,没有能力门槛。SDK `LogApi::write` 和 `Widget::request_redraw` 会忽略错误;需要状态时使用原始表。 -`CONTEXT_FLAG_SHOW_COMPACT` 表示该资源可进入紧凑岛显示。如果 `compact_text` 为空,就使用 title;body 非空时作为次要文本渲染。 +## 资源限制与错误 -`timeout_ms = 0` 表示不超时。非零超时从创建或更新时开始计算。过期只会隐藏 Context,不会 release 或归还配额,插件仍拥有该 ID。 - -### 创建、更新和释放 - -```rust -let mut id = INVALID_ID; -let result = unsafe { context_api.create.unwrap()(token, &context, &mut id) }; -if result.status != 0 { - return result; -} - -let updated = ContextDataV1 { - title: str_to_fixed("Deployment verified"), - compact_text: str_to_fixed("Healthy"), - ..context -}; -let result = unsafe { context_api.update.unwrap()(token, id, &updated) }; - -let result = unsafe { context_api.release.unwrap()(token, id) }; -``` - -update 失败时保持 ID 不变;只有 release 成功后才能把它改成 `INVALID_ID`。资源在 WinIsland 首次渲染前就创建并立即释放也是受支持的,事件合并不会留下旧文本。 - -### Context 常见错误 - -典型错误包括 title 为空、未知优先级/flag、token 错误、ID 属于其他插件或服务类型,以及达到 64 个资源上限。 - -## Media 服务 - -插件 Media 资源提供 WinIsland 实际显示的媒体信息,不受 SMTC 开关影响。最近创建或更新的插件 Media 为活动来源;释放后会选择下一个最近资源,全部释放后恢复 SMTC。 - -### 显示数据 - -```rust -let cover = std::fs::read("cover.png").unwrap_or_default(); -let media = MediaSourceDataV1 { - flags: MEDIA_FLAG_PLAYING, - duration_ms: 180_000, - position_ms: 12_000, - title: str_to_fixed("Plugin Track"), - artist: str_to_fixed("Plugin Artist"), - album: str_to_fixed("Plugin Album"), - cover: ByteSliceV1::from_slice(&cover), - ..Default::default() -}; -``` - -title 必填,artist 和 album 可以为空。封面应为可解码的 PNG 或 JPEG;WinIsland 会在调用返回前复制字节。空切片表示清除封面。 - -设置 `MEDIA_FLAG_PLAYING` 后,WinIsland 会根据经过时间从 `position_ms` 推进显示进度。seek、暂停/恢复、切歌或需要校准权威进度时,应发送更新。`duration_ms = 0` 表示时长未知。 - -### 可选控制 - -控制功能需要主动声明。只设置插件真正能处理的命令;只要任意 control bit 非零,就必须提供回调: - -```rust -media.available_controls = MEDIA_CONTROL_TOGGLE_PLAY - | MEDIA_CONTROL_PREVIOUS - | MEDIA_CONTROL_NEXT - | MEDIA_CONTROL_SEEK; -media.on_command = Some(on_media_command); -media.callback_data = state_ptr; -``` - -```rust -unsafe extern "C" fn on_media_command( - callback_data: *mut std::ffi::c_void, - resource_id: ResourceId, - command: *const MediaCommandV1, -) { - if callback_data.is_null() || command.is_null() { - return; - } - let command = unsafe { &*command }; - if command.struct_size < std::mem::size_of::() as u32 { - return; - } - - match command.command { - MEDIA_COMMAND_TOGGLE_PLAY => { /* 命令入队 */ } - MEDIA_COMMAND_PREVIOUS => { /* 上一首入队 */ } - MEDIA_COMMAND_NEXT => { /* 下一首入队 */ } - MEDIA_COMMAND_SEEK => { - let target_ms = command.position_ms; - // 为 `resource_id` 入队 seek - } - _ => {} - } -} -``` - -WinIsland 在事件循环线程同步调用回调。回调应尽快把慢任务入队后返回。`callback_data` 必须有效到资源 release 成功。Seek 拖动会绑定拖动开始时的 Media 资源,因此应使用回调收到的 `resource_id`,不要假设命令一定属于插件最新的资源。 - -在回调内部更新或释放同一个资源会返回 `media callback is in progress`。可以调用其他宿主服务。插件卸载也会等待所有 Media 回调结束。 - -### Media 常见错误 - -Media 调用会拒绝空 title、未知 flag/control、声明控制但没有回调、长度非零但 cover 指针为空、单封面超过 16 MiB、封面总配额溢出、owner/type 不匹配,以及回调期间更新或释放。 - -## Lyrics Transform 服务 - -Lyrics Transform 用于后处理 WinIsland 获取的歌词。声明 `CAPABILITY_LYRICS_TRANSFORM`、 -查询 `lyrics_transform_api()`,再注册一个回调资源: - -```rust -let transformer = LyricsTransformerDataV1 { - on_transform: Some(transform_lyrics), - callback_data: state_ptr, - ..Default::default() -}; -let mut transformer_id = INVALID_ID; -let result = unsafe { - lyrics_api.register.unwrap()(token, &transformer, &mut transformer_id) -}; -``` - -每次歌词获取并解析完成后、写入缓存前,WinIsland 会按注册顺序对每行调用一次转换器。 -因此简体转繁体插件只需处理文本:把输入行交给 OpenCC,再返回转换后的 UTF-8 文本。 - -回调采用两阶段输出。第一次调用时 `output` 为空且 `output_capacity` 为零,插件把所需字节数 -写入 `out_len`;第二次调用时,最多向 `output` 写入 `output_capacity` 字节,并把实际长度写回 -`out_len`。指针无效、转换失败或容量不足时应返回错误。 - -`LyricsTextV1` 包含 `line_time_ms`、借用的 UTF-8 行文本和 -`LYRICS_TEXT_FLAG_WORD_SYNCED`。逐字歌词的输出必须保持与输入相同的 Unicode 字符数量; -WinIsland 会把原来的逐字边界映射到转换后的 UTF-8 byte offset,因此即使编码字节变化, -高亮时间仍保持不变。字符数量不同的结果只会在该行被拒绝,其他行仍继续经过转换链。 - -回调会在歌词获取 worker 上同步执行,可以调用其他宿主服务,但必须保持有界;每行输出上限为 -256 KiB。`callback_data` 必须有效到 release 成功。回调执行期间 release 和插件卸载都会返回 -错误。插件应在 `shutdown` 中释放转换器;新注册的转换器从下一次歌词获取开始生效。 - -## Widget 服务 - -Widget 资源通过 WinIsland 的 `DrawApiV1` 渲染,因此插件不需要依赖 Skia 或其他图形库。声明 -`CAPABILITY_WIDGET`、查询 `widget_api()`,并为每个可配置小组件提供稳定 key: - -```rust -let widget = WidgetDataV1 { - key: str_to_fixed("status"), - span_cols: 2, - span_rows: 1, - title: str_to_fixed("Status"), - on_draw: Some(draw_widget), - ..Default::default() -}; - -let mut widget_id = INVALID_ID; -let result = unsafe { widget_api.create.unwrap()(token, &widget, &mut widget_id) }; -``` - -宿主会把 key 与 descriptor 中的插件 ID 组合成持久化布局身份。key 必须由 1-63 个 ASCII -字母、数字、`_` 或 `-` 组成,在同一插件内唯一,并且在 `update` 和后续插件版本中保持不变。 -带 key 的小组件会进入“设置 > 小组件”,并通过真实绘制回调生成实时预览。用户可以把它拖入 -灵动岛网格、调整位置,或删除回小组件库;选择的位置会跨重启保存。 - -空 key 只用于兼容 API 0.5 之前构建的插件。这类小组件仍会自动放进第一个空位,但不会进入 -设置中的小组件库。 - -绘制回调的实现如下: - -```rust -unsafe extern "C" fn draw_widget( - callback_data: *mut std::ffi::c_void, - ctx: *const WidgetDrawContextV1, -) { - if ctx.is_null() { - return; - } - // SAFETY: WinIsland supplies the context and keeps it valid for this call. - let ctx = unsafe { &*ctx }; - let Some(draw) = (unsafe { ctx.draw_api() }) else { - return; - }; - let (Some(round_rect), Some(text), Some(circle)) = - (draw.draw_round_rect, draw.draw_text, draw.draw_circle) - else { - return; - }; - let _ = callback_data; - - // Coordinates are logical; the host applies the island scale and alpha. - unsafe { round_rect(ctx, 0.0, 0.0, ctx.width, ctx.height, 12.0, 0x28FFFFFF) }; - unsafe { text(ctx, 16.0, 16.0, Utf8SliceV1::borrowed("就绪"), 18.0, 1, 0xFFFFFFFF) }; - unsafe { circle(ctx, ctx.width - 14.0, 14.0, 4.0, 0xFF34C759) }; - - // save/restore/translate keep a plugin-local transform stack. - unsafe { draw.save.unwrap()(ctx) }; - unsafe { draw.translate.unwrap()(ctx, 8.0, 0.0) }; - unsafe { draw.draw_rect.unwrap()(ctx, 0.0, ctx.height - 3.0, 24.0, 2.0, 0x40FFFFFF) }; - unsafe { draw.restore.unwrap()(ctx) }; -} -``` - -颜色使用 `0xAARRGGBB`,`draw_text` 的 `y` 是文字顶线(ascent 线),`draw_image` 接收非预乘 -RGBA8 像素并由宿主应用 context alpha。完整绘制操作包括 `draw_rect`、`draw_round_rect`、 -`draw_circle`、`draw_line`、`draw_arc`、`draw_text`、`measure_text`、`draw_image` 以及 -`save` / `restore` / `translate` 变换栈。 - -绘制回调在渲染线程同步执行。请使用相对小组件的逻辑坐标,保持回调简短,不要保存 context, -并在 shutdown 中释放资源。Widget 调用会拒绝非法占格、未知 flag、缺少回调、重复或非法 key、 -update 时更换 key、所有权不匹配,以及超过每插件资源上限。 - -## 翻译服务 - -翻译 bundle 会向 WinIsland 现有翻译查找加入插件自有 key。每种语言注册一个 bundle,并保存每个返回 ID。 - -```rust -let pairs = [ - TranslationPairV1 { - key: Utf8SliceV1::borrowed("hello.title"), - value: Utf8SliceV1::borrowed("Hello"), - }, - TranslationPairV1 { - key: Utf8SliceV1::borrowed("hello.status"), - value: Utf8SliceV1::borrowed("Running"), - }, -]; - -let mut bundle_id = INVALID_ID; -let result = unsafe { - i18n_api.register_bundle.unwrap()( - token, - Utf8SliceV1::borrowed("en_us"), - pairs.as_ptr(), - pairs.len() as u32, - &mut bundle_id, - ) -}; -``` - -WinIsland 会在注册期间复制语言、key 和 value,调用返回后即可释放这些切片。key 不得为空,value 可以为空。建议使用带插件前缀的唯一 key,避免意外覆盖其他插件或应用字符串。 - -当前内置语言代码包括 `en_us`、`zh_cn` 和 `es_es`。只有 bundle 的语言与 WinIsland 当前语言一致时才参与查找。同语言同 key 下,最近注册且仍活动的插件 bundle 优先。释放后会重新显示较早 bundle 或内置翻译。 - -```rust -let result = unsafe { i18n_api.release_bundle.unwrap()(token, bundle_id) }; -``` - -shutdown 中按注册相反顺序释放 bundle。插件成功 shutdown 后,WinIsland 也会清理遗漏 bundle。 - -### 翻译常见错误 - -注册会拒绝空 bundle、空 pair 数组、超过 4,096 对、空语言或 key、无效 UTF-8、字符串超长、bundle 超过 1 MiB、每插件数量/总配额溢出,以及缺少 capability。 - -## Host State 服务 - -Host State 是快照,不是订阅。先初始化输出结构以填写 `struct_size`,再调用 `get`: - -```rust -let mut state = HostStateV1::default(); -let result = unsafe { host_state_api.get.unwrap()(token, &mut state) }; -if result.status == 0 { - let is_playing = state.is_playing != 0; - let title = read_fixed(&state.media_title); - let theme = read_fixed(&state.theme); -} -``` - -插件可使用本地定长缓冲区读取函数: - -```rust -fn read_fixed(bytes: &[u8]) -> String { - let end = bytes.iter().position(|byte| *byte == 0).unwrap_or(bytes.len()); - String::from_utf8_lossy(&bytes[..end]).into_owned() -} -``` - -快照包含 WinIsland 当前实际显示的媒体,包括活动插件 Media,以及当前主题字符串(`light` 或 `dark`)。没有显示媒体时字段可以为空。不要把结果当作事件流长期缓存;需要新状态时再次查询。 - -Host State 会校验 token、capability、输出指针和输出 `struct_size`。它不创建 `ResourceId`,无需 release。 - -## 资源清理模式 - -把 ID 保存在实例中,并且只在 release 成功后清空: - -```rust -fn release_resource( - id: &mut ResourceId, - release: unsafe extern "C" fn(PluginToken, ResourceId) -> PluginResultC, - token: PluginToken, -) -> PluginResultC { - if *id == INVALID_ID { - return PluginResultC::ok(); - } - let result = unsafe { release(token, *id) }; - if result.status == 0 { - *id = INVALID_ID; - } - result -} -``` - -资源较多时,先停止回调/worker,再按依赖相反顺序 release。如果某项失败,保留尚未释放的 ID 并返回错误,使 shutdown 可以重试。 - -## 诊断宿主错误 - -Rust 侧可以用 `PluginResultC::into_result()` 转成 `Result<(), String>`。常见错误含义如下: - -| 错误 | 含义 | -|---|---| -| `invalid plugin token` | Token 为零、已过期,或不是当前已加载实例的 token | -| `capability was not declared` | Descriptor 没有声明对应服务能力 | -| `resource was not found` | ID 已 release/回收,或从未存在 | -| `resource is owned by another plugin` | Token 或服务类型与 ID 不匹配 | -| `media callback is in progress` | 等回调返回后再更新或释放 | -| `... limit reached` / `... exceed ... limit` | 释放已有资源,或减少复制数据 | - -日志中应包含失败操作和资源 ID,但不要记录 secret 或原始封面/翻译 payload。 +当前每插件限制为:64 个 Context、4 个 Media 源(合计 32 MiB)、16 个翻译资源(4 MiB)、8 个 Widget(4 MiB)、4 个歌词转换器、1 个设置页(2 MiB)、64 张图片(64 MiB)、16 个宿主状态订阅。宿主通过 `PluginStatus` 拒绝过期、其他插件的句柄和超额资源。成功 shutdown 前应主动释放;宿主之后会撤销剩余资源。 diff --git a/crates/winisland-core/src/lib.rs b/crates/winisland-core/src/lib.rs index 33e65e46..7c056c45 100644 --- a/crates/winisland-core/src/lib.rs +++ b/crates/winisland-core/src/lib.rs @@ -28,4 +28,5 @@ pub mod i18n; pub mod lyrics; pub mod persistence; pub mod physics; +pub mod plugin_settings; pub mod widgets; diff --git a/src/core/plugin_settings.rs b/crates/winisland-core/src/plugin_settings.rs similarity index 93% rename from src/core/plugin_settings.rs rename to crates/winisland-core/src/plugin_settings.rs index b232e119..9d05fac3 100644 --- a/src/core/plugin_settings.rs +++ b/crates/winisland-core/src/plugin_settings.rs @@ -1,4 +1,4 @@ -use crate::plugin::types::ResourceId; +pub type PluginSettingsResourceId = u64; #[derive(Clone)] pub struct PluginSettingsOption { @@ -56,7 +56,7 @@ impl PluginSettingsItem { #[derive(Clone)] pub struct PluginSettingsPage { - pub resource_id: ResourceId, + pub resource_id: PluginSettingsResourceId, pub key: String, pub title: String, pub icon: Vec, diff --git a/crates/winisland-plugin-api/Cargo.toml b/crates/winisland-plugin-api/Cargo.toml index 4060967e..3b1f8211 100644 --- a/crates/winisland-plugin-api/Cargo.toml +++ b/crates/winisland-plugin-api/Cargo.toml @@ -1,45 +1,34 @@ [package] name = "winisland-plugin-api" -version = "0.7.0" +version = "0.8.0" edition = "2024" description = "Versioned native plugin ABI for WinIsland, with optional packaging and signing tools" repository = "https://github.com/WinIslandProject/WinIsland" homepage = "https://github.com/WinIslandProject/WinIsland" -license = "MIT" +license = "GPL-3.0-only" keywords = ["plugin", "dynamic-island", "winisland"] categories = ["api-bindings", "development-tools::build-utils"] readme = "README.md" [features] -default = [] +default = ["sdk"] +sdk = [] packager = [ + "dep:winisland-plugin-package", "dep:ed25519-dalek", - "dep:sha2", - "dep:hex", - "dep:zip", "dep:tempfile", "dep:log", "dep:libloading", - "dep:serde_yaml", - "dep:serde", - "dep:serde_json", "dep:toml", - "dep:thiserror", ] [dependencies] -serde = { version = "1.0", features = ["derive"], optional = true } -serde_json = { version = "1.0", optional = true } +winisland-plugin-package = { version = "0.8", path = "../winisland-plugin-package", optional = true } toml = { version = "1.1.2", optional = true } -serde_yaml = { version = "0.9", optional = true } ed25519-dalek = { version = "2", features = ["pem", "pkcs8"], optional = true } -sha2 = { version = "0.11.0", optional = true } -hex = { version = "0.4", optional = true } -zip = { version = "8.6.0", optional = true } tempfile = { version = "3", optional = true } log = { version = "0.4", optional = true } libloading = { version = "0.9", optional = true } -thiserror = { version = "2", optional = true } [package.metadata.docs.rs] features = ["packager"] diff --git a/crates/winisland-plugin-api/ChangeLog.md b/crates/winisland-plugin-api/ChangeLog.md index eb75d5a6..9548e403 100644 --- a/crates/winisland-plugin-api/ChangeLog.md +++ b/crates/winisland-plugin-api/ChangeLog.md @@ -1,6 +1,28 @@ # Changelog -This changelog lists published `winisland-plugin-api` releases only. Release notes are added when a version is published; there is no `Unreleased` section. +This changelog lists `winisland-plugin-api` versions. The website displays this file as its plugin API update log. + +## 0.8.0 - Sep 27, 2026 + +Changed: + +- **Breaking**: replaced the native ABI v1 entry with `winisland_plugin_entry_v2` and `PluginDescriptorV2`; ABI v1 DLLs must be rebuilt and packaged with `abi-version: 2` +- Replaced `PluginResultC` with `PluginStatus` and moved optional `on_tick` to the plugin descriptor +- Replaced render-thread widget callbacks with complete draw lists submitted from plugin workers, validated by the host, and replayed without entering plugin code +- Updated `PluginPackager` to emit ABI v2 manifests and validate the built DLL descriptor before producing a ZIP +- Declared `winisland-plugin-api` and its publishable `winisland-plugin-package` dependency as GPL-3.0-only + +Added: + +- Eleven versioned host interfaces for Context, Media, i18n, Host State, Widget, Lyrics Transform, Settings, Text, Image, Store, and Log +- An opt-in Rust SDK with owned resource handles, widget draw-list builders, image helpers, persistent Store operations, and simple settings-page helpers +- Draw-list operations for clipping, transforms, alpha, shapes, gradients, strokes, shadows, images, and UTF-8 text with explicit font families +- Album-art access, rich declarative settings items, plugin-scoped persistent storage, and host-state subscriptions + +Fixed: + +- Bounded draw-list validation and resource ownership checks before rendering +- DLL lifetime protection when shutdown or partial-create cleanup fails ## 0.7.0 - Sep 13, 2026 diff --git a/crates/winisland-plugin-api/README.md b/crates/winisland-plugin-api/README.md index 35c48e3c..ad4e929e 100644 --- a/crates/winisland-plugin-api/README.md +++ b/crates/winisland-plugin-api/README.md @@ -1,349 +1,55 @@ # winisland-plugin-api -Versioned C ABI types and packaging tools for trusted native WinIsland plugins. +ABI v2 types and SDK for trusted native WinIsland plugins. Plugins are in-process Windows DLLs. The published ABI layouts and opcodes are defined in `src/abi`, `src/types/v2`, and `src/draw/v2.rs`. -Plugins are loaded as in-process Windows DLLs. They are not sandboxed: install only plugins you trust. ABI v1 is published by crate version `0.6` and does not support the old `0.2` vtable ABI. +License: GPL-3.0-only. -## Project setup +## Start a plugin -```toml -[package] -name = "hello-winisland-plugin" -version = "0.1.0" -edition = "2024" -authors = ["Example Author"] -description = "Minimal WinIsland ABI v1 plugin" -repository = "https://github.com/example/hello-winisland-plugin" +Create a Rust `cdylib` with `winisland-plugin-api` as a dependency. Export `winisland_plugin_entry_v2` returning a static `PluginDescriptorV2`. The descriptor declares capabilities and `create`, `shutdown`, and `destroy` callbacks. `on_tick` is optional and belongs to the plugin descriptor. +```toml [lib] -name = "hello_winisland_plugin" crate-type = ["cdylib"] [dependencies] -winisland-plugin-api = "0.6" -``` - -## Minimal context plugin - -```rust -use std::ffi::c_void; -use winisland_plugin_api::*; - -struct Instance { - token: PluginToken, - context_api: ContextApiV1, - context_id: ResourceId, -} - -static DESCRIPTOR: PluginDescriptorV1 = PluginDescriptorV1 { - struct_size: std::mem::size_of::() as u32, - abi_version: ABI_VERSION_1, - capabilities: CAPABILITY_CONTEXT, - metadata: cargo_plugin_metadata!("hello-winisland-plugin", "Hello WinIsland Plugin"), - create: Some(create), - shutdown: Some(shutdown), - destroy: Some(destroy), -}; - -unsafe extern "C" fn create( - create_info: *const PluginCreateInfoV1, - out_handle: *mut PluginHandle, -) -> PluginResultC { - if create_info.is_null() || out_handle.is_null() { - return PluginResultC::err("null create argument"); - } - // SAFETY: WinIsland supplies a complete ABI v1 create-info structure. - let info = unsafe { &*create_info }; - if info.struct_size < std::mem::size_of::() as u32 - || info.abi_version != ABI_VERSION_1 - || info.host_api.is_null() - { - return PluginResultC::err("unsupported create info"); - } - // SAFETY: The host API pointer remains valid for the process lifetime. - let host = unsafe { &*info.host_api }; - // SAFETY: `host` was supplied by WinIsland and validated above. - let Some(context_api) = (unsafe { host.context_api() }) else { - return PluginResultC::err("context API is unavailable"); - }; - let Some(create_context) = context_api.create else { - return PluginResultC::err("context create is unavailable"); - }; - - let context = ContextDataV1 { - title: str_to_fixed("Hello WinIsland"), - body: str_to_fixed("ABI v1 plugin is running"), - compact_text: str_to_fixed("Hello"), - ..Default::default() - }; - let mut context_id = INVALID_ID; - // SAFETY: The input and output pointers remain valid for this call. - let result = unsafe { create_context(info.plugin_token, &context, &mut context_id) }; - if result.status != 0 { - return result; - } - - let instance = Box::new(Instance { - token: info.plugin_token, - context_api, - context_id, - }); - // SAFETY: WinIsland owns this opaque handle until `destroy`. - unsafe { out_handle.write(Box::into_raw(instance).cast::()) }; - PluginResultC::ok() -} - -unsafe extern "C" fn shutdown(handle: PluginHandle) -> PluginResultC { - if handle.is_null() { - return PluginResultC::ok(); - } - // SAFETY: `handle` was created from `Box` in `create`. - let instance = unsafe { &mut *handle.cast::() }; - if instance.context_id != INVALID_ID { - if let Some(release) = instance.context_api.release { - // SAFETY: This resource belongs to the same plugin token. - let result = unsafe { release(instance.token, instance.context_id) }; - if result.status != 0 { - return result; - } - } - instance.context_id = INVALID_ID; - } - PluginResultC::ok() -} - -unsafe extern "C" fn destroy(handle: PluginHandle) { - if !handle.is_null() { - // SAFETY: `destroy` is called once after successful shutdown. - unsafe { drop(Box::from_raw(handle.cast::())) }; - } -} - -#[unsafe(no_mangle)] -/// # Safety -/// WinIsland calls this function using the documented ABI v1 signature. -pub unsafe extern "C" fn winisland_plugin_entry_v1() -> *const PluginDescriptorV1 { - &DESCRIPTOR -} +winisland-plugin-api = "0.8" ``` -Lifecycle is always `create -> shutdown -> destroy`. `shutdown` must stop and join every thread that can execute plugin code or call a host service. WinIsland destroys the handle and unloads the DLL only after `shutdown` succeeds. - -## Host services +The host passes a plugin token and an instance-owned `PluginHostV2` table to `create`. Query service tables through the SDK `Host` wrapper or through `PluginHostV2.query_interface`. The eleven interfaces cover context, media, translations, host state, widgets, lyrics, settings, text, images, store, and logging. Each resource belongs to the plugin token that created it. -Declare each service in `PluginDescriptorV1.capabilities`, then query it from `HostApiV1` during `create`: +## Lifecycle and drawing -| Capability | Query method | Resource operations | -|---|---|---| -| `CAPABILITY_CONTEXT` | `context_api()` | create, update, release | -| `CAPABILITY_MEDIA` | `media_api()` | create, update, release, UI command callback | -| `CAPABILITY_I18N` | `i18n_api()` | register and release translation bundles | -| `CAPABILITY_HOST_STATE` | `host_state_api()` | read the current media/theme snapshot | -| `CAPABILITY_WIDGET` | `widget_api()` | create, update, release, per-frame render callback | -| `CAPABILITY_LYRICS_TRANSFORM` | `lyrics_transform_api()` | register and release parsed lyric text transformers | +The host calls `create`, then optional `on_tick` on a plugin worker. The plugin builds a complete ABI v2 draw list and submits it through the widget table. WinIsland validates the entire list before replaying it with the render crate; render callbacks do not enter plugin code. `shutdown` must stop and join plugin-owned threads before returning success. The host then calls `destroy` and unloads the DLL. If shutdown fails, including cleanup after a partial `create`, the DLL and its host service tables remain allocated until process exit. The host cannot detect a plugin that returns success while its own threads are still running. -All created resources belong to the host-issued `PluginToken`. A plugin cannot update or release another plugin's resources. WinIsland automatically revokes remaining resources after successful shutdown. +Borrowed strings and byte slices are valid only for the synchronous call that receives them. Callback data must remain valid until its resource is released and no callback is active. Service methods return `PluginStatus`; stale or foreign resource handles are rejected. -Borrowed slices (`ByteSliceV1`, `Utf8SliceV1`) only need to remain valid until the host call returns. Callback data in Media, Widget, and Lyrics Transform resources must remain valid until the resource is successfully released. Media callbacks run synchronously on the WinIsland event-loop thread; lyric callbacks run on a lyrics-fetch worker. Release or unload returns an error while the corresponding callback is executing. +The SDK wraps common calls; the ABI tables in `src/abi` expose the full interface. `Host::from_raw` requires the host table and token passed to `create`, and the wrapper must not outlive plugin shutdown. Query methods on `Host` return an error when a capability or table is unavailable. The returned handles may be used from plugin-owned threads while the instance is active. -Every pointer passed across the ABI must be non-null when required, correctly aligned for its declared type, and readable or writable for the complete call. All public ABI structs use `#[repr(C)]` and start with `struct_size` where versioned extension is supported. - -## Widget rendering - -Declare `CAPABILITY_WIDGET`, create a `WidgetDataV1` with a render callback, and the host places -the widget on the expanded island's widget page grid. The host calls `on_draw` synchronously on -every rendered frame; inside the callback you draw through the host-provided `DrawApiV1` drawing operations — -no graphics library is linked into the plugin. - -```rust -// In `create`, after querying `widget_api`: -let create_widget = widget_api.create.ok_or(())?; // Option field, same as Media - -let mut widget = WidgetDataV1::default(); // span 2x1 -widget.key = str_to_fixed("status"); -widget.span_cols = 2; -widget.span_rows = 2; -widget.on_draw = Some(on_draw); - -let mut widget_id = INVALID_ID; -let result = unsafe { create_widget(info.plugin_token, &widget, &mut widget_id) }; -if result.status != 0 { return result; } - -// The render callback is invoked on every frame: -unsafe extern "C" fn on_draw(callback_data: *mut c_void, ctx: *const WidgetDrawContextV1) { - // SAFETY: The context is host-provided and valid for this call. - let ctx = unsafe { &*ctx }; - // SAFETY: The drawing operations originate from the host and are ABI-versioned. - let Some(draw) = (unsafe { ctx.draw_api() }) else { return; }; - - // SAFETY: All draw calls are synchronous and the context stays valid. - unsafe { - draw.draw_round_rect.unwrap()(ctx, 0.0, 0.0, ctx.width, ctx.height, 12.0, 0x28FFFFFF); - draw.draw_text.unwrap()(ctx, 16.0, 20.0, Utf8SliceV1::borrowed("hello"), 18.0, 1, 0xFFFFFFFF); - } - let _ = callback_data; -} -``` - -Contract notes: - -- `key` is the stable identity of this widget within the plugin. Use 1-63 ASCII letters, digits, - `_`, or `-`, keep it unique within the plugin, and never change it in `update` or across plugin - versions. A keyed widget appears in **Settings > Widgets**, where its real render callback is - used for the library preview and users can place, move, or remove it. The saved layout combines - the plugin ID and widget key, so runtime `ResourceId` values are never persisted. -- An empty `key` is accepted only for compatibility with plugins built before 0.5. Such a widget - keeps the legacy automatic free-slot placement and cannot be managed in Settings. -- Coordinates are **logical** and relative to the widget slot's top-left corner; the host applies - the island `scale` and `alpha` automatically. `ctx.width` / `ctx.height` are the logical slot - footprint dimensions (span columns/rows plus gaps). -- `save` / `restore` / `translate` maintain a plugin-local transform stack; they never touch the - host canvas state, and unbalanced calls are contained per frame. -- Colors are `0xAARRGGBB`. `draw_text`'s `y` is the text top (ascent line). `draw_image` takes - non-premultiplied RGBA8 pixels and the host applies the context alpha to the whole image. -- The callback runs on the render thread: keep it short, do not block, and do not retain `ctx` - after returning. Release the widget resource in `shutdown` as shown for Context above. - -## Lyrics transformation - -Declare `CAPABILITY_LYRICS_TRANSFORM`, query `lyrics_transform_api()`, and register one callback: - -```rust -let transformer = LyricsTransformerDataV1 { - on_transform: Some(transform_lyrics), - callback_data: state_ptr, - ..Default::default() -}; -let mut transformer_id = INVALID_ID; -let result = unsafe { - lyrics_api.register.unwrap()(info.plugin_token, &transformer, &mut transformer_id) -}; -``` - -The callback uses a two-pass output protocol. A real Simplified-to-Traditional plugin can replace -the example conversion with OpenCC or another converter: - -```rust -unsafe extern "C" fn transform_lyrics( - _callback_data: *mut std::ffi::c_void, - _resource_id: ResourceId, - input: *const LyricsTextV1, - output: *mut u8, - output_capacity: u32, - out_len: *mut u32, -) -> PluginResultC { - if input.is_null() || out_len.is_null() { - return PluginResultC::err("null lyrics transform argument"); - } - let input = unsafe { &*input }; - let bytes = unsafe { std::slice::from_raw_parts(input.text.ptr, input.text.len as usize) }; - let Ok(text) = std::str::from_utf8(bytes) else { - return PluginResultC::err("invalid lyrics UTF-8"); - }; - let transformed = text.replace('汉', "漢"); - let transformed = transformed.as_bytes(); - unsafe { out_len.write(transformed.len() as u32) }; - if output.is_null() && output_capacity == 0 { - return PluginResultC::ok(); - } - if output.is_null() || output_capacity < transformed.len() as u32 { - return PluginResultC::err("lyrics output buffer is too small"); - } - unsafe { std::ptr::copy_nonoverlapping(transformed.as_ptr(), output, transformed.len()) }; - PluginResultC::ok() -} -``` +| SDK method | Behavior and lifetime | +|---|---| +| `ContextApi::create`, `MediaApi::create_source`, `LyricsApi::register`, `SettingsApi::create_page` / `create_label_page`, `WidgetApi::create` | Return an owned resource on success; the handle releases it on drop. `LyricsApi::register` retains its callback until release is safe; if release fails, the callback allocation is retained to avoid a dangling callback pointer. | +| `Widget::submit` | Copies a complete draw list into the host and returns its status. Validation and frame preparation happen later; a successful submit does not guarantee that the frame will be displayed. | +| `Widget::logical_size` | Returns the current logical dimensions. It returns `(0, 0)` if the host call fails; the plugin should skip drawing that frame. The size follows the configured expanded grid and does not shrink during collapse animation. | +| `Widget::request_redraw`, `LogApi::write` | Best-effort convenience calls that discard host errors. Use the raw ABI table when the status matters. | +| `TextApi::measure` | Measures with weight 400 and upright style in the requested family. Use the raw `TextApiV2` table for other weights or italic text. | +| `ImageApi::decode`, `upload_rgba`, `album_art` | Return an owned image handle, released on drop. An album-art handle keeps its image after the current cover changes. | +| `StoreApi::get`, `set`, `delete` | Operate in the plugin's own persistent namespace. `get` returns `None` for an absent key and may report a size error if the value changes between its length query and read. | -WinIsland invokes every registered transformer in registration order once per parsed line, after -fetching and before caching. Each callback is called first with a null output to query its required -byte length, then with an allocated output buffer. Output must be valid UTF-8 and no larger than -256 KiB per line. For `LYRICS_TEXT_FLAG_WORD_SYNCED`, the transformed text must keep the same -Unicode character count; the host then rebuilds byte boundaries so per-word highlighting keeps its -original timing. Release the transformer during `shutdown`; release and unload are rejected while -its callback is active. +The settings helpers create only a section label. Rich settings items and change callbacks require the raw `SettingsApiV2` table. `WidgetSpec::new` and `title` copy into fixed ABI buffers and truncate long UTF-8 text at a character boundary; choose keys within the 63-byte ASCII limit. The ABI types in `src/abi` define the exact signatures; the host enforces threading, quotas, and failure statuses. -## Packaging +## Package -Enable the optional packager: +A package is a ZIP containing `plugin.yml` and the DLL named by its `entry` field. Set `abi-version: 2`. The optional `packager` feature provides `PluginPackager` for building, signing, and zipping a plugin: ```toml [dev-dependencies] -winisland-plugin-api = { version = "0.6", features = ["packager"] } - -[[example]] -name = "pack" -path = "package.rs" -``` - -```rust -fn main() { - winisland_plugin_api::packager::PluginPackager::from_cargo() - .unwrap() - .build() - .unwrap(); -} +winisland-plugin-api = { version = "0.8", features = ["packager"] } ``` -Run `cargo run --example pack`. - -To publish through the WinIsland plugin marketplace, keep the complete source in a public GitHub repository with a detected SPDX license. Add this tag workflow: - -```yaml -name: Release WinIsland plugin - -on: - push: - tags: ["v*"] - -permissions: - contents: write - id-token: write - attestations: write - -jobs: - release: - uses: WinIslandProject/PluginMarketplace/.github/workflows/build-plugin.yml@main +```rust,no_run +winisland_plugin_api::packager::PluginPackager::from_cargo() + .unwrap() + .build() + .unwrap(); ``` - -After the first release, submit one registration file to [WinIslandProject/PluginMarketplace](https://github.com/WinIslandProject/PluginMarketplace). Future versions are discovered from new valid GitHub Releases without another registration pull request. The complete format is documented in the marketplace [contribution guide](https://github.com/WinIslandProject/PluginMarketplace/blob/main/CONTRIBUTING.md). - -`from_cargo()` reads package metadata, `repository`, `[lib].name`, and optional display metadata: - -```toml -[package.metadata.winisland] -name = "Hello WinIsland Plugin" -# id = "hello-winisland-plugin" # Defaults to package.name -``` - -Use `cargo_plugin_metadata!("hello-winisland-plugin", "Hello WinIsland Plugin")` in the DLL -descriptor so Cargo remains the source of truth for version, authors, and description. During -packaging, the built DLL is loaded and its complete ABI metadata is compared with `plugin.yml`; -the build fails with the mismatched field instead of publishing an uninstallable package. - -The generated `plugin.yml` identifies one entry DLL: - -```yaml -id: hello-winisland-plugin -name: hello-winisland-plugin -author: Example Author -version: 0.1.0 -description: Minimal WinIsland ABI v1 plugin -github-link: https://github.com/example/hello-winisland-plugin -abi-version: 1 -entry: hello_winisland_plugin.dll -icon: icon.png -readme: README.md -``` - -`icon` and `readme` are optional safe relative paths used by **Settings > Plugins**. `PluginPackager::from_cargo()` automatically includes conventional root files (`icon.png`/`.jpg`/`.jpeg`/`.webp` and `README.md`/`.markdown`/`.txt`). Use `.icon("path")` or `.readme("path")` to select another file. The README is rendered as CommonMark/GFM in the plugin details panel; enable and disable changes take effect after WinIsland restarts. - -Additional DLLs and asset directories may be included as dependencies, but WinIsland only loads `entry` as the plugin. The packager can write hashes and an Ed25519 signature; host-side signature verification is not implemented yet. - -## Features - -| Feature | Description | -|---|---| -| default | Core ABI v1 types with no extra dependencies | -| `packager` | Build, ZIP, hash, and optional Ed25519 signing tools | - -See the [WinIsland plugin development guide](https://tanikaze.icu/WinIsland/) for all services and installation details. diff --git a/crates/winisland-plugin-api/examples/lyrics_transform.rs b/crates/winisland-plugin-api/examples/lyrics_transform.rs new file mode 100644 index 00000000..e23f4abf --- /dev/null +++ b/crates/winisland-plugin-api/examples/lyrics_transform.rs @@ -0,0 +1,9 @@ +use winisland_plugin_api::sdk::{Error, Host, Resource}; + +fn register(host: &Host) -> Result { + host.lyrics()?.register(|line| line.to_uppercase()) +} + +fn main() { + let _ = register as fn(&Host) -> Result; +} diff --git a/crates/winisland-plugin-api/examples/media_source.rs b/crates/winisland-plugin-api/examples/media_source.rs new file mode 100644 index 00000000..6d4407c4 --- /dev/null +++ b/crates/winisland-plugin-api/examples/media_source.rs @@ -0,0 +1,9 @@ +use winisland_plugin_api::sdk::{Error, Host, Resource}; + +fn register(host: &Host) -> Result { + host.media()?.create_source("Sample track", "Sample artist") +} + +fn main() { + let _ = register as fn(&Host) -> Result; +} diff --git a/crates/winisland-plugin-api/examples/minimal_widget.rs b/crates/winisland-plugin-api/examples/minimal_widget.rs new file mode 100644 index 00000000..461ec3e5 --- /dev/null +++ b/crates/winisland-plugin-api/examples/minimal_widget.rs @@ -0,0 +1,48 @@ +use winisland_plugin_api::sdk::*; + +struct NowPlaying { + widget: Widget, + title: String, + cover: Option, +} + +impl NowPlaying { + fn new(host: &Host) -> Result { + let widget = host + .widgets()? + .create(WidgetSpec::new("now-playing").span(2, 1))?; + Ok(Self { + widget, + title: String::new(), + cover: None, + }) + } + + fn on_tick(&mut self, host: &Host) -> Result<(), Error> { + let media = host.media()?; + self.title = media.title().unwrap_or_default(); + self.cover = host.images()?.album_art().ok(); + let (w, h) = self.widget.logical_size(); + let mut list = DrawListBuilder::new(Size::new(w, h)); + list.fill_round_rect(Rect::new(0.0, 0.0, w, h), 8.0, Rgba::from_argb(0x8000_0000)); + if let Some(cover) = &self.cover { + list.image( + cover.id(), + Rect::new(4.0, 4.0, h - 8.0, h - 8.0), + ImageFit::Cover, + ); + } + list.text( + &self.title, + Rect::new(h + 2.0, 6.0, w - h - 6.0, h - 12.0), + &TextStyle::default_at(13.0), + Rgba::WHITE, + ); + self.widget.submit(list.finish()) + } +} + +fn main() { + let _ = NowPlaying::new as fn(&Host) -> Result; + let _ = NowPlaying::on_tick as fn(&mut NowPlaying, &Host) -> Result<(), Error>; +} diff --git a/crates/winisland-plugin-api/examples/settings_page.rs b/crates/winisland-plugin-api/examples/settings_page.rs new file mode 100644 index 00000000..6c0e7bb1 --- /dev/null +++ b/crates/winisland-plugin-api/examples/settings_page.rs @@ -0,0 +1,10 @@ +use winisland_plugin_api::sdk::{Error, Host, Resource}; + +fn register(host: &Host) -> Result { + host.settings()? + .create_label_page("sample", "Sample settings", "Plugin is ready") +} + +fn main() { + let _ = register as fn(&Host) -> Result; +} diff --git a/crates/winisland-plugin-api/src/abi/layout.rs b/crates/winisland-plugin-api/src/abi/layout.rs new file mode 100644 index 00000000..6ef1227d --- /dev/null +++ b/crates/winisland-plugin-api/src/abi/layout.rs @@ -0,0 +1,311 @@ +use std::mem::{align_of, offset_of, size_of}; + +use super::*; +use crate::types::metadata::PluginMetadataC; +use crate::types::v2::context::{ContextDataV2, HostStateV2, MediaCommandV2, MediaSourceDataV2}; +use crate::types::v2::i18n::TranslationPairV2; +use crate::types::v2::lyrics::{LyricsTextV2, LyricsTransformerDataV2}; +use crate::types::v2::settings::{ + SettingsChangeV2, SettingsItemV2, SettingsOptionV2, SettingsPageDataV2, +}; +use crate::types::v2::widget::WidgetSpecV2; +use crate::types::v2::{ + ByteSlice, ImageId, PluginToken, ResourceId, TextMetricsV2, TextStyleV2, Utf8Slice, WidgetId, +}; + +macro_rules! assert_layout { + ($ty:ty, $size:expr, $align:expr, $($field:tt = $offset:expr),+ $(,)?) => { + const _: () = { + assert!(size_of::<$ty>() == $size); + assert!(align_of::<$ty>() == $align); + $(assert!(offset_of!($ty, $field) == $offset);)+ + }; + }; +} + +const _: () = { + assert!(size_of::() == 4); + assert!(align_of::() == 4); +}; + +assert_layout!(PluginToken, 8, 8, 0 = 0); +assert_layout!(ResourceId, 8, 8, 0 = 0); +assert_layout!(WidgetId, 8, 8, 0 = 0); +assert_layout!(ImageId, 8, 8, 0 = 0); +assert_layout!(ByteSlice, 16, 8, ptr = 0, len = 8); +assert_layout!(Utf8Slice, 16, 8, ptr = 0, len = 8); +assert_layout!( + TablePrefix, + 16, + 8, + struct_size = 0, + version = 4, + context = 8 +); +assert_layout!(PluginHostV2, 32, 8, prefix = 0, host_build = 16, query = 24); +assert_layout!( + PluginCreateInfoV2, + 24, + 8, + struct_size = 0, + abi_version = 4, + plugin_token = 8, + host_api = 16 +); +assert_layout!( + PluginMetadataC, + 608, + 1, + id = 0, + name = 64, + version = 192, + author = 224, + description = 352 +); +assert_layout!( + PluginDescriptorV2, + 656, + 8, + struct_size = 0, + abi_version = 4, + capabilities = 8, + metadata = 16, + create = 624, + shutdown = 632, + destroy = 640, + on_tick = 648 +); + +assert_layout!( + ContextApiV2, + 40, + 8, + prefix = 0, + create = 16, + update = 24, + release = 32 +); +assert_layout!( + MediaApiV2, + 48, + 8, + prefix = 0, + create = 16, + update = 24, + release = 32, + current_title = 40 +); +assert_layout!( + I18nApiV2, + 32, + 8, + prefix = 0, + register_bundle = 16, + release_bundle = 24 +); +assert_layout!( + HostStateApiV2, + 40, + 8, + prefix = 0, + get = 16, + subscribe = 24, + release_subscription = 32 +); +assert_layout!( + WidgetApiV2, + 64, + 8, + prefix = 0, + create = 16, + update = 24, + release = 32, + submit_draw_list = 40, + request_redraw = 48, + logical_size = 56 +); +assert_layout!( + LyricsTransformApiV2, + 32, + 8, + prefix = 0, + register = 16, + release = 24 +); +assert_layout!( + SettingsApiV2, + 40, + 8, + prefix = 0, + create = 16, + update = 24, + release = 32 +); +assert_layout!(TextApiV2, 32, 8, prefix = 0, measure = 16, font_family = 24); +assert_layout!( + ImageApiV2, + 48, + 8, + prefix = 0, + decode = 16, + upload_rgba = 24, + album_art = 32, + release = 40 +); +assert_layout!( + StoreApiV2, + 40, + 8, + prefix = 0, + get = 16, + set = 24, + delete = 32 +); +assert_layout!(LogApiV2, 24, 8, prefix = 0, write = 16); + +assert_layout!( + ContextDataV2, + 912, + 4, + struct_size = 0, + priority = 4, + flags = 8, + timeout_ms = 12, + title = 16, + body = 272, + compact_text = 784 +); +assert_layout!( + HostStateV2, + 560, + 4, + struct_size = 0, + flags = 4, + media_title = 8, + media_artist = 264, + is_playing = 520, + reserved = 521, + theme = 528 +); +assert_layout!( + MediaCommandV2, + 16, + 8, + struct_size = 0, + command = 4, + position_ms = 8 +); +assert_layout!( + MediaSourceDataV2, + 832, + 8, + struct_size = 0, + flags = 4, + duration_ms = 8, + position_ms = 16, + available_controls = 24, + reserved = 28, + title = 32, + artist = 288, + album = 544, + cover = 800, + on_command = 816, + callback_data = 824 +); +assert_layout!(TranslationPairV2, 32, 8, key = 0, value = 16); +assert_layout!( + LyricsTextV2, + 32, + 8, + struct_size = 0, + flags = 4, + line_time_ms = 8, + text = 16 +); +assert_layout!( + LyricsTransformerDataV2, + 24, + 8, + struct_size = 0, + flags = 4, + on_transform = 8, + callback_data = 16 +); +assert_layout!( + SettingsOptionV2, + 388, + 4, + struct_size = 0, + value = 4, + label = 132 +); +assert_layout!( + SettingsItemV2, + 632, + 8, + struct_size = 0, + kind = 4, + flags = 8, + key = 12, + label = 76, + value = 332, + options = 592, + option_count = 600, + minimum = 608, + maximum = 616, + step = 624 +); +assert_layout!( + SettingsChangeV2, + 324, + 4, + struct_size = 0, + key = 4, + value = 68 +); +assert_layout!( + SettingsPageDataV2, + 248, + 8, + struct_size = 0, + key = 4, + title = 68, + icon = 200, + items = 216, + item_count = 224, + on_change = 232, + callback_data = 240 +); +assert_layout!( + WidgetSpecV2, + 856, + 4, + struct_size = 0, + span_cols = 4, + span_rows = 8, + flags = 12, + title = 16, + body = 272, + key = 784, + min_width = 848, + min_height = 852 +); +assert_layout!( + TextStyleV2, + 24, + 8, + size = 0, + weight = 4, + italic = 6, + reserved = 7, + family = 8 +); +assert_layout!( + TextMetricsV2, + 16, + 4, + width = 0, + height = 4, + ascent = 8, + descent = 12 +); diff --git a/crates/winisland-plugin-api/src/abi/mod.rs b/crates/winisland-plugin-api/src/abi/mod.rs new file mode 100644 index 00000000..d8c89003 --- /dev/null +++ b/crates/winisland-plugin-api/src/abi/mod.rs @@ -0,0 +1,113 @@ +mod layout; +mod tables; + +use std::ffi::c_void; + +use crate::types::metadata::PluginMetadataC; +use crate::types::v2::{PluginToken, WidgetId}; + +pub use tables::*; + +pub const ABI_VERSION_2: u32 = 2; +pub const PLUGIN_ENTRY_SYMBOL_V2: &[u8] = b"winisland_plugin_entry_v2"; + +pub const IFACE_CONTEXT: u32 = 0x01; +pub const IFACE_MEDIA: u32 = 0x02; +pub const IFACE_I18N: u32 = 0x03; +pub const IFACE_HOST_STATE: u32 = 0x04; +pub const IFACE_WIDGET: u32 = 0x05; +pub const IFACE_LYRICS_TRANSFORM: u32 = 0x06; +pub const IFACE_SETTINGS: u32 = 0x07; +pub const IFACE_TEXT: u32 = 0x08; +pub const IFACE_IMAGE: u32 = 0x09; +pub const IFACE_STORE: u32 = 0x0a; +pub const IFACE_LOG: u32 = 0x0b; +pub const IFACE_VERSION_1: u32 = 1; + +pub const CAP_CONTEXT: u64 = 1 << 0; +pub const CAP_MEDIA: u64 = 1 << 1; +pub const CAP_I18N: u64 = 1 << 2; +pub const CAP_HOST_STATE: u64 = 1 << 3; +pub const CAP_WIDGET: u64 = 1 << 4; +pub const CAP_LYRICS: u64 = 1 << 5; +pub const CAP_SETTINGS: u64 = 1 << 6; +pub const CAP_TEXT: u64 = 1 << 7; +pub const CAP_IMAGE: u64 = 1 << 8; +pub const CAP_STORE: u64 = 1 << 9; +pub const KNOWN_CAPABILITIES_V2: u64 = CAP_CONTEXT + | CAP_MEDIA + | CAP_I18N + | CAP_HOST_STATE + | CAP_WIDGET + | CAP_LYRICS + | CAP_SETTINGS + | CAP_TEXT + | CAP_IMAGE + | CAP_STORE; + +#[repr(transparent)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PluginStatus(i32); + +#[allow(non_upper_case_globals)] +impl PluginStatus { + pub const Ok: Self = Self(0); + pub const InvalidArgument: Self = Self(1); + pub const StaleHandle: Self = Self(2); + pub const CapabilityMissing: Self = Self(3); + pub const LimitExceeded: Self = Self(4); + pub const UnsupportedVersion: Self = Self(5); + pub const IoError: Self = Self(6); + pub const Internal: Self = Self(7); + + pub const fn code(self) -> i32 { + self.0 + } +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct TablePrefix { + pub struct_size: u32, + pub version: u32, + pub context: *mut c_void, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct PluginHostV2 { + pub prefix: TablePrefix, + pub host_build: u32, + pub query: Option *const c_void>, +} + +pub type PluginHandleV2 = *mut c_void; +pub type PluginCreateFnV2 = + unsafe extern "C" fn(*const PluginCreateInfoV2, *mut PluginHandleV2) -> PluginStatus; +pub type PluginShutdownFnV2 = unsafe extern "C" fn(PluginHandleV2) -> PluginStatus; +pub type PluginDestroyFnV2 = unsafe extern "C" fn(PluginHandleV2); +pub type PluginTickFnV2 = unsafe extern "C" fn(PluginHandleV2, WidgetId, f64) -> PluginStatus; + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct PluginCreateInfoV2 { + pub struct_size: u32, + pub abi_version: u32, + pub plugin_token: PluginToken, + pub host_api: *const PluginHostV2, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct PluginDescriptorV2 { + pub struct_size: u32, + pub abi_version: u32, + pub capabilities: u64, + pub metadata: PluginMetadataC, + pub create: Option, + pub shutdown: Option, + pub destroy: Option, + pub on_tick: Option, +} + +pub type PluginEntryFnV2 = unsafe extern "C" fn() -> *const PluginDescriptorV2; diff --git a/crates/winisland-plugin-api/src/abi/tables.rs b/crates/winisland-plugin-api/src/abi/tables.rs new file mode 100644 index 00000000..7946ce88 --- /dev/null +++ b/crates/winisland-plugin-api/src/abi/tables.rs @@ -0,0 +1,242 @@ +use std::ffi::c_void; + +use crate::abi::{PluginStatus, TablePrefix}; +use crate::types::v2::context::{ContextDataV2, HostStateV2, MediaSourceDataV2}; +use crate::types::v2::i18n::TranslationPairV2; +use crate::types::v2::lyrics::LyricsTransformerDataV2; +use crate::types::v2::settings::SettingsPageDataV2; +use crate::types::v2::widget::WidgetSpecV2; +use crate::types::v2::{ + ByteSlice, HostStateChangedFnV2, ImageId, PluginToken, ResourceId, TextMetricsV2, TextStyleV2, + Utf8Slice, WidgetId, +}; + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ContextApiV2 { + pub prefix: TablePrefix, + pub create: Option< + unsafe extern "C" fn( + *mut c_void, + PluginToken, + *const ContextDataV2, + *mut ResourceId, + ) -> PluginStatus, + >, + pub update: Option< + unsafe extern "C" fn( + *mut c_void, + PluginToken, + ResourceId, + *const ContextDataV2, + ) -> PluginStatus, + >, + pub release: Option PluginStatus>, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct MediaApiV2 { + pub prefix: TablePrefix, + pub create: Option< + unsafe extern "C" fn( + *mut c_void, + PluginToken, + *const MediaSourceDataV2, + *mut ResourceId, + ) -> PluginStatus, + >, + pub update: Option< + unsafe extern "C" fn( + *mut c_void, + PluginToken, + ResourceId, + *const MediaSourceDataV2, + ) -> PluginStatus, + >, + pub release: Option PluginStatus>, + pub current_title: Option< + unsafe extern "C" fn(*mut c_void, PluginToken, *mut u8, u32, *mut u32) -> PluginStatus, + >, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct I18nApiV2 { + pub prefix: TablePrefix, + pub register_bundle: Option< + unsafe extern "C" fn( + *mut c_void, + PluginToken, + Utf8Slice, + *const TranslationPairV2, + u32, + *mut ResourceId, + ) -> PluginStatus, + >, + pub release_bundle: + Option PluginStatus>, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct HostStateApiV2 { + pub prefix: TablePrefix, + pub get: + Option PluginStatus>, + pub subscribe: Option< + unsafe extern "C" fn( + *mut c_void, + PluginToken, + HostStateChangedFnV2, + *mut c_void, + *mut ResourceId, + ) -> PluginStatus, + >, + pub release_subscription: + Option PluginStatus>, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct WidgetApiV2 { + pub prefix: TablePrefix, + pub create: Option< + unsafe extern "C" fn( + *mut c_void, + PluginToken, + *const WidgetSpecV2, + *mut WidgetId, + ) -> PluginStatus, + >, + pub update: Option< + unsafe extern "C" fn( + *mut c_void, + PluginToken, + WidgetId, + *const WidgetSpecV2, + ) -> PluginStatus, + >, + pub release: Option PluginStatus>, + pub submit_draw_list: Option< + unsafe extern "C" fn(*mut c_void, PluginToken, WidgetId, *const u8, u32) -> PluginStatus, + >, + pub request_redraw: + Option PluginStatus>, + pub logical_size: Option< + unsafe extern "C" fn( + *mut c_void, + PluginToken, + WidgetId, + *mut f32, + *mut f32, + ) -> PluginStatus, + >, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct LyricsTransformApiV2 { + pub prefix: TablePrefix, + pub register: Option< + unsafe extern "C" fn( + *mut c_void, + PluginToken, + *const LyricsTransformerDataV2, + *mut ResourceId, + ) -> PluginStatus, + >, + pub release: Option PluginStatus>, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct SettingsApiV2 { + pub prefix: TablePrefix, + pub create: Option< + unsafe extern "C" fn( + *mut c_void, + PluginToken, + *const SettingsPageDataV2, + *mut ResourceId, + ) -> PluginStatus, + >, + pub update: Option< + unsafe extern "C" fn( + *mut c_void, + PluginToken, + ResourceId, + *const SettingsPageDataV2, + ) -> PluginStatus, + >, + pub release: Option PluginStatus>, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct TextApiV2 { + pub prefix: TablePrefix, + pub measure: Option< + unsafe extern "C" fn( + *mut c_void, + PluginToken, + Utf8Slice, + *const TextStyleV2, + *mut TextMetricsV2, + ) -> PluginStatus, + >, + pub font_family: Option< + unsafe extern "C" fn(*mut c_void, PluginToken, u32, *mut u8, u32, *mut u32) -> PluginStatus, + >, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ImageApiV2 { + pub prefix: TablePrefix, + pub decode: Option< + unsafe extern "C" fn(*mut c_void, PluginToken, ByteSlice, *mut ImageId) -> PluginStatus, + >, + pub upload_rgba: Option< + unsafe extern "C" fn( + *mut c_void, + PluginToken, + u32, + u32, + ByteSlice, + *mut ImageId, + ) -> PluginStatus, + >, + pub album_art: + Option PluginStatus>, + pub release: Option PluginStatus>, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct StoreApiV2 { + pub prefix: TablePrefix, + pub get: Option< + unsafe extern "C" fn( + *mut c_void, + PluginToken, + Utf8Slice, + *mut u8, + u32, + *mut u32, + *mut u8, + ) -> PluginStatus, + >, + pub set: Option< + unsafe extern "C" fn(*mut c_void, PluginToken, Utf8Slice, ByteSlice) -> PluginStatus, + >, + pub delete: Option PluginStatus>, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct LogApiV2 { + pub prefix: TablePrefix, + pub write: + Option PluginStatus>, +} diff --git a/crates/winisland-plugin-api/src/bin/abi-layout.rs b/crates/winisland-plugin-api/src/bin/abi-layout.rs new file mode 100644 index 00000000..b0cd6c04 --- /dev/null +++ b/crates/winisland-plugin-api/src/bin/abi-layout.rs @@ -0,0 +1,183 @@ +use std::mem::{align_of, offset_of, size_of}; + +use winisland_plugin_api::abi::*; +use winisland_plugin_api::draw::v2::{DrawCommandHeader, DrawListHeader}; +use winisland_plugin_api::types::metadata::PluginMetadataC; +use winisland_plugin_api::types::v2::context::{ + ContextDataV2, HostStateV2, MediaCommandV2, MediaSourceDataV2, +}; +use winisland_plugin_api::types::v2::i18n::TranslationPairV2; +use winisland_plugin_api::types::v2::lyrics::{LyricsTextV2, LyricsTransformerDataV2}; +use winisland_plugin_api::types::v2::settings::{ + SettingsChangeV2, SettingsItemV2, SettingsOptionV2, SettingsPageDataV2, +}; +use winisland_plugin_api::types::v2::widget::WidgetSpecV2; +use winisland_plugin_api::types::v2::{ + ByteSlice, ImageId, PluginToken, ResourceId, TextMetricsV2, TextStyleV2, Utf8Slice, WidgetId, +}; + +macro_rules! show { + ($ty:ty, $($field:tt),+ $(,)?) => { + println!("{}: size={} align={}", stringify!($ty), size_of::<$ty>(), align_of::<$ty>()); + $(println!(" {}={}", stringify!($field), offset_of!($ty, $field));)+ + }; +} + +fn main() { + for (name, size, align) in [ + ( + "PluginToken", + size_of::(), + align_of::(), + ), + ( + "ResourceId", + size_of::(), + align_of::(), + ), + ("WidgetId", size_of::(), align_of::()), + ("ImageId", size_of::(), align_of::()), + ] { + println!("{name}: size={size} align={align}"); + println!(" 0=0"); + } + show!(ByteSlice, ptr, len); + show!(Utf8Slice, ptr, len); + show!(TablePrefix, struct_size, version, context); + show!(PluginHostV2, prefix, host_build, query); + show!( + PluginCreateInfoV2, + struct_size, + abi_version, + plugin_token, + host_api + ); + show!(PluginMetadataC, id, name, version, author, description); + show!( + PluginDescriptorV2, + struct_size, + abi_version, + capabilities, + metadata, + create, + shutdown, + destroy, + on_tick + ); + show!(ContextApiV2, prefix, create, update, release); + show!(MediaApiV2, prefix, create, update, release, current_title); + show!(I18nApiV2, prefix, register_bundle, release_bundle); + show!(HostStateApiV2, prefix, get, subscribe, release_subscription); + show!( + WidgetApiV2, + prefix, + create, + update, + release, + submit_draw_list, + request_redraw, + logical_size + ); + show!(LyricsTransformApiV2, prefix, register, release); + show!(SettingsApiV2, prefix, create, update, release); + show!(TextApiV2, prefix, measure, font_family); + show!(ImageApiV2, prefix, decode, upload_rgba, album_art, release); + show!(StoreApiV2, prefix, get, set, delete); + show!(LogApiV2, prefix, write); + show!( + ContextDataV2, + struct_size, + priority, + flags, + timeout_ms, + title, + body, + compact_text + ); + show!( + HostStateV2, + struct_size, + flags, + media_title, + media_artist, + is_playing, + reserved, + theme + ); + show!(MediaCommandV2, struct_size, command, position_ms); + show!( + MediaSourceDataV2, + struct_size, + flags, + duration_ms, + position_ms, + available_controls, + reserved, + title, + artist, + album, + cover, + on_command, + callback_data + ); + show!(TranslationPairV2, key, value); + show!(LyricsTextV2, struct_size, flags, line_time_ms, text); + show!( + LyricsTransformerDataV2, + struct_size, + flags, + on_transform, + callback_data + ); + show!(SettingsOptionV2, struct_size, value, label); + show!( + SettingsItemV2, + struct_size, + kind, + flags, + key, + label, + value, + options, + option_count, + minimum, + maximum, + step + ); + show!(SettingsChangeV2, struct_size, key, value); + show!( + SettingsPageDataV2, + struct_size, + key, + title, + icon, + items, + item_count, + on_change, + callback_data + ); + show!( + WidgetSpecV2, + struct_size, + span_cols, + span_rows, + flags, + title, + body, + key, + min_width, + min_height + ); + show!(TextStyleV2, size, weight, italic, reserved, family); + show!(TextMetricsV2, width, height, ascent, descent); + show!( + DrawListHeader, + magic, + version, + logical_w, + logical_h, + command_count, + payload_len + ); + show!(DrawCommandHeader, opcode, flags, payload_len); +} diff --git a/crates/winisland-plugin-api/src/descriptor.rs b/crates/winisland-plugin-api/src/descriptor.rs deleted file mode 100644 index cf12a50f..00000000 --- a/crates/winisland-plugin-api/src/descriptor.rs +++ /dev/null @@ -1,70 +0,0 @@ -use crate::{HostApiV1, PluginHandle, PluginMetadataC, PluginResultC, PluginToken}; - -pub const ABI_VERSION_1: u32 = 1; -pub const PLUGIN_ENTRY_SYMBOL_V1: &[u8] = b"winisland_plugin_entry_v1"; - -pub const CAPABILITY_CONTEXT: u64 = 1 << 0; -pub const CAPABILITY_MEDIA: u64 = 1 << 1; -pub const CAPABILITY_I18N: u64 = 1 << 2; -pub const CAPABILITY_HOST_STATE: u64 = 1 << 3; -pub const CAPABILITY_WIDGET: u64 = 1 << 4; -pub const CAPABILITY_LYRICS_TRANSFORM: u64 = 1 << 5; -pub const CAPABILITY_SETTINGS: u64 = 1 << 6; -pub const KNOWN_CAPABILITIES: u64 = CAPABILITY_CONTEXT - | CAPABILITY_MEDIA - | CAPABILITY_I18N - | CAPABILITY_HOST_STATE - | CAPABILITY_WIDGET - | CAPABILITY_LYRICS_TRANSFORM - | CAPABILITY_SETTINGS; - -/// Build plugin metadata from Cargo package fields while keeping the plugin ID and display name -/// explicit. -#[macro_export] -macro_rules! cargo_plugin_metadata { - ($id:expr, $name:expr) => { - $crate::PluginMetadataC::new( - $id, - $name, - env!("CARGO_PKG_VERSION"), - env!("CARGO_PKG_AUTHORS"), - env!("CARGO_PKG_DESCRIPTION"), - ) - }; -} - -#[repr(C)] -#[derive(Clone, Copy)] -pub struct PluginCreateInfoV1 { - pub struct_size: u32, - pub abi_version: u32, - pub plugin_token: PluginToken, - pub host_api: *const HostApiV1, -} - -pub type PluginCreateFnV1 = unsafe extern "C" fn( - create_info: *const PluginCreateInfoV1, - out_handle: *mut PluginHandle, -) -> PluginResultC; -/// Stop all plugin work and join every thread that may execute plugin code. -/// -/// The host destroys the plugin handle and unloads the DLL only after this -/// returns success. A failed `create` may return a cleanup handle; the host -/// then follows the same `shutdown` and `destroy` sequence. -pub type PluginShutdownFnV1 = unsafe extern "C" fn(PluginHandle) -> PluginResultC; -pub type PluginDestroyFnV1 = unsafe extern "C" fn(PluginHandle); - -#[repr(C)] -#[derive(Clone, Copy)] -pub struct PluginDescriptorV1 { - pub struct_size: u32, - pub abi_version: u32, - /// Each `CAPABILITY_*` bit authorizes the corresponding host interface. - pub capabilities: u64, - pub metadata: PluginMetadataC, - pub create: Option, - pub shutdown: Option, - pub destroy: Option, -} - -pub type PluginEntryFnV1 = unsafe extern "C" fn() -> *const PluginDescriptorV1; diff --git a/crates/winisland-plugin-api/src/draw.rs b/crates/winisland-plugin-api/src/draw.rs index 8660eac8..7083bd82 100644 --- a/crates/winisland-plugin-api/src/draw.rs +++ b/crates/winisland-plugin-api/src/draw.rs @@ -1,161 +1 @@ -use std::ffi::c_void; - -use crate::{ByteSliceV1, INTERFACE_VERSION_1, Utf8SliceV1}; - -/// Widget render callback invoked synchronously by the host on the render thread. -/// -/// The context is valid only for the duration of this call. The plugin must not -/// retain the pointer or any value derived from it after returning. -pub type WidgetDrawFnV1 = - unsafe extern "C" fn(callback_data: *mut c_void, ctx: *const WidgetDrawContextV1); - -/// Rendering context handed to a plugin widget's `on_draw` callback. -/// -/// Coordinates are logical and relative to the widget slot's top-left corner; -/// the host applies `scale` and `alpha` automatically inside every draw -/// function. `canvas_handle` is an opaque host-owned token — the plugin must -/// pass it back unchanged and must never dereference it. -#[repr(C)] -#[derive(Clone, Copy)] -pub struct WidgetDrawContextV1 { - /// Must be `size_of::()`. - pub struct_size: u32, - /// Must be `INTERFACE_VERSION_1`. - pub version: u32, - /// Logical slot footprint width (span columns plus gaps). - pub width: f32, - /// Logical slot footprint height (span rows plus gaps). - pub height: f32, - /// Global scale factor, applied by the host. - pub scale: f32, - /// Island opacity (0-255), applied by the host. - pub alpha: u8, - /// Opaque host-owned canvas token. Never dereference. - pub canvas_handle: *mut c_void, - /// Drawing operations. Never null. - pub draw: *const DrawApiV1, -} - -impl WidgetDrawContextV1 { - /// The host's drawing operations, or `None` if the pointer is null or the - /// version does not match this ABI. - /// - /// # Safety - /// `self.draw` must originate from the host and stay valid for this call. - pub unsafe fn draw_api(&self) -> Option<&'static DrawApiV1> { - // SAFETY: The host guarantees a valid operations pointer during on_draw. - let draw = unsafe { self.draw.as_ref() }?; - (draw.struct_size >= std::mem::size_of::() as u32 - && draw.version == INTERFACE_VERSION_1) - .then_some(draw) - } -} - -/// Drawing operations provided by the host. -/// -/// Every function takes the context returned to the plugin's `on_draw` -/// callback as its first argument. All coordinates are logical slot -/// coordinates; all colors are `0xAARRGGBB`. The host applies the context's -/// `scale` and `alpha` to every operation. -#[repr(C)] -#[derive(Clone, Copy)] -pub struct DrawApiV1 { - pub struct_size: u32, - pub version: u32, - /// Draw a single line of text. `bold` is 0 or 1. `y` is the text top - /// (ascent line), not the baseline. The font is managed by the host. - pub draw_text: Option< - unsafe extern "C" fn( - ctx: *const WidgetDrawContextV1, - x: f32, - y: f32, - text: Utf8SliceV1, - size: f32, - bold: u8, - color: u32, - ), - >, - /// Measure the width of a text line in logical pixels. - pub measure_text: Option< - unsafe extern "C" fn( - ctx: *const WidgetDrawContextV1, - text: Utf8SliceV1, - size: f32, - bold: u8, - ) -> f32, - >, - /// Fill a rectangle. - pub draw_rect: Option< - unsafe extern "C" fn( - ctx: *const WidgetDrawContextV1, - x: f32, - y: f32, - w: f32, - h: f32, - color: u32, - ), - >, - /// Fill a rounded rectangle. - pub draw_round_rect: Option< - unsafe extern "C" fn( - ctx: *const WidgetDrawContextV1, - x: f32, - y: f32, - w: f32, - h: f32, - radius: f32, - color: u32, - ), - >, - /// Fill a circle. - pub draw_circle: Option< - unsafe extern "C" fn(ctx: *const WidgetDrawContextV1, cx: f32, cy: f32, r: f32, color: u32), - >, - /// Stroke a line. - pub draw_line: Option< - unsafe extern "C" fn( - ctx: *const WidgetDrawContextV1, - x1: f32, - y1: f32, - x2: f32, - y2: f32, - stroke_width: f32, - color: u32, - ), - >, - /// Stroke an arc (progress ring). Angles are degrees, 0 at 3 o'clock, - /// increasing clockwise. - pub draw_arc: Option< - unsafe extern "C" fn( - ctx: *const WidgetDrawContextV1, - x: f32, - y: f32, - w: f32, - h: f32, - start_angle: f32, - sweep_angle: f32, - stroke_width: f32, - color: u32, - ), - >, - /// Draw raw non-premultiplied RGBA8 pixels into the given logical rect. - /// The host applies the context's alpha to the whole image. - pub draw_image: Option< - unsafe extern "C" fn( - ctx: *const WidgetDrawContextV1, - x: f32, - y: f32, - w: f32, - h: f32, - bitmap: ByteSliceV1, - bitmap_width: u32, - bitmap_height: u32, - ), - >, - /// Push the current plugin transform. Must be balanced with `restore`. - pub save: Option, - /// Pop a plugin transform pushed with `save`. - pub restore: Option, - /// Translate subsequent drawing by logical pixels. - pub translate: Option, -} +pub mod v2; diff --git a/crates/winisland-plugin-api/src/draw/v2.rs b/crates/winisland-plugin-api/src/draw/v2.rs new file mode 100644 index 00000000..8b58b922 --- /dev/null +++ b/crates/winisland-plugin-api/src/draw/v2.rs @@ -0,0 +1,65 @@ +pub const MAGIC: u32 = 0x5749_4432; +pub const VERSION: u32 = 2; +pub const MAX_LIST_BYTES: usize = 4 * 1024 * 1024; +pub const MAX_COMMANDS: u32 = 4096; +pub const MAX_PAYLOAD_BYTES: usize = 1024 * 1024; +pub const MAX_TEXT_BYTES: usize = 64 * 1024; +pub const MAX_FAMILY_BYTES: usize = 255; + +pub const PUSH_CLIP_RECT: u16 = 0x0001; +pub const PUSH_CLIP_ROUND_RECT: u16 = 0x0002; +pub const POP_CLIP: u16 = 0x0003; +pub const PUSH_TRANSFORM: u16 = 0x0004; +pub const POP_TRANSFORM: u16 = 0x0005; +pub const PUSH_ALPHA: u16 = 0x0006; +pub const POP_ALPHA: u16 = 0x0007; +pub const FILL_RECT: u16 = 0x0010; +pub const FILL_ROUND_RECT: u16 = 0x0011; +pub const FILL_CIRCLE: u16 = 0x0012; +pub const FILL_CONVEX_POLYGON: u16 = 0x0013; +pub const FILL_LINEAR_GRADIENT: u16 = 0x0014; +pub const STROKE_LINE: u16 = 0x0020; +pub const STROKE_ROUND_RECT: u16 = 0x0021; +pub const STROKE_ARC: u16 = 0x0022; +pub const DRAW_IMAGE: u16 = 0x0030; +pub const DRAW_IMAGE_PIXELS: u16 = 0x0031; +pub const DRAW_TEXT: u16 = 0x0040; +pub const DRAW_TEXT_RUNS: u16 = 0x0041; +pub const DRAW_SHADOW_ROUND_RECT: u16 = 0x0050; + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct DrawListHeader { + pub magic: u32, + pub version: u32, + pub logical_w: f32, + pub logical_h: f32, + pub command_count: u32, + pub payload_len: u32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct DrawCommandHeader { + pub opcode: u16, + pub flags: u16, + pub payload_len: u32, +} + +const _: () = { + use std::mem::{align_of, offset_of, size_of}; + + assert!(size_of::() == 24); + assert!(align_of::() == 4); + assert!(offset_of!(DrawListHeader, magic) == 0); + assert!(offset_of!(DrawListHeader, version) == 4); + assert!(offset_of!(DrawListHeader, logical_w) == 8); + assert!(offset_of!(DrawListHeader, logical_h) == 12); + assert!(offset_of!(DrawListHeader, command_count) == 16); + assert!(offset_of!(DrawListHeader, payload_len) == 20); + assert!(size_of::() == 8); + assert!(align_of::() == 4); + assert!(offset_of!(DrawCommandHeader, opcode) == 0); + assert!(offset_of!(DrawCommandHeader, flags) == 2); + assert!(offset_of!(DrawCommandHeader, payload_len) == 4); +}; diff --git a/crates/winisland-plugin-api/src/host.rs b/crates/winisland-plugin-api/src/host.rs deleted file mode 100644 index 3a2c3d56..00000000 --- a/crates/winisland-plugin-api/src/host.rs +++ /dev/null @@ -1,223 +0,0 @@ -use std::ffi::c_void; - -use crate::{ - ContextDataV1, HostStateV1, LyricsTransformerDataV1, MediaSourceDataV1, PluginResultC, - PluginToken, ResourceId, SettingsPageDataV1, TranslationPairV1, Utf8SliceV1, WidgetDataV1, -}; - -pub const INTERFACE_VERSION_1: u32 = 1; -pub const INTERFACE_CONTEXT: u32 = 1; -pub const INTERFACE_MEDIA: u32 = 2; -pub const INTERFACE_I18N: u32 = 3; -pub const INTERFACE_HOST_STATE: u32 = 4; -pub const INTERFACE_WIDGET: u32 = 5; -pub const INTERFACE_LYRICS_TRANSFORM: u32 = 6; -pub const INTERFACE_SETTINGS: u32 = 7; - -#[repr(C)] -#[derive(Clone, Copy)] -pub struct HostApiV1 { - pub struct_size: u32, - pub abi_version: u32, - pub query_interface: - Option *const c_void>, -} - -impl HostApiV1 { - /// Query the ABI v1 context service table. - /// - /// # Safety - /// `self` and its function pointers must originate from WinIsland and remain - /// valid for the duration of this call. - pub unsafe fn context_api(&self) -> Option { - // SAFETY: The caller guarantees this host table came from WinIsland. - unsafe { self.query(INTERFACE_CONTEXT) } - } - - /// Query the ABI v1 media service table. - /// - /// # Safety - /// `self` and its function pointers must originate from WinIsland and remain - /// valid for the duration of this call. - pub unsafe fn media_api(&self) -> Option { - // SAFETY: The caller guarantees this host table came from WinIsland. - unsafe { self.query(INTERFACE_MEDIA) } - } - - /// Query the ABI v1 translation service table. - /// - /// # Safety - /// `self` and its function pointers must originate from WinIsland and remain - /// valid for the duration of this call. - pub unsafe fn i18n_api(&self) -> Option { - // SAFETY: The caller guarantees this host table came from WinIsland. - unsafe { self.query(INTERFACE_I18N) } - } - - /// Query the ABI v1 host-state service table. - /// - /// # Safety - /// `self` and its function pointers must originate from WinIsland and remain - /// valid for the duration of this call. - pub unsafe fn host_state_api(&self) -> Option { - // SAFETY: The caller guarantees this host table came from WinIsland. - unsafe { self.query(INTERFACE_HOST_STATE) } - } - - /// Query the ABI v1 widget service table. - /// - /// # Safety - /// `self` and its function pointers must originate from WinIsland and remain - /// valid for the duration of this call. - pub unsafe fn widget_api(&self) -> Option { - // SAFETY: The caller guarantees this host table came from WinIsland. - unsafe { self.query(INTERFACE_WIDGET) } - } - - /// Query the ABI v1 lyric transformation service table. - /// - /// # Safety - /// `self` and its function pointers must originate from WinIsland and remain - /// valid for the duration of this call. - pub unsafe fn lyrics_transform_api(&self) -> Option { - // SAFETY: The caller guarantees this host table came from WinIsland. - unsafe { self.query(INTERFACE_LYRICS_TRANSFORM) } - } - - /// Query the ABI v1 settings service table. - /// - /// # Safety - /// `self` and its function pointers must originate from WinIsland and remain - /// valid for the duration of this call. - pub unsafe fn settings_api(&self) -> Option { - // SAFETY: The caller guarantees this host table came from WinIsland. - unsafe { self.query(INTERFACE_SETTINGS) } - } - - unsafe fn query(&self, interface_id: u32) -> Option { - if self.abi_version != crate::ABI_VERSION_1 - || self.struct_size < std::mem::size_of::() as u32 - { - return None; - } - let query = self.query_interface?; - // SAFETY: The caller guarantees the host function pointer is valid. - let pointer = unsafe { query(interface_id, INTERFACE_VERSION_1) }; - if pointer.is_null() { - return None; - } - let header = pointer.cast::(); - // SAFETY: Every service table begins with two readable u32 fields. - let struct_size = unsafe { std::ptr::read_unaligned(header) }; - // SAFETY: The second header field follows the first u32. - let version = unsafe { std::ptr::read_unaligned(header.add(1)) }; - if struct_size < std::mem::size_of::() as u32 || version != INTERFACE_VERSION_1 { - return None; - } - // SAFETY: The validated prefix contains a complete copyable v1 table. - Some(unsafe { std::ptr::read_unaligned(pointer.cast::()) }) - } -} - -#[repr(C)] -#[derive(Clone, Copy)] -pub struct ContextApiV1 { - pub struct_size: u32, - pub version: u32, - pub create: Option< - unsafe extern "C" fn(PluginToken, *const ContextDataV1, *mut ResourceId) -> PluginResultC, - >, - pub update: Option< - unsafe extern "C" fn(PluginToken, ResourceId, *const ContextDataV1) -> PluginResultC, - >, - pub release: Option PluginResultC>, -} - -#[repr(C)] -#[derive(Clone, Copy)] -pub struct MediaApiV1 { - pub struct_size: u32, - pub version: u32, - pub create: Option< - unsafe extern "C" fn( - PluginToken, - *const MediaSourceDataV1, - *mut ResourceId, - ) -> PluginResultC, - >, - pub update: Option< - unsafe extern "C" fn(PluginToken, ResourceId, *const MediaSourceDataV1) -> PluginResultC, - >, - pub release: Option PluginResultC>, -} - -#[repr(C)] -#[derive(Clone, Copy)] -pub struct I18nApiV1 { - pub struct_size: u32, - pub version: u32, - pub register_bundle: Option< - unsafe extern "C" fn( - PluginToken, - Utf8SliceV1, - *const TranslationPairV1, - u32, - *mut ResourceId, - ) -> PluginResultC, - >, - pub release_bundle: Option PluginResultC>, -} - -#[repr(C)] -#[derive(Clone, Copy)] -pub struct HostStateApiV1 { - pub struct_size: u32, - pub version: u32, - pub get: Option PluginResultC>, -} - -#[repr(C)] -#[derive(Clone, Copy)] -pub struct WidgetApiV1 { - pub struct_size: u32, - pub version: u32, - pub create: Option< - unsafe extern "C" fn(PluginToken, *const WidgetDataV1, *mut ResourceId) -> PluginResultC, - >, - pub update: - Option PluginResultC>, - pub release: Option PluginResultC>, -} - -#[repr(C)] -#[derive(Clone, Copy)] -pub struct LyricsTransformApiV1 { - pub struct_size: u32, - pub version: u32, - pub register: Option< - unsafe extern "C" fn( - PluginToken, - *const LyricsTransformerDataV1, - *mut ResourceId, - ) -> PluginResultC, - >, - pub release: Option PluginResultC>, -} - -#[repr(C)] -#[derive(Clone, Copy)] -pub struct SettingsApiV1 { - pub struct_size: u32, - pub version: u32, - pub create: Option< - unsafe extern "C" fn( - PluginToken, - *const SettingsPageDataV1, - *mut ResourceId, - ) -> PluginResultC, - >, - pub update: Option< - unsafe extern "C" fn(PluginToken, ResourceId, *const SettingsPageDataV1) -> PluginResultC, - >, - pub release: Option PluginResultC>, -} diff --git a/crates/winisland-plugin-api/src/lib.rs b/crates/winisland-plugin-api/src/lib.rs index 59b3aacc..c9a0462c 100644 --- a/crates/winisland-plugin-api/src/lib.rs +++ b/crates/winisland-plugin-api/src/lib.rs @@ -1,84 +1,19 @@ -//! # WinIsland Plugin API +//! Versioned C ABI and SDK for WinIsland native plugins. //! -//! C ABI types and tooling for developing [WinIsland](https://github.com/WinIslandProject/WinIsland) plugins. -//! -//! Plugins are trusted native DLLs that communicate with WinIsland through a -//! versioned C ABI and host service tables. -//! -//! ## Usage modes -//! -//! ### 1. Writing a plugin (core C ABI types, zero extra dependencies) -//! -//! ```toml -//! [dependencies] -//! winisland-plugin-api = "0.6" -//! ``` -//! -//! Export the ABI v1 descriptor from a `cdylib`. See the crate README for a -//! complete lifecycle and Context example: -//! -//! ```rust,ignore -//! use winisland_plugin_api::*; -//! -//! #[unsafe(no_mangle)] -//! pub unsafe extern "C" fn winisland_plugin_entry_v1() -> *const PluginDescriptorV1 { -//! &DESCRIPTOR -//! } -//! ``` -//! -//! ### 2. Packaging a plugin (requires `packager` feature) -//! -//! ```toml -//! [dev-dependencies] -//! winisland-plugin-api = { version = "0.6", features = ["packager"] } -//! ``` -//! -//! Add a `package.rs` example target that builds, signs and zips the plugin: -//! -//! ```rust,no_run -//! winisland_plugin_api::packager::PluginPackager::from_cargo() -//! .unwrap() -//! .build() -//! .unwrap(); -//! ``` -//! -//! Then run `cargo run --example pack` to produce a `.zip` distributable. +//! Plugins export `winisland_plugin_entry_v2` from a `cdylib` and receive +//! per-instance host service tables. See the crate README and SDK examples. -pub mod descriptor; +pub mod abi; pub mod draw; -pub mod host; pub mod types; +#[cfg(feature = "sdk")] +pub mod sdk; + #[cfg(feature = "packager")] pub mod packager; -// --------------------------------------------------------------------------- -// Public re-exports — flat import for plugin authors -// --------------------------------------------------------------------------- - -pub use descriptor::*; -pub use draw::{DrawApiV1, WidgetDrawContextV1, WidgetDrawFnV1}; -pub use host::*; -pub use types::context::{ - CONTEXT_FLAG_SHOW_COMPACT, ContextDataV1, HostStateV1, MEDIA_COMMAND_NEXT, - MEDIA_COMMAND_PREVIOUS, MEDIA_COMMAND_SEEK, MEDIA_COMMAND_TOGGLE_PLAY, MEDIA_CONTROL_NEXT, - MEDIA_CONTROL_PREVIOUS, MEDIA_CONTROL_SEEK, MEDIA_CONTROL_TOGGLE_PLAY, MEDIA_FLAG_PLAYING, - MediaCommandFnV1, MediaCommandV1, MediaSourceDataV1, PRIORITY_HIGH, PRIORITY_LOW, - PRIORITY_MEDIUM, -}; -pub use types::i18n::TranslationPairV1; -pub use types::lyrics::{ - LYRICS_TEXT_FLAG_WORD_SYNCED, LyricsTextV1, LyricsTransformFnV1, LyricsTransformerDataV1, -}; +pub use abi::*; pub use types::metadata::PluginMetadataC; -pub use types::settings::{ - SETTINGS_ITEM_BUTTON, SETTINGS_ITEM_FLAG_DISABLED, SETTINGS_ITEM_GROUP_END, - SETTINGS_ITEM_GROUP_START, SETTINGS_ITEM_LABEL, SETTINGS_ITEM_SECTION, SETTINGS_ITEM_SELECT, - SETTINGS_ITEM_STEPPER, SETTINGS_ITEM_SWITCH, SettingsChangeV1, SettingsChangedFnV1, - SettingsItemV1, SettingsOptionV1, SettingsPageDataV1, -}; -pub use types::widget::{WIDGET_FLAG_SHOW_COMPACT, WidgetDataV1}; -pub use types::{ - ByteSliceV1, INVALID_ID, PluginHandle, PluginResultC, PluginToken, ResourceId, Utf8SliceV1, - str_to_fixed, -}; +pub use types::str_to_fixed; +pub use types::v2::*; diff --git a/crates/winisland-plugin-api/src/packager/mod.rs b/crates/winisland-plugin-api/src/packager/mod.rs index 6e810edb..ec081280 100644 --- a/crates/winisland-plugin-api/src/packager/mod.rs +++ b/crates/winisland-plugin-api/src/packager/mod.rs @@ -1,6 +1,6 @@ -pub mod manifest; -pub mod packaging; -pub mod signing; +pub use winisland_plugin_package::manifest; +pub use winisland_plugin_package::packaging; +pub use winisland_plugin_package::signing; use ed25519_dalek::SigningKey; use libloading::Library; @@ -8,8 +8,9 @@ use manifest::PluginManifest; use signing::{hash_file, load_signing_key, load_signing_key_from_env, sign_payload}; use std::path::{Path, PathBuf}; -use crate::{ - ABI_VERSION_1, KNOWN_CAPABILITIES, PLUGIN_ENTRY_SYMBOL_V1, PluginDescriptorV1, PluginEntryFnV1, +use crate::abi::{ + ABI_VERSION_2, KNOWN_CAPABILITIES_V2, PLUGIN_ENTRY_SYMBOL_V2, PluginDescriptorV2, + PluginEntryFnV2, }; /// A build-time tool that compiles, packages, and optionally signs @@ -354,7 +355,7 @@ impl PluginPackager { version: self.version.clone(), description: self.description.clone(), github_link: self.github_link.clone(), - abi_version: crate::ABI_VERSION_1, + abi_version: ABI_VERSION_2, entry: dll_dest_name.to_string(), icon: self.icon.clone(), readme: self.readme.clone(), @@ -374,7 +375,7 @@ impl PluginPackager { // 9. Validate and write plugin.yml manifest - .validate() + .validate(ABI_VERSION_2) .map_err(|e| format!("Invalid manifest: {e}"))?; validate_dll_descriptor(&dll_path, &manifest)?; manifest @@ -430,9 +431,9 @@ fn validate_dll_descriptor(dll_path: &Path, manifest: &PluginManifest) -> Result // entry point. It does not create a plugin instance or invoke plugin-controlled callbacks. let library = unsafe { Library::new(dll_path) } .map_err(|error| format!("Cannot validate plugin DLL: {error}"))?; - // SAFETY: The symbol type is the public WinIsland ABI v1 entry-point signature. - let entry = unsafe { library.get::(PLUGIN_ENTRY_SYMBOL_V1) } - .map_err(|error| format!("Plugin DLL has no ABI v1 entry point: {error}"))?; + // SAFETY: The symbol type is the public WinIsland ABI v2 entry-point signature. + let entry = unsafe { library.get::(PLUGIN_ENTRY_SYMBOL_V2) } + .map_err(|error| format!("Plugin DLL has no ABI v2 entry point: {error}"))?; // SAFETY: The entry point is called synchronously while the DLL remains loaded. let pointer = unsafe { entry() }; if pointer.is_null() { @@ -440,18 +441,18 @@ fn validate_dll_descriptor(dll_path: &Path, manifest: &PluginManifest) -> Result } // SAFETY: Every ABI descriptor starts with a readable struct_size field. let struct_size = unsafe { std::ptr::read_unaligned(pointer.cast::()) }; - if struct_size < std::mem::size_of::() as u32 { - return Err("Plugin DLL returned a truncated ABI v1 descriptor".into()); + if struct_size < std::mem::size_of::() as u32 { + return Err("Plugin DLL returned a truncated ABI v2 descriptor".into()); } - // SAFETY: The size check proves the complete copyable ABI v1 prefix is available. + // SAFETY: The size check proves the complete copyable ABI v2 prefix is available. let descriptor = unsafe { std::ptr::read_unaligned(pointer) }; - if descriptor.abi_version != ABI_VERSION_1 - || descriptor.capabilities & !KNOWN_CAPABILITIES != 0 + if descriptor.abi_version != ABI_VERSION_2 + || descriptor.capabilities & !KNOWN_CAPABILITIES_V2 != 0 || descriptor.create.is_none() || descriptor.shutdown.is_none() || descriptor.destroy.is_none() { - return Err("Plugin DLL contains an invalid ABI v1 descriptor".into()); + return Err("Plugin DLL contains an invalid ABI v2 descriptor".into()); } let metadata = &descriptor.metadata; diff --git a/crates/winisland-plugin-api/src/sdk/draw.rs b/crates/winisland-plugin-api/src/sdk/draw.rs new file mode 100644 index 00000000..fa559c2c --- /dev/null +++ b/crates/winisland-plugin-api/src/sdk/draw.rs @@ -0,0 +1,383 @@ +use crate::draw::v2 as wire; +use crate::types::v2::ImageId; + +#[derive(Clone, Copy)] +pub struct Size { + pub width: f32, + pub height: f32, +} + +impl Size { + pub const fn new(width: f32, height: f32) -> Self { + Self { width, height } + } +} + +#[derive(Clone, Copy)] +pub struct Rect { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +impl Rect { + pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self { + Self { + x, + y, + width, + height, + } + } +} + +#[derive(Clone, Copy)] +pub struct Rgba(u32); + +impl Rgba { + pub const WHITE: Self = Self(0xffff_ffff); + + pub const fn from_argb(value: u32) -> Self { + Self(value) + } + + pub const fn argb(self) -> u32 { + self.0 + } +} + +#[derive(Clone, Copy)] +pub struct Deg12(pub f32); + +#[repr(u8)] +#[derive(Clone, Copy)] +pub enum ImageFit { + Contain = 0, + Cover = 1, + Fill = 2, +} + +#[derive(Clone)] +pub struct TextStyle { + pub size: f32, + pub weight: u16, + pub italic: bool, + pub align: u8, + pub wrap: bool, + pub ellipsis: bool, + pub family: String, +} + +impl TextStyle { + pub fn default_at(size: f32) -> Self { + Self { + size, + weight: 400, + italic: false, + align: 0, + wrap: false, + ellipsis: false, + family: String::new(), + } + } + + pub fn bold(mut self) -> Self { + self.weight = 700; + self + } +} + +#[derive(Clone, Copy)] +pub struct TextRun<'a> { + pub text: &'a str, + pub color: Rgba, + pub weight: u16, +} + +#[derive(Clone, Copy)] +pub struct GradientStop { + pub position: f32, + pub color: Rgba, +} + +pub struct DrawListBuilder { + bytes: Vec, + command_count: u32, +} + +pub struct DrawList<'a> { + bytes: &'a [u8], +} + +impl DrawList<'_> { + pub fn as_bytes(&self) -> &[u8] { + self.bytes + } +} + +impl DrawListBuilder { + pub fn new(logical: Size) -> Self { + let mut bytes = Vec::with_capacity(256); + bytes.extend_from_slice(&wire::MAGIC.to_le_bytes()); + bytes.extend_from_slice(&wire::VERSION.to_le_bytes()); + bytes.extend_from_slice(&logical.width.to_le_bytes()); + bytes.extend_from_slice(&logical.height.to_le_bytes()); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + Self { + bytes, + command_count: 0, + } + } + + pub fn finish(&self) -> DrawList<'_> { + DrawList { bytes: &self.bytes } + } + + fn command(&mut self, opcode: u16, payload: &[u8]) -> &mut Self { + let len = u32::try_from(payload.len()).unwrap_or(u32::MAX); + self.bytes.extend_from_slice(&opcode.to_le_bytes()); + self.bytes.extend_from_slice(&0_u16.to_le_bytes()); + self.bytes.extend_from_slice(&len.to_le_bytes()); + self.bytes.extend_from_slice(payload); + self.command_count = self.command_count.saturating_add(1); + self.bytes[16..20].copy_from_slice(&self.command_count.to_le_bytes()); + let total = u32::try_from(self.bytes.len() - 24).unwrap_or(u32::MAX); + self.bytes[20..24].copy_from_slice(&total.to_le_bytes()); + self + } + + pub fn clip_rect(&mut self, rect: Rect) -> &mut Self { + self.command(wire::PUSH_CLIP_RECT, &rect_bytes(rect)) + } + + pub fn clip_round_rect(&mut self, rect: Rect, radius: f32) -> &mut Self { + let mut payload = rect_bytes(rect); + push_f32(&mut payload, radius); + self.command(wire::PUSH_CLIP_ROUND_RECT, &payload) + } + + pub fn pop_clip(&mut self) -> &mut Self { + self.command(wire::POP_CLIP, &[]) + } + + pub fn transform(&mut self, affine: [f32; 6]) -> &mut Self { + let mut payload = Vec::with_capacity(24); + for value in affine { + push_f32(&mut payload, value); + } + self.command(wire::PUSH_TRANSFORM, &payload) + } + + pub fn pop_transform(&mut self) -> &mut Self { + self.command(wire::POP_TRANSFORM, &[]) + } + + pub fn alpha(&mut self, value: u8) -> &mut Self { + self.command(wire::PUSH_ALPHA, &[value]) + } + + pub fn pop_alpha(&mut self) -> &mut Self { + self.command(wire::POP_ALPHA, &[]) + } + + pub fn fill_rect(&mut self, rect: Rect, color: Rgba) -> &mut Self { + let mut payload = rect_bytes(rect); + push_u32(&mut payload, color.argb()); + self.command(wire::FILL_RECT, &payload) + } + + pub fn fill_round_rect(&mut self, rect: Rect, radius: f32, color: Rgba) -> &mut Self { + let mut payload = rect_bytes(rect); + push_f32(&mut payload, radius); + push_u32(&mut payload, color.argb()); + self.command(wire::FILL_ROUND_RECT, &payload) + } + + pub fn fill_circle(&mut self, x: f32, y: f32, radius: f32, color: Rgba) -> &mut Self { + let mut payload = Vec::with_capacity(16); + push_f32(&mut payload, x); + push_f32(&mut payload, y); + push_f32(&mut payload, radius); + push_u32(&mut payload, color.argb()); + self.command(wire::FILL_CIRCLE, &payload) + } + + pub fn fill_convex_polygon(&mut self, points: &[(f32, f32)], color: Rgba) -> &mut Self { + let mut payload = Vec::with_capacity(6 + points.len() * 8); + push_u16(&mut payload, points.len().min(u16::MAX as usize) as u16); + for &(x, y) in points { + push_f32(&mut payload, x); + push_f32(&mut payload, y); + } + push_u32(&mut payload, color.argb()); + self.command(wire::FILL_CONVEX_POLYGON, &payload) + } + + pub fn fill_linear_gradient( + &mut self, + rect: Rect, + angle: f32, + stops: &[GradientStop], + ) -> &mut Self { + let mut payload = rect_bytes(rect); + push_f32(&mut payload, angle); + payload.push(stops.len().min(u8::MAX as usize) as u8); + for stop in stops { + push_f32(&mut payload, stop.position); + push_u32(&mut payload, stop.color.argb()); + } + self.command(wire::FILL_LINEAR_GRADIENT, &payload) + } + + pub fn stroke_line( + &mut self, + from: (f32, f32), + to: (f32, f32), + width: f32, + color: Rgba, + ) -> &mut Self { + let mut payload = Vec::with_capacity(24); + for value in [from.0, from.1, to.0, to.1, width] { + push_f32(&mut payload, value); + } + push_u32(&mut payload, color.argb()); + self.command(wire::STROKE_LINE, &payload) + } + + pub fn stroke_round_rect( + &mut self, + rect: Rect, + radius: f32, + width: f32, + color: Rgba, + ) -> &mut Self { + let mut payload = rect_bytes(rect); + push_f32(&mut payload, radius); + push_f32(&mut payload, width); + push_u32(&mut payload, color.argb()); + self.command(wire::STROKE_ROUND_RECT, &payload) + } + + pub fn stroke_arc( + &mut self, + rect: Rect, + start: Deg12, + sweep: Deg12, + width: f32, + color: Rgba, + ) -> &mut Self { + let mut payload = rect_bytes(rect); + for value in [start.0, sweep.0, width] { + push_f32(&mut payload, value); + } + push_u32(&mut payload, color.argb()); + self.command(wire::STROKE_ARC, &payload) + } + + pub fn image(&mut self, id: ImageId, rect: Rect, fit: ImageFit) -> &mut Self { + let mut payload = Vec::with_capacity(26); + payload.extend_from_slice(&id.get().to_le_bytes()); + payload.extend_from_slice(&rect_bytes(rect)); + payload.extend_from_slice(&[fit as u8, 255]); + self.command(wire::DRAW_IMAGE, &payload) + } + + pub fn image_pixels( + &mut self, + rgba: &[u8], + dimensions: (u32, u32), + rect: Rect, + fit: ImageFit, + ) -> &mut Self { + let mut payload = rect_bytes(rect); + payload.extend_from_slice(&[fit as u8, 255]); + push_u32(&mut payload, dimensions.0); + push_u32(&mut payload, dimensions.1); + push_u32(&mut payload, rgba.len().min(u32::MAX as usize) as u32); + payload.extend_from_slice(rgba); + self.command(wire::DRAW_IMAGE_PIXELS, &payload) + } + + pub fn text(&mut self, value: &str, rect: Rect, style: &TextStyle, color: Rgba) -> &mut Self { + let mut payload = rect_bytes(rect); + push_u32(&mut payload, value.len().min(u32::MAX as usize) as u32); + payload.extend_from_slice(value.as_bytes()); + push_f32(&mut payload, style.size); + push_u16(&mut payload, style.weight); + push_text_flags(&mut payload, style); + push_family(&mut payload, &style.family); + push_u32(&mut payload, color.argb()); + self.command(wire::DRAW_TEXT, &payload) + } + + pub fn text_runs(&mut self, runs: &[TextRun<'_>], rect: Rect, style: &TextStyle) -> &mut Self { + let mut payload = rect_bytes(rect); + push_f32(&mut payload, style.size); + push_text_flags(&mut payload, style); + push_family(&mut payload, &style.family); + payload.push(runs.len().min(u8::MAX as usize) as u8); + for run in runs { + push_u32(&mut payload, run.text.len().min(u32::MAX as usize) as u32); + payload.extend_from_slice(run.text.as_bytes()); + push_u32(&mut payload, run.color.argb()); + push_u16(&mut payload, run.weight); + } + self.command(wire::DRAW_TEXT_RUNS, &payload) + } + + pub fn shadow_round_rect( + &mut self, + rect: Rect, + radius: f32, + sigma: f32, + offset: (f32, f32), + color: Rgba, + ) -> &mut Self { + let mut payload = rect_bytes(rect); + for value in [radius, sigma, offset.0, offset.1] { + push_f32(&mut payload, value); + } + push_u32(&mut payload, color.argb()); + self.command(wire::DRAW_SHADOW_ROUND_RECT, &payload) + } +} + +fn rect_bytes(rect: Rect) -> Vec { + let mut bytes = Vec::with_capacity(16); + for value in [rect.x, rect.y, rect.width, rect.height] { + push_f32(&mut bytes, value); + } + bytes +} + +fn push_text_flags(bytes: &mut Vec, style: &TextStyle) { + bytes.extend_from_slice(&[ + u8::from(style.italic), + style.align, + u8::from(style.wrap), + u8::from(style.ellipsis), + ]); +} + +fn push_family(bytes: &mut Vec, family: &str) { + let mut len = family.len().min(wire::MAX_FAMILY_BYTES); + while !family.is_char_boundary(len) { + len -= 1; + } + push_u16(bytes, len as u16); + bytes.extend_from_slice(&family.as_bytes()[..len]); +} + +fn push_u16(bytes: &mut Vec, value: u16) { + bytes.extend_from_slice(&value.to_le_bytes()); +} + +fn push_u32(bytes: &mut Vec, value: u32) { + bytes.extend_from_slice(&value.to_le_bytes()); +} + +fn push_f32(bytes: &mut Vec, value: f32) { + bytes.extend_from_slice(&value.to_le_bytes()); +} diff --git a/crates/winisland-plugin-api/src/sdk/mod.rs b/crates/winisland-plugin-api/src/sdk/mod.rs new file mode 100644 index 00000000..77b39038 --- /dev/null +++ b/crates/winisland-plugin-api/src/sdk/mod.rs @@ -0,0 +1,443 @@ +mod draw; +mod resources; + +use std::fmt; +use std::ptr::NonNull; + +use crate::abi::{ + self, ContextApiV2, HostStateApiV2, I18nApiV2, ImageApiV2, LogApiV2, LyricsTransformApiV2, + MediaApiV2, PluginHostV2, PluginStatus, SettingsApiV2, StoreApiV2, TablePrefix, TextApiV2, + WidgetApiV2, +}; +use crate::types::v2::widget::WidgetSpecV2; +use crate::types::v2::{ImageId, PluginToken, Utf8Slice, WidgetId}; + +pub use draw::*; +pub use resources::*; + +#[derive(Debug)] +pub enum Error { + InvalidHost, + MissingInterface(u32), + MissingFunction(&'static str), + InvalidArgument, + StaleHandle, + CapabilityMissing, + LimitExceeded, + UnsupportedVersion, + Io, + Internal, + UnknownStatus(i32), + InvalidText, + Length, +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidHost => formatter.write_str("invalid plugin host table"), + Self::MissingInterface(id) => write!(formatter, "host interface {id} unavailable"), + Self::MissingFunction(name) => write!(formatter, "host function {name} unavailable"), + Self::InvalidArgument => formatter.write_str("host rejected an argument"), + Self::StaleHandle => formatter.write_str("resource handle is stale"), + Self::CapabilityMissing => formatter.write_str("plugin capability is missing"), + Self::LimitExceeded => formatter.write_str("host limit exceeded"), + Self::UnsupportedVersion => formatter.write_str("ABI version is unsupported"), + Self::Io => formatter.write_str("host I/O failed"), + Self::Internal => formatter.write_str("host internal error"), + Self::UnknownStatus(code) => write!(formatter, "unknown host status {code}"), + Self::InvalidText => formatter.write_str("host returned invalid UTF-8"), + Self::Length => formatter.write_str("value exceeds ABI length limit"), + } + } +} + +impl std::error::Error for Error {} + +fn success(status: PluginStatus) -> Result<(), Error> { + if status == PluginStatus::Ok { + Ok(()) + } else { + Err(status_error(status)) + } +} + +fn status_error(status: PluginStatus) -> Error { + match status { + PluginStatus::Ok => Error::Internal, + PluginStatus::InvalidArgument => Error::InvalidArgument, + PluginStatus::StaleHandle => Error::StaleHandle, + PluginStatus::CapabilityMissing => Error::CapabilityMissing, + PluginStatus::LimitExceeded => Error::LimitExceeded, + PluginStatus::UnsupportedVersion => Error::UnsupportedVersion, + PluginStatus::IoError => Error::Io, + PluginStatus::Internal => Error::Internal, + _ => Error::UnknownStatus(status.code()), + } +} + +#[derive(Clone)] +pub struct Host { + raw: NonNull, + token: PluginToken, +} + +// SAFETY: The host guarantees the table stays allocated until plugin shutdown completes. +// All table operations accept concurrent calls and use the instance context for synchronization. +unsafe impl Send for Host {} +// SAFETY: The same host table is immutable and its operations are thread-safe by ABI contract. +unsafe impl Sync for Host {} + +impl Host { + /// # Safety + /// `raw` must point to a valid host table for the lifetime of this wrapper. + pub unsafe fn from_raw(raw: *const PluginHostV2, token: PluginToken) -> Result { + let raw = NonNull::new(raw.cast_mut()).ok_or(Error::InvalidHost)?; + // SAFETY: The caller guarantees a readable table prefix before full-table validation. + let prefix = unsafe { std::ptr::read_unaligned(raw.as_ptr().cast::()) }; + if prefix.struct_size < std::mem::size_of::() as u32 + || prefix.version != abi::ABI_VERSION_2 + || prefix.context.is_null() + { + return Err(Error::InvalidHost); + } + // SAFETY: The validated size covers the current host table. + let host = unsafe { std::ptr::read_unaligned(raw.as_ptr()) }; + if host.query.is_none() { + return Err(Error::InvalidHost); + } + Ok(Self { raw, token }) + } + + fn query(&self, interface: u32) -> Result { + // SAFETY: Construction validated the host pointer and plugin shutdown has not completed. + let host = unsafe { self.raw.as_ref() }; + let query = host.query.ok_or(Error::InvalidHost)?; + // SAFETY: The validated host query accepts this instance context and interface id. + let pointer = unsafe { query(host.prefix.context, interface, abi::IFACE_VERSION_1) }; + if pointer.is_null() { + return Err(Error::MissingInterface(interface)); + } + // SAFETY: Every service table begins with a readable TablePrefix. + let prefix = unsafe { std::ptr::read_unaligned(pointer.cast::()) }; + if prefix.struct_size < std::mem::size_of::() as u32 + || prefix.version != abi::IFACE_VERSION_1 + || prefix.context != host.prefix.context + { + return Err(Error::InvalidHost); + } + // SAFETY: The checked size covers the complete copyable table. + Ok(unsafe { std::ptr::read_unaligned(pointer.cast::()) }) + } + + pub fn context(&self) -> Result { + self.query::(abi::IFACE_CONTEXT)?; + Ok(ContextApi { + _host: self.clone(), + }) + } + + pub fn media(&self) -> Result { + self.query::(abi::IFACE_MEDIA)?; + Ok(MediaApi(self.clone())) + } + + pub fn i18n(&self) -> Result { + self.query::(abi::IFACE_I18N)?; + Ok(I18nApi { + _host: self.clone(), + }) + } + + pub fn host_state(&self) -> Result { + self.query::(abi::IFACE_HOST_STATE)?; + Ok(HostStateApi { + _host: self.clone(), + }) + } + + pub fn widgets(&self) -> Result { + self.query::(abi::IFACE_WIDGET)?; + Ok(WidgetApi(self.clone())) + } + + pub fn lyrics(&self) -> Result { + self.query::(abi::IFACE_LYRICS_TRANSFORM)?; + Ok(LyricsApi { + _host: self.clone(), + }) + } + + pub fn settings(&self) -> Result { + self.query::(abi::IFACE_SETTINGS)?; + Ok(SettingsApi { + _host: self.clone(), + }) + } + + pub fn text(&self) -> Result { + self.query::(abi::IFACE_TEXT)?; + Ok(TextApi { + _host: self.clone(), + }) + } + + pub fn images(&self) -> Result { + self.query::(abi::IFACE_IMAGE)?; + Ok(ImageApi(self.clone())) + } + + pub fn store(&self) -> Result { + self.query::(abi::IFACE_STORE)?; + Ok(StoreApi { + _host: self.clone(), + }) + } + + pub fn log(&self) -> LogApi { + LogApi(self.clone()) + } +} + +pub struct ContextApi { + _host: Host, +} +pub struct I18nApi { + _host: Host, +} +pub struct HostStateApi { + _host: Host, +} +pub struct LyricsApi { + _host: Host, +} +pub struct SettingsApi { + _host: Host, +} +pub struct TextApi { + _host: Host, +} +pub struct StoreApi { + _host: Host, +} +pub struct LogApi(Host); + +pub struct MediaApi(Host); + +impl MediaApi { + pub fn title(&self) -> Result { + let table = self.0.query::(abi::IFACE_MEDIA)?; + let function = table + .current_title + .ok_or(Error::MissingFunction("current_title"))?; + let mut required = 0_u32; + // SAFETY: The out length is valid and a zero-capacity query writes no title bytes. + let status = unsafe { + function( + table.prefix.context, + self.0.token, + std::ptr::null_mut(), + 0, + &mut required, + ) + }; + if status != PluginStatus::Ok && status != PluginStatus::LimitExceeded { + return Err(status_error(status)); + } + let mut bytes = vec![0_u8; required as usize]; + // SAFETY: The allocation provides `required` writable bytes and the out length is valid. + success(unsafe { + function( + table.prefix.context, + self.0.token, + bytes.as_mut_ptr(), + required, + &mut required, + ) + })?; + bytes.truncate(required as usize); + String::from_utf8(bytes).map_err(|_| Error::InvalidText) + } +} + +pub struct WidgetSpec { + raw: WidgetSpecV2, +} + +impl WidgetSpec { + pub fn new(key: &str) -> Self { + let mut raw = WidgetSpecV2::default(); + copy_fixed(&mut raw.key, key); + Self { raw } + } + + pub fn span(mut self, columns: u32, rows: u32) -> Self { + self.raw.span_cols = columns; + self.raw.span_rows = rows; + self + } + + pub fn title(mut self, title: &str) -> Self { + copy_fixed(&mut self.raw.title, title); + self + } +} + +fn copy_fixed(target: &mut [u8], value: &str) { + let mut len = value.len().min(target.len().saturating_sub(1)); + while !value.is_char_boundary(len) { + len -= 1; + } + target[..len].copy_from_slice(&value.as_bytes()[..len]); +} + +pub struct WidgetApi(Host); + +impl WidgetApi { + pub fn create(&self, spec: WidgetSpec) -> Result { + let table = self.0.query::(abi::IFACE_WIDGET)?; + let function = table + .create + .ok_or(Error::MissingFunction("widget.create"))?; + let mut id = WidgetId::INVALID; + // SAFETY: The spec and out id remain valid for the duration of the host call. + success(unsafe { function(table.prefix.context, self.0.token, &spec.raw, &mut id) })?; + Ok(Widget { + host: self.0.clone(), + id, + }) + } +} + +pub struct Widget { + host: Host, + id: WidgetId, +} + +impl Widget { + pub fn id(&self) -> WidgetId { + self.id + } + + pub fn logical_size(&self) -> (f32, f32) { + let Ok(table) = self.host.query::(abi::IFACE_WIDGET) else { + return (0.0, 0.0); + }; + let Some(function) = table.logical_size else { + return (0.0, 0.0); + }; + let mut width = 0.0; + let mut height = 0.0; + // SAFETY: The out dimensions are valid for the synchronous host call. + let status = unsafe { + function( + table.prefix.context, + self.host.token, + self.id, + &mut width, + &mut height, + ) + }; + if status == PluginStatus::Ok { + (width, height) + } else { + (0.0, 0.0) + } + } + + pub fn submit(&self, list: DrawList<'_>) -> Result<(), Error> { + let table = self.host.query::(abi::IFACE_WIDGET)?; + let function = table + .submit_draw_list + .ok_or(Error::MissingFunction("widget.submit_draw_list"))?; + let len = u32::try_from(list.as_bytes().len()).map_err(|_| Error::Length)?; + // SAFETY: The list stays allocated until the host copies it before returning. + success(unsafe { + function( + table.prefix.context, + self.host.token, + self.id, + list.as_bytes().as_ptr(), + len, + ) + }) + } + + pub fn request_redraw(&self) { + if let Ok(table) = self.host.query::(abi::IFACE_WIDGET) + && let Some(function) = table.request_redraw + { + // SAFETY: The host validates the token and widget id. + let _ = unsafe { function(table.prefix.context, self.host.token, self.id) }; + } + } +} + +impl Drop for Widget { + fn drop(&mut self) { + if let Ok(table) = self.host.query::(abi::IFACE_WIDGET) + && let Some(function) = table.release + { + // SAFETY: The host validates stale ids and the plugin is still within its lifetime. + let _ = unsafe { function(table.prefix.context, self.host.token, self.id) }; + } + } +} + +pub struct ImageApi(Host); + +impl ImageApi { + pub fn album_art(&self) -> Result { + let table = self.0.query::(abi::IFACE_IMAGE)?; + let function = table + .album_art + .ok_or(Error::MissingFunction("image.album_art"))?; + let mut id = ImageId::INVALID; + // SAFETY: The out image id is valid for the synchronous host call. + success(unsafe { function(table.prefix.context, self.0.token, &mut id) })?; + Ok(ImageHandle { + host: self.0.clone(), + id, + }) + } +} + +pub struct ImageHandle { + host: Host, + id: ImageId, +} + +impl ImageHandle { + pub fn id(&self) -> ImageId { + self.id + } +} + +impl Drop for ImageHandle { + fn drop(&mut self) { + if let Ok(table) = self.host.query::(abi::IFACE_IMAGE) + && let Some(function) = table.release + { + // SAFETY: The host validates the token and image id before release. + let _ = unsafe { function(table.prefix.context, self.host.token, self.id) }; + } + } +} + +impl LogApi { + pub fn write(&self, level: u32, message: &str) { + if let Ok(table) = self.0.query::(abi::IFACE_LOG) + && let Some(function) = table.write + { + // SAFETY: The borrowed UTF-8 string remains valid for the synchronous host call. + let _ = unsafe { + function( + table.prefix.context, + self.0.token, + level, + Utf8Slice::borrowed(message), + ) + }; + } + } +} diff --git a/crates/winisland-plugin-api/src/sdk/resources.rs b/crates/winisland-plugin-api/src/sdk/resources.rs new file mode 100644 index 00000000..04b078fc --- /dev/null +++ b/crates/winisland-plugin-api/src/sdk/resources.rs @@ -0,0 +1,435 @@ +use std::ffi::c_void; + +use super::{ + ContextApi, Error, Host, HostStateApi, ImageApi, ImageHandle, LyricsApi, MediaApi, SettingsApi, + StoreApi, TextApi, copy_fixed, success, +}; +use crate::abi::{ + self, ContextApiV2, HostStateApiV2, ImageApiV2, LyricsTransformApiV2, MediaApiV2, PluginStatus, + SettingsApiV2, StoreApiV2, TextApiV2, +}; +use crate::types::v2::context::{ContextDataV2, HostStateV2, MediaSourceDataV2}; +use crate::types::v2::lyrics::{LyricsTextV2, LyricsTransformerDataV2}; +use crate::types::v2::settings::{SETTINGS_ITEM_SECTION, SettingsItemV2, SettingsPageDataV2}; +use crate::types::v2::{ByteSlice, ImageId, ResourceId, TextMetricsV2, TextStyleV2, Utf8Slice}; + +enum ResourceKind { + Context, + Media, + Lyrics, + Settings, +} + +type Transform = dyn Fn(&str) -> String + Send + Sync; +type TransformHolder = Box>; + +pub struct Resource { + host: Host, + id: ResourceId, + kind: ResourceKind, + callback: Option, +} + +impl Resource { + pub fn id(&self) -> ResourceId { + self.id + } +} + +impl Drop for Resource { + fn drop(&mut self) { + let result = match self.kind { + ResourceKind::Context => self + .host + .query::(abi::IFACE_CONTEXT) + .ok() + .and_then(|table| { + table.release.map(|release| { + // SAFETY: The resource remains owned by this plugin until release returns. + unsafe { release(table.prefix.context, self.host.token, self.id) } + }) + }), + ResourceKind::Media => self + .host + .query::(abi::IFACE_MEDIA) + .ok() + .and_then(|table| { + table.release.map(|release| { + // SAFETY: The resource remains owned by this plugin until release returns. + unsafe { release(table.prefix.context, self.host.token, self.id) } + }) + }), + ResourceKind::Lyrics => self + .host + .query::(abi::IFACE_LYRICS_TRANSFORM) + .ok() + .and_then(|table| { + table.release.map(|release| { + // SAFETY: The host stops callbacks before release returns. + unsafe { release(table.prefix.context, self.host.token, self.id) } + }) + }), + ResourceKind::Settings => self + .host + .query::(abi::IFACE_SETTINGS) + .ok() + .and_then(|table| { + table.release.map(|release| { + // SAFETY: The resource remains owned by this plugin until release returns. + unsafe { release(table.prefix.context, self.host.token, self.id) } + }) + }), + }; + if !matches!(result, Some(PluginStatus::Ok | PluginStatus::StaleHandle)) + && let Some(callback) = self.callback.take() + { + std::mem::forget(callback); + } + } +} + +impl ContextApi { + pub fn create(&self, title: &str, body: &str) -> Result { + let table = self._host.query::(abi::IFACE_CONTEXT)?; + let create = table + .create + .ok_or(Error::MissingFunction("context.create"))?; + let mut data = ContextDataV2::default(); + copy_fixed(&mut data.title, title); + copy_fixed(&mut data.body, body); + let mut id = ResourceId::INVALID; + // SAFETY: The data and output id live until this synchronous call returns. + success(unsafe { create(table.prefix.context, self._host.token, &data, &mut id) })?; + Ok(Resource { + host: self._host.clone(), + id, + kind: ResourceKind::Context, + callback: None, + }) + } +} + +impl MediaApi { + pub fn create_source(&self, title: &str, artist: &str) -> Result { + let table = self.0.query::(abi::IFACE_MEDIA)?; + let create = table.create.ok_or(Error::MissingFunction("media.create"))?; + let mut data = MediaSourceDataV2::default(); + copy_fixed(&mut data.title, title); + copy_fixed(&mut data.artist, artist); + let mut id = ResourceId::INVALID; + // SAFETY: The host copies the source data during this call. + success(unsafe { create(table.prefix.context, self.0.token, &data, &mut id) })?; + Ok(Resource { + host: self.0.clone(), + id, + kind: ResourceKind::Media, + callback: None, + }) + } +} + +impl HostStateApi { + pub fn get(&self) -> Result { + let table = self._host.query::(abi::IFACE_HOST_STATE)?; + let get = table.get.ok_or(Error::MissingFunction("host_state.get"))?; + let mut state = HostStateV2::default(); + // SAFETY: The output snapshot lives until the synchronous call returns. + success(unsafe { get(table.prefix.context, self._host.token, &mut state) })?; + Ok(state) + } +} + +unsafe extern "C" fn transform_line( + callback_data: *mut c_void, + _resource_id: ResourceId, + input: *const LyricsTextV2, + output: *mut u8, + capacity: u32, + out_len: *mut u32, +) -> PluginStatus { + if callback_data.is_null() || input.is_null() || out_len.is_null() { + return PluginStatus::InvalidArgument; + } + // SAFETY: The host owns this callback invocation and keeps the input alive for its duration. + let input = unsafe { &*input }; + if input.text.ptr.is_null() && input.text.len != 0 { + return PluginStatus::InvalidArgument; + } + // SAFETY: A non-null slice pointer is valid for the declared length by ABI contract. + let bytes = unsafe { + std::slice::from_raw_parts( + if input.text.len == 0 { + std::ptr::NonNull::dangling().as_ptr() + } else { + input.text.ptr + }, + input.text.len as usize, + ) + }; + let Ok(text) = std::str::from_utf8(bytes) else { + return PluginStatus::InvalidArgument; + }; + // SAFETY: The callback pointer is the stable inner Box retained by Resource. + let callback = unsafe { &*(callback_data as *const Box) }; + let Ok(value) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(text))) + else { + return PluginStatus::Internal; + }; + let Ok(required) = u32::try_from(value.len()) else { + return PluginStatus::LimitExceeded; + }; + // SAFETY: The host provides a valid out_len pointer. + unsafe { *out_len = required }; + if output.is_null() && capacity == 0 { + return PluginStatus::Ok; + } + if capacity < required { + return PluginStatus::LimitExceeded; + } + if required != 0 { + if output.is_null() { + return PluginStatus::InvalidArgument; + } + // SAFETY: The host provides at least `capacity` writable bytes. + unsafe { std::ptr::copy_nonoverlapping(value.as_ptr(), output, required as usize) }; + } + PluginStatus::Ok +} + +impl LyricsApi { + pub fn register(&self, transform: F) -> Result + where + F: Fn(&str) -> String + Send + Sync + 'static, + { + let table = self + ._host + .query::(abi::IFACE_LYRICS_TRANSFORM)?; + let register = table + .register + .ok_or(Error::MissingFunction("lyrics.register"))?; + let mut callback: TransformHolder = Box::new(Box::new(transform)); + let data = LyricsTransformerDataV2 { + on_transform: Some(transform_line), + callback_data: (&mut *callback as *mut Box).cast(), + ..Default::default() + }; + let mut id = ResourceId::INVALID; + // SAFETY: The callback pointer remains stable in Resource until release completes. + success(unsafe { register(table.prefix.context, self._host.token, &data, &mut id) })?; + Ok(Resource { + host: self._host.clone(), + id, + kind: ResourceKind::Lyrics, + callback: Some(callback), + }) + } +} + +impl SettingsApi { + pub fn create_page(&self, key: &str, title: &str) -> Result { + let table = self._host.query::(abi::IFACE_SETTINGS)?; + let create = table + .create + .ok_or(Error::MissingFunction("settings.create"))?; + let mut data = SettingsPageDataV2::default(); + copy_fixed(&mut data.key, key); + copy_fixed(&mut data.title, title); + let mut item = SettingsItemV2 { + kind: SETTINGS_ITEM_SECTION, + ..Default::default() + }; + copy_fixed(&mut item.label, title); + data.items = &item; + data.item_count = 1; + let mut id = ResourceId::INVALID; + // SAFETY: The host copies the page data during this call. + success(unsafe { create(table.prefix.context, self._host.token, &data, &mut id) })?; + Ok(Resource { + host: self._host.clone(), + id, + kind: ResourceKind::Settings, + callback: None, + }) + } + + pub fn create_label_page( + &self, + key: &str, + title: &str, + label: &str, + ) -> Result { + let table = self._host.query::(abi::IFACE_SETTINGS)?; + let create = table + .create + .ok_or(Error::MissingFunction("settings.create"))?; + let mut item = SettingsItemV2 { + kind: SETTINGS_ITEM_SECTION, + ..Default::default() + }; + copy_fixed(&mut item.label, label); + let mut data = SettingsPageDataV2::default(); + copy_fixed(&mut data.key, key); + copy_fixed(&mut data.title, title); + data.items = &item; + data.item_count = 1; + let mut id = ResourceId::INVALID; + // SAFETY: The item and page remain valid until the host copies them. + success(unsafe { create(table.prefix.context, self._host.token, &data, &mut id) })?; + Ok(Resource { + host: self._host.clone(), + id, + kind: ResourceKind::Settings, + callback: None, + }) + } +} + +impl TextApi { + pub fn measure(&self, text: &str, size: f32, family: &str) -> Result { + let table = self._host.query::(abi::IFACE_TEXT)?; + let measure = table + .measure + .ok_or(Error::MissingFunction("text.measure"))?; + let style = TextStyleV2 { + size, + weight: 400, + italic: 0, + reserved: 0, + family: Utf8Slice::borrowed(family), + }; + let mut metrics = TextMetricsV2::default(); + // SAFETY: Borrowed text and style remain valid during the host call. + success(unsafe { + measure( + table.prefix.context, + self._host.token, + Utf8Slice::borrowed(text), + &style, + &mut metrics, + ) + })?; + Ok(metrics) + } +} + +impl ImageApi { + pub fn decode(&self, encoded: &[u8]) -> Result { + let table = self.0.query::(abi::IFACE_IMAGE)?; + let decode = table.decode.ok_or(Error::MissingFunction("image.decode"))?; + let mut id = ImageId::INVALID; + // SAFETY: The borrowed image bytes remain valid during the call. + success(unsafe { + decode( + table.prefix.context, + self.0.token, + ByteSlice::borrowed(encoded), + &mut id, + ) + })?; + Ok(ImageHandle { + host: self.0.clone(), + id, + }) + } + + pub fn upload_rgba(&self, width: u32, height: u32, rgba: &[u8]) -> Result { + let expected = (width as usize) + .checked_mul(height as usize) + .and_then(|pixels| pixels.checked_mul(4)); + if expected != Some(rgba.len()) { + return Err(Error::Length); + } + let table = self.0.query::(abi::IFACE_IMAGE)?; + let upload = table + .upload_rgba + .ok_or(Error::MissingFunction("image.upload_rgba"))?; + let mut id = ImageId::INVALID; + // SAFETY: The borrowed RGBA bytes remain valid during the call. + success(unsafe { + upload( + table.prefix.context, + self.0.token, + width, + height, + ByteSlice::borrowed(rgba), + &mut id, + ) + })?; + Ok(ImageHandle { + host: self.0.clone(), + id, + }) + } +} + +impl StoreApi { + pub fn set(&self, key: &str, value: &[u8]) -> Result<(), Error> { + let table = self._host.query::(abi::IFACE_STORE)?; + let set = table.set.ok_or(Error::MissingFunction("store.set"))?; + // SAFETY: The host copies both borrowed slices before returning. + success(unsafe { + set( + table.prefix.context, + self._host.token, + Utf8Slice::borrowed(key), + ByteSlice::borrowed(value), + ) + }) + } + + pub fn delete(&self, key: &str) -> Result<(), Error> { + let table = self._host.query::(abi::IFACE_STORE)?; + let delete = table.delete.ok_or(Error::MissingFunction("store.delete"))?; + // SAFETY: The borrowed key remains valid during the call. + success(unsafe { + delete( + table.prefix.context, + self._host.token, + Utf8Slice::borrowed(key), + ) + }) + } + + pub fn get(&self, key: &str) -> Result>, Error> { + let table = self._host.query::(abi::IFACE_STORE)?; + let get = table.get.ok_or(Error::MissingFunction("store.get"))?; + let mut required = 0; + let mut found = 0; + // SAFETY: Output scalars remain writable during this synchronous call. + let status = unsafe { + get( + table.prefix.context, + self._host.token, + Utf8Slice::borrowed(key), + std::ptr::null_mut(), + 0, + &mut required, + &mut found, + ) + }; + if status != PluginStatus::Ok && status != PluginStatus::LimitExceeded { + return Err(super::status_error(status)); + } + if found == 0 { + return Ok(None); + } + let mut value = vec![0; required as usize]; + if required == 0 { + return Ok(Some(value)); + } + // SAFETY: The allocation has `required` writable bytes. + success(unsafe { + get( + table.prefix.context, + self._host.token, + Utf8Slice::borrowed(key), + value.as_mut_ptr(), + required, + &mut required, + &mut found, + ) + })?; + value.truncate(required as usize); + Ok(if found == 0 { None } else { Some(value) }) + } +} diff --git a/crates/winisland-plugin-api/src/types/i18n.rs b/crates/winisland-plugin-api/src/types/i18n.rs deleted file mode 100644 index 76128f29..00000000 --- a/crates/winisland-plugin-api/src/types/i18n.rs +++ /dev/null @@ -1,7 +0,0 @@ -/// A borrowed translation key-value pair. -#[repr(C)] -#[derive(Clone, Copy)] -pub struct TranslationPairV1 { - pub key: crate::Utf8SliceV1, - pub value: crate::Utf8SliceV1, -} diff --git a/crates/winisland-plugin-api/src/types/mod.rs b/crates/winisland-plugin-api/src/types/mod.rs index 65ac4331..06311455 100644 --- a/crates/winisland-plugin-api/src/types/mod.rs +++ b/crates/winisland-plugin-api/src/types/mod.rs @@ -1,127 +1,6 @@ -pub mod context; -pub mod i18n; -pub mod lyrics; pub mod metadata; -pub mod settings; -pub mod widget; +pub mod v2; -/// Opaque plugin-owned instance handle. -pub type PluginHandle = *mut std::ffi::c_void; - -/// Host-issued identity used to validate every plugin-to-host call. -pub type PluginToken = u64; - -/// Host-issued identifier for a plugin-owned resource. -pub type ResourceId = u64; - -/// Invalid token or resource identifier. -pub const INVALID_ID: u64 = 0; - -/// Borrowed bytes that are valid only for the duration of an FFI call. -#[repr(C)] -#[derive(Clone, Copy)] -pub struct ByteSliceV1 { - pub ptr: *const u8, - pub len: u32, -} - -impl ByteSliceV1 { - pub const fn empty() -> Self { - Self { - ptr: std::ptr::null(), - len: 0, - } - } - - pub fn from_slice(value: &[u8]) -> Self { - Self { - ptr: value.as_ptr(), - len: value.len().min(u32::MAX as usize) as u32, - } - } -} - -/// Borrowed UTF-8 bytes that are valid only for the duration of an FFI call. -#[repr(C)] -#[derive(Clone, Copy)] -pub struct Utf8SliceV1 { - pub ptr: *const u8, - pub len: u32, -} - -impl Utf8SliceV1 { - pub const fn empty() -> Self { - Self { - ptr: std::ptr::null(), - len: 0, - } - } - - pub fn borrowed(value: &str) -> Self { - Self { - ptr: value.as_ptr(), - len: value.len().min(u32::MAX as usize) as u32, - } - } -} - -/// The return type for fallible plugin host calls. -/// -/// This is a C-compatible equivalent of `Result<(), String>`. -#[repr(C)] -#[derive(Debug, Clone, Copy)] -pub struct PluginResultC { - /// Zero for success, non-zero for failure. - pub status: i32, - /// Null-terminated UTF-8 error message (max 255 bytes + NUL). - pub error: [u8; 256], -} - -impl PluginResultC { - /// Construct a success result. - pub fn ok() -> Self { - Self { - status: 0, - error: [0u8; 256], - } - } - - /// Construct an error result with the given message. - /// - /// The message is truncated to 255 bytes if it exceeds the buffer. - pub fn err(msg: &str) -> Self { - let mut error = [0u8; 256]; - let bytes = msg.as_bytes(); - let mut len = bytes.len().min(255); - while !msg.is_char_boundary(len) { - len -= 1; - } - error[..len].copy_from_slice(&bytes[..len]); - Self { status: 1, error } - } - - /// Convert back into a Rust `Result`. - pub fn into_result(self) -> Result<(), String> { - if self.status == 0 { - Ok(()) - } else { - let end = self.error.iter().position(|&b| b == 0).unwrap_or(256); - Err(String::from_utf8_lossy(&self.error[..end]).into_owned()) - } - } -} - -/// Fill a fixed-size byte buffer with a string, zeroing the rest. -/// -/// Useful for initialising `#[repr(C)]` struct fields with a -/// null-terminated string. The string is truncated if it doesn't fit. -/// -/// ```rust -/// use winisland_plugin_api::str_to_fixed; -/// let buf: [u8; 64] = str_to_fixed("hello"); -/// assert_eq!(&buf[..6], b"hello\0"); -/// assert_eq!(buf[6..].iter().all(|&b| b == 0), true); -/// ``` pub const fn str_to_fixed(s: &str) -> [u8; N] { let mut buf = [0u8; N]; let bytes = s.as_bytes(); diff --git a/crates/winisland-plugin-api/src/types/context.rs b/crates/winisland-plugin-api/src/types/v2/context.rs similarity index 87% rename from crates/winisland-plugin-api/src/types/context.rs rename to crates/winisland-plugin-api/src/types/v2/context.rs index 32938086..b521c5c3 100644 --- a/crates/winisland-plugin-api/src/types/context.rs +++ b/crates/winisland-plugin-api/src/types/v2/context.rs @@ -11,8 +11,8 @@ pub const CONTEXT_FLAG_SHOW_COMPACT: u32 = 1 << 0; /// Context content owned by a plugin resource. #[repr(C)] #[derive(Clone, Copy)] -pub struct ContextDataV1 { - /// Must be `size_of::()`. +pub struct ContextDataV2 { + /// Must be `size_of::()`. pub struct_size: u32, /// Priority: [`PRIORITY_LOW`], [`PRIORITY_MEDIUM`], [`PRIORITY_HIGH`]. pub priority: u32, @@ -28,7 +28,7 @@ pub struct ContextDataV1 { pub compact_text: [u8; 128], } -impl Default for ContextDataV1 { +impl Default for ContextDataV2 { fn default() -> Self { Self { struct_size: std::mem::size_of::() as u32, @@ -45,8 +45,8 @@ impl Default for ContextDataV1 { /// Snapshot of the current host state that a plugin can query. #[repr(C)] #[derive(Clone, Copy)] -pub struct HostStateV1 { - /// Must be `size_of::()`. +pub struct HostStateV2 { + /// Must be `size_of::()`. pub struct_size: u32, /// Reserved for future state flags. pub flags: u32, @@ -61,7 +61,7 @@ pub struct HostStateV1 { pub theme: [u8; 32], } -impl Default for HostStateV1 { +impl Default for HostStateV2 { fn default() -> Self { Self { struct_size: std::mem::size_of::() as u32, @@ -90,24 +90,24 @@ pub const MEDIA_COMMAND_SEEK: u32 = 4; #[repr(C)] #[derive(Clone, Copy)] -pub struct MediaCommandV1 { +pub struct MediaCommandV2 { pub struct_size: u32, pub command: u32, /// Used only by `MEDIA_COMMAND_SEEK`. pub position_ms: u64, } -pub type MediaCommandFnV1 = unsafe extern "C" fn( +pub type MediaCommandFnV2 = unsafe extern "C" fn( callback_data: *mut std::ffi::c_void, - resource_id: crate::ResourceId, - command: *const MediaCommandV1, + resource_id: super::ResourceId, + command: *const MediaCommandV2, ); /// Display-only media source data supplied by a plugin. #[repr(C)] #[derive(Clone, Copy)] -pub struct MediaSourceDataV1 { - /// Must be `size_of::()`. +pub struct MediaSourceDataV2 { + /// Must be `size_of::()`. pub struct_size: u32, /// Combination of `MEDIA_FLAG_*` values. pub flags: u32, @@ -125,14 +125,14 @@ pub struct MediaSourceDataV1 { /// Album name. Max 255 bytes + NUL. pub album: [u8; 256], /// Raw JPEG or PNG bytes. The host copies them before returning. - pub cover: crate::ByteSliceV1, + pub cover: super::ByteSlice, /// Optional callback for controls declared in `available_controls`. - pub on_command: Option, + pub on_command: Option, /// Opaque pointer passed back to `on_command`. pub callback_data: *mut std::ffi::c_void, } -impl Default for MediaSourceDataV1 { +impl Default for MediaSourceDataV2 { fn default() -> Self { Self { struct_size: std::mem::size_of::() as u32, @@ -144,7 +144,7 @@ impl Default for MediaSourceDataV1 { title: [0; 256], artist: [0; 256], album: [0; 256], - cover: crate::ByteSliceV1::empty(), + cover: super::ByteSlice::empty(), on_command: None, callback_data: std::ptr::null_mut(), } diff --git a/crates/winisland-plugin-api/src/types/v2/i18n.rs b/crates/winisland-plugin-api/src/types/v2/i18n.rs new file mode 100644 index 00000000..2f339621 --- /dev/null +++ b/crates/winisland-plugin-api/src/types/v2/i18n.rs @@ -0,0 +1,7 @@ +/// A borrowed translation key-value pair. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct TranslationPairV2 { + pub key: super::Utf8Slice, + pub value: super::Utf8Slice, +} diff --git a/crates/winisland-plugin-api/src/types/lyrics.rs b/crates/winisland-plugin-api/src/types/v2/lyrics.rs similarity index 76% rename from crates/winisland-plugin-api/src/types/lyrics.rs rename to crates/winisland-plugin-api/src/types/v2/lyrics.rs index ba6a555e..a06465a6 100644 --- a/crates/winisland-plugin-api/src/types/lyrics.rs +++ b/crates/winisland-plugin-api/src/types/v2/lyrics.rs @@ -1,6 +1,7 @@ use std::ffi::c_void; -use crate::{PluginResultC, ResourceId, Utf8SliceV1}; +use super::{ResourceId, Utf8Slice}; +use crate::abi::PluginStatus; /// The lyric line contains word-synchronised timing boundaries. pub const LYRICS_TEXT_FLAG_WORD_SYNCED: u32 = 1 << 0; @@ -8,24 +9,24 @@ pub const LYRICS_TEXT_FLAG_WORD_SYNCED: u32 = 1 << 0; /// A parsed lyric line supplied to a registered transformer. #[repr(C)] #[derive(Clone, Copy)] -pub struct LyricsTextV1 { - /// Must be `size_of::()`. +pub struct LyricsTextV2 { + /// Must be `size_of::()`. pub struct_size: u32, /// Combination of `LYRICS_TEXT_FLAG_*` values. pub flags: u32, /// Timestamp of this line in milliseconds. pub line_time_ms: u64, /// Borrowed UTF-8 text valid only for this callback. - pub text: Utf8SliceV1, + pub text: Utf8Slice, } -impl Default for LyricsTextV1 { +impl Default for LyricsTextV2 { fn default() -> Self { Self { struct_size: std::mem::size_of::() as u32, flags: 0, line_time_ms: 0, - text: Utf8SliceV1::empty(), + text: Utf8Slice::empty(), } } } @@ -39,29 +40,29 @@ impl Default for LyricsTextV1 { /// /// Word-synchronised lines must keep the same Unicode character count so the /// host can preserve their timing boundaries. -pub type LyricsTransformFnV1 = unsafe extern "C" fn( +pub type LyricsTransformFnV2 = unsafe extern "C" fn( callback_data: *mut c_void, resource_id: ResourceId, - input: *const LyricsTextV1, + input: *const LyricsTextV2, output: *mut u8, output_capacity: u32, out_len: *mut u32, -) -> PluginResultC; +) -> PluginStatus; /// Registration data for a lyric text transformer. #[repr(C)] #[derive(Clone, Copy)] -pub struct LyricsTransformerDataV1 { - /// Must be `size_of::()`. +pub struct LyricsTransformerDataV2 { + /// Must be `size_of::()`. pub struct_size: u32, /// Reserved for future flags. Must be zero. pub flags: u32, - pub on_transform: Option, + pub on_transform: Option, /// Opaque pointer passed back to `on_transform`. pub callback_data: *mut c_void, } -impl Default for LyricsTransformerDataV1 { +impl Default for LyricsTransformerDataV2 { fn default() -> Self { Self { struct_size: std::mem::size_of::() as u32, diff --git a/crates/winisland-plugin-api/src/types/v2/mod.rs b/crates/winisland-plugin-api/src/types/v2/mod.rs new file mode 100644 index 00000000..f818e829 --- /dev/null +++ b/crates/winisland-plugin-api/src/types/v2/mod.rs @@ -0,0 +1,106 @@ +pub mod context; +pub mod i18n; +pub mod lyrics; +pub mod settings; +pub mod widget; + +use std::ffi::c_void; + +use crate::abi::PluginStatus; + +macro_rules! handle { + ($name:ident) => { + #[repr(transparent)] + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] + pub struct $name(pub(crate) u64); + + impl $name { + pub const INVALID: Self = Self(0); + + pub const fn get(self) -> u64 { + self.0 + } + + /// # Safety + /// The host must validate the raw value and its owner before use. + pub const unsafe fn from_raw(raw: u64) -> Self { + Self(raw) + } + } + }; +} + +handle!(PluginToken); +handle!(ResourceId); +handle!(WidgetId); +handle!(ImageId); + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ByteSlice { + pub ptr: *const u8, + pub len: u32, +} + +impl ByteSlice { + pub const fn empty() -> Self { + Self { + ptr: std::ptr::null(), + len: 0, + } + } + + pub fn borrowed(value: &[u8]) -> Self { + Self { + ptr: value.as_ptr(), + len: value.len().min(u32::MAX as usize) as u32, + } + } +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct Utf8Slice { + pub ptr: *const u8, + pub len: u32, +} + +impl Utf8Slice { + pub const fn empty() -> Self { + Self { + ptr: std::ptr::null(), + len: 0, + } + } + + pub fn borrowed(value: &str) -> Self { + Self { + ptr: value.as_ptr(), + len: value.len().min(u32::MAX as usize) as u32, + } + } +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct TextStyleV2 { + pub size: f32, + pub weight: u16, + pub italic: u8, + pub reserved: u8, + pub family: Utf8Slice, +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct TextMetricsV2 { + pub width: f32, + pub height: f32, + pub ascent: f32, + pub descent: f32, +} + +pub type HostStateChangedFnV2 = unsafe extern "C" fn( + callback_data: *mut c_void, + state: *const context::HostStateV2, +) -> PluginStatus; diff --git a/crates/winisland-plugin-api/src/types/settings.rs b/crates/winisland-plugin-api/src/types/v2/settings.rs similarity index 82% rename from crates/winisland-plugin-api/src/types/settings.rs rename to crates/winisland-plugin-api/src/types/v2/settings.rs index 0f51d5f0..f338105b 100644 --- a/crates/winisland-plugin-api/src/types/settings.rs +++ b/crates/winisland-plugin-api/src/types/v2/settings.rs @@ -1,4 +1,5 @@ -use crate::{ByteSliceV1, PluginResultC, ResourceId}; +use super::{ByteSlice, ResourceId}; +use crate::abi::PluginStatus; pub const SETTINGS_ITEM_SECTION: u32 = 1; pub const SETTINGS_ITEM_GROUP_START: u32 = 2; @@ -13,7 +14,7 @@ pub const SETTINGS_ITEM_FLAG_DISABLED: u32 = 1 << 0; #[repr(C)] #[derive(Clone, Copy)] -pub struct SettingsOptionV1 { +pub struct SettingsOptionV2 { pub struct_size: u32, /// Stable value returned to the plugin. Max 127 bytes plus NUL. pub value: [u8; 128], @@ -21,7 +22,7 @@ pub struct SettingsOptionV1 { pub label: [u8; 256], } -impl Default for SettingsOptionV1 { +impl Default for SettingsOptionV2 { fn default() -> Self { Self { struct_size: std::mem::size_of::() as u32, @@ -33,7 +34,7 @@ impl Default for SettingsOptionV1 { #[repr(C)] #[derive(Clone, Copy)] -pub struct SettingsItemV1 { +pub struct SettingsItemV2 { pub struct_size: u32, /// One of the `SETTINGS_ITEM_*` constants. pub kind: u32, @@ -46,7 +47,7 @@ pub struct SettingsItemV1 { /// Current switch/select/stepper value, or the button label. pub value: [u8; 256], /// Borrowed options used only by `SETTINGS_ITEM_SELECT`. - pub options: *const SettingsOptionV1, + pub options: *const SettingsOptionV2, pub option_count: u32, /// Numeric bounds used only by `SETTINGS_ITEM_STEPPER`. pub minimum: f64, @@ -54,7 +55,7 @@ pub struct SettingsItemV1 { pub step: f64, } -impl Default for SettingsItemV1 { +impl Default for SettingsItemV2 { fn default() -> Self { Self { struct_size: std::mem::size_of::() as u32, @@ -74,7 +75,7 @@ impl Default for SettingsItemV1 { #[repr(C)] #[derive(Clone, Copy)] -pub struct SettingsChangeV1 { +pub struct SettingsChangeV2 { pub struct_size: u32, /// Key of the changed item. pub key: [u8; 64], @@ -83,37 +84,37 @@ pub struct SettingsChangeV1 { } /// Handle a user setting change. Return an error to reject the new value. -pub type SettingsChangedFnV1 = unsafe extern "C" fn( +pub type SettingsChangedFnV2 = unsafe extern "C" fn( callback_data: *mut std::ffi::c_void, page_id: ResourceId, - change: *const SettingsChangeV1, -) -> PluginResultC; + change: *const SettingsChangeV2, +) -> PluginStatus; #[repr(C)] #[derive(Clone, Copy)] -pub struct SettingsPageDataV1 { +pub struct SettingsPageDataV2 { pub struct_size: u32, /// Stable page key within this plugin. Max 63 ASCII bytes plus NUL. pub key: [u8; 64], /// Sidebar and page title. Max 127 bytes plus NUL. pub title: [u8; 128], /// Optional encoded PNG, JPEG, or WebP icon copied by the host. - pub icon: ByteSliceV1, + pub icon: ByteSlice, /// Borrowed declarative items copied by the host during create or update. - pub items: *const SettingsItemV1, + pub items: *const SettingsItemV2, pub item_count: u32, - /// Called on the settings UI thread after a user action. - pub on_change: Option, + /// Called on the plugin worker thread after a user action. + pub on_change: Option, pub callback_data: *mut std::ffi::c_void, } -impl Default for SettingsPageDataV1 { +impl Default for SettingsPageDataV2 { fn default() -> Self { Self { struct_size: std::mem::size_of::() as u32, key: [0; 64], title: [0; 128], - icon: ByteSliceV1::empty(), + icon: ByteSlice::empty(), items: std::ptr::null(), item_count: 0, on_change: None, diff --git a/crates/winisland-plugin-api/src/types/v2/widget.rs b/crates/winisland-plugin-api/src/types/v2/widget.rs new file mode 100644 index 00000000..1ebc398f --- /dev/null +++ b/crates/winisland-plugin-api/src/types/v2/widget.rs @@ -0,0 +1,31 @@ +pub const WIDGET_FLAG_SHOW_COMPACT: u32 = 1 << 0; + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct WidgetSpecV2 { + pub struct_size: u32, + pub span_cols: u32, + pub span_rows: u32, + pub flags: u32, + pub title: [u8; 256], + pub body: [u8; 512], + pub key: [u8; 64], + pub min_width: f32, + pub min_height: f32, +} + +impl Default for WidgetSpecV2 { + fn default() -> Self { + Self { + struct_size: std::mem::size_of::() as u32, + span_cols: 2, + span_rows: 1, + flags: 0, + title: [0; 256], + body: [0; 512], + key: [0; 64], + min_width: 0.0, + min_height: 0.0, + } + } +} diff --git a/crates/winisland-plugin-api/src/types/widget.rs b/crates/winisland-plugin-api/src/types/widget.rs deleted file mode 100644 index 6ca7b60b..00000000 --- a/crates/winisland-plugin-api/src/types/widget.rs +++ /dev/null @@ -1,51 +0,0 @@ -use crate::WidgetDrawFnV1; - -/// Show this widget's compact representation in the mini island. -/// -/// Reserved for future use; the host currently renders widgets on the -/// expanded widget page only. -pub const WIDGET_FLAG_SHOW_COMPACT: u32 = 1 << 0; - -/// Widget content owned by a plugin resource. -/// -/// The host places the widget on the expanded widget page grid and invokes -/// `on_draw` on every frame to render it. -#[repr(C)] -#[derive(Clone, Copy)] -pub struct WidgetDataV1 { - /// Must be `size_of::()`. - pub struct_size: u32, - /// Grid columns occupied (1-6). - pub span_cols: u32, - /// Grid rows occupied (1-3). - pub span_rows: u32, - /// Combination of `WIDGET_FLAG_*` values. - pub flags: u32, - /// Widget title. Max 255 bytes plus NUL. - pub title: [u8; 256], - /// Widget body text. Max 511 bytes plus NUL. - pub body: [u8; 512], - /// Render callback invoked synchronously on the render thread. - pub on_draw: Option, - /// Opaque pointer passed back to `on_draw`. - pub callback_data: *mut std::ffi::c_void, - /// Stable widget key within this plugin. Required for settings layout control. - /// Max 63 ASCII bytes plus NUL, must match `[a-zA-Z0-9_-]+`, and cannot change on update. - pub key: [u8; 64], -} - -impl Default for WidgetDataV1 { - fn default() -> Self { - Self { - struct_size: std::mem::size_of::() as u32, - span_cols: 2, - span_rows: 1, - flags: 0, - title: [0; 256], - body: [0; 512], - on_draw: None, - callback_data: std::ptr::null_mut(), - key: [0; 64], - } - } -} diff --git a/crates/winisland-plugin-host/Cargo.toml b/crates/winisland-plugin-host/Cargo.toml new file mode 100644 index 00000000..e1654757 --- /dev/null +++ b/crates/winisland-plugin-host/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "winisland-plugin-host" +version.workspace = true +edition = "2024" + +[dependencies] +libloading = "0.9" +log = "0.4" +sha2 = "0.10" +winisland-plugin-api = { path = "../winisland-plugin-api", default-features = false } +winisland-plugin-package = { path = "../winisland-plugin-package" } +winisland-core = { path = "../winisland-core" } +winisland-render = { path = "../winisland-render" } diff --git a/crates/winisland-plugin-host/src/abi/mod.rs b/crates/winisland-plugin-host/src/abi/mod.rs new file mode 100644 index 00000000..34d07643 --- /dev/null +++ b/crates/winisland-plugin-host/src/abi/mod.rs @@ -0,0 +1,3 @@ +mod tables; + +pub(crate) use tables::AbiTables; diff --git a/crates/winisland-plugin-host/src/abi/tables.rs b/crates/winisland-plugin-host/src/abi/tables.rs new file mode 100644 index 00000000..2f6b0095 --- /dev/null +++ b/crates/winisland-plugin-host/src/abi/tables.rs @@ -0,0 +1,154 @@ +use std::ffi::c_void; + +use winisland_plugin_api::abi::{ + self, ContextApiV2, HostStateApiV2, I18nApiV2, ImageApiV2, LogApiV2, LyricsTransformApiV2, + MediaApiV2, PluginHostV2, SettingsApiV2, StoreApiV2, TablePrefix, TextApiV2, WidgetApiV2, +}; + +use crate::runtime::HostRuntime; +use crate::services::{ + context, host_state, i18n, image, log, lyrics, media, settings, store, text, widget, +}; + +pub(crate) struct AbiTables { + pub host: PluginHostV2, + context: ContextApiV2, + media: MediaApiV2, + i18n: I18nApiV2, + host_state: HostStateApiV2, + widget: WidgetApiV2, + lyrics: LyricsTransformApiV2, + settings: SettingsApiV2, + text: TextApiV2, + image: ImageApiV2, + store: StoreApiV2, + log: LogApiV2, +} + +fn prefix(version: u32) -> TablePrefix { + TablePrefix { + struct_size: std::mem::size_of::() as u32, + version, + context: std::ptr::null_mut(), + } +} + +impl AbiTables { + pub fn new(host_build: u32) -> Self { + Self { + host: PluginHostV2 { + prefix: prefix::(abi::ABI_VERSION_2), + host_build, + query: Some(query), + }, + context: ContextApiV2 { + prefix: prefix::(abi::IFACE_VERSION_1), + create: Some(context::create), + update: Some(context::update), + release: Some(context::release), + }, + media: MediaApiV2 { + prefix: prefix::(abi::IFACE_VERSION_1), + create: Some(media::create), + update: Some(media::update), + release: Some(media::release), + current_title: Some(media::current_title), + }, + i18n: I18nApiV2 { + prefix: prefix::(abi::IFACE_VERSION_1), + register_bundle: Some(i18n::register_bundle), + release_bundle: Some(i18n::release_bundle), + }, + host_state: HostStateApiV2 { + prefix: prefix::(abi::IFACE_VERSION_1), + get: Some(host_state::get), + subscribe: Some(host_state::subscribe), + release_subscription: Some(host_state::release_subscription), + }, + widget: WidgetApiV2 { + prefix: prefix::(abi::IFACE_VERSION_1), + create: Some(widget::create), + update: Some(widget::update), + release: Some(widget::release), + submit_draw_list: Some(widget::submit_draw_list), + request_redraw: Some(widget::request_redraw), + logical_size: Some(widget::logical_size), + }, + lyrics: LyricsTransformApiV2 { + prefix: prefix::(abi::IFACE_VERSION_1), + register: Some(lyrics::register), + release: Some(lyrics::release), + }, + settings: SettingsApiV2 { + prefix: prefix::(abi::IFACE_VERSION_1), + create: Some(settings::create), + update: Some(settings::update), + release: Some(settings::release), + }, + text: TextApiV2 { + prefix: prefix::(abi::IFACE_VERSION_1), + measure: Some(text::measure), + font_family: Some(text::font_family), + }, + image: ImageApiV2 { + prefix: prefix::(abi::IFACE_VERSION_1), + decode: Some(image::decode), + upload_rgba: Some(image::upload_rgba), + album_art: Some(image::album_art), + release: Some(image::release), + }, + store: StoreApiV2 { + prefix: prefix::(abi::IFACE_VERSION_1), + get: Some(store::get), + set: Some(store::set), + delete: Some(store::delete), + }, + log: LogApiV2 { + prefix: prefix::(abi::IFACE_VERSION_1), + write: Some(log::write), + }, + } + } + + pub fn bind(&mut self, context: *mut c_void) { + self.host.prefix.context = context; + self.context.prefix.context = context; + self.media.prefix.context = context; + self.i18n.prefix.context = context; + self.host_state.prefix.context = context; + self.widget.prefix.context = context; + self.lyrics.prefix.context = context; + self.settings.prefix.context = context; + self.text.prefix.context = context; + self.image.prefix.context = context; + self.store.prefix.context = context; + self.log.prefix.context = context; + } +} + +unsafe extern "C" fn query( + context: *mut c_void, + interface: u32, + min_version: u32, +) -> *const c_void { + if context.is_null() || min_version > abi::IFACE_VERSION_1 { + return std::ptr::null(); + } + // SAFETY: The context is the stable Box address while the host table is live. + let runtime = unsafe { &*context.cast::() }; + let tables = &runtime.tables; + match interface { + abi::IFACE_CONTEXT => (&tables.context as *const ContextApiV2).cast(), + abi::IFACE_MEDIA => (&tables.media as *const MediaApiV2).cast(), + abi::IFACE_I18N => (&tables.i18n as *const I18nApiV2).cast(), + abi::IFACE_HOST_STATE => (&tables.host_state as *const HostStateApiV2).cast(), + abi::IFACE_WIDGET => (&tables.widget as *const WidgetApiV2).cast(), + abi::IFACE_LYRICS_TRANSFORM => (&tables.lyrics as *const LyricsTransformApiV2).cast(), + abi::IFACE_SETTINGS => (&tables.settings as *const SettingsApiV2).cast(), + abi::IFACE_TEXT => (&tables.text as *const TextApiV2).cast(), + abi::IFACE_IMAGE => (&tables.image as *const ImageApiV2).cast(), + abi::IFACE_STORE => (&tables.store as *const StoreApiV2).cast(), + abi::IFACE_LOG => (&tables.log as *const LogApiV2).cast(), + _ => std::ptr::null(), + } +} diff --git a/crates/winisland-plugin-host/src/draw/decode.rs b/crates/winisland-plugin-host/src/draw/decode.rs new file mode 100644 index 00000000..07b63bb6 --- /dev/null +++ b/crates/winisland-plugin-host/src/draw/decode.rs @@ -0,0 +1,328 @@ +use winisland_plugin_api::draw::v2 as wire; +use winisland_render::text::FontManager; +use winisland_render::{GradientStop, Image, ImageFit, Path, PathBuilder, Point, Rect, Rgba, Vec2}; + +use super::validate::ValidatedDrawList; + +pub struct PreparedFrame { + pub logical_width: f32, + pub logical_height: f32, + pub(crate) commands: Vec, +} + +pub(crate) enum DrawOp { + PushClipRect(Rect), + PushClipRoundRect(Rect, f32), + PopClip, + PushTransform([f32; 6]), + PopTransform, + PushAlpha(u8), + PopAlpha, + FillRect(Rect, Rgba), + FillRoundRect(Rect, f32, Rgba), + FillCircle(Point, f32, Rgba), + FillPolygon(Path, Rgba), + FillGradient(Rect, Point, Point, Vec), + StrokeLine(Point, Point, f32, Rgba), + StrokeRoundRect(Rect, f32, f32, Rgba), + StrokeArc(Rect, f32, f32, f32, Rgba), + Image(Image, Rect, ImageFit, u8), + Text(String, Rect, TextLayout, Rgba), + TextRuns(Vec, Rect, TextLayout), + Shadow(Path, f32, Rgba), +} + +pub(crate) struct TextLayout { + pub size: f32, + pub italic: bool, + pub align: u8, + pub wrap: bool, + pub ellipsis: bool, + pub family: String, + pub weight: u16, +} + +pub(crate) struct TextRun { + pub text: String, + pub color: Rgba, + pub weight: u16, +} + +struct Reader<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> Reader<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + fn take(&mut self, len: usize) -> Result<&'a [u8], &'static str> { + let end = self + .offset + .checked_add(len) + .ok_or("draw payload length overflow")?; + let part = self + .bytes + .get(self.offset..end) + .ok_or("truncated validated draw payload")?; + self.offset = end; + Ok(part) + } + + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes( + self.take(2)?.try_into().map_err(|_| "truncated u16")?, + )) + } + + fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes( + self.take(4)?.try_into().map_err(|_| "truncated u32")?, + )) + } + + fn u64(&mut self) -> Result { + Ok(u64::from_le_bytes( + self.take(8)?.try_into().map_err(|_| "truncated u64")?, + )) + } + + fn f32(&mut self) -> Result { + Ok(f32::from_bits(self.u32()?)) + } + + fn rect(&mut self) -> Result { + Ok(Rect::from_xywh( + self.f32()?, + self.f32()?, + self.f32()?, + self.f32()?, + )) + } + + fn point(&mut self) -> Result { + Ok(Point::new(self.f32()?, self.f32()?)) + } + + fn color(&mut self) -> Result { + let color = self.u32()?; + Ok(Rgba::from_argb( + (color >> 24) as u8, + (color >> 16) as u8, + (color >> 8) as u8, + color as u8, + )) + } + + fn fit(&mut self) -> Result { + Ok(match self.u8()? { + 0 => ImageFit::Contain, + 1 => ImageFit::Cover, + 2 => ImageFit::Fill, + _ => return Err("invalid validated image fit"), + }) + } + + fn utf8(&mut self, len: usize) -> Result { + String::from_utf8(self.take(len)?.to_vec()).map_err(|_| "invalid validated UTF-8") + } + + fn text(&mut self) -> Result { + let len = self.u32()? as usize; + self.utf8(len) + } + + fn family(&mut self) -> Result { + let len = self.u16()? as usize; + self.utf8(len) + } + + fn layout(&mut self, runs: bool) -> Result { + let size = self.f32()?; + let weight = if runs { 400 } else { self.u16()? }; + let italic = self.u8()? != 0; + let align = self.u8()?; + let wrap = self.u8()? != 0; + let ellipsis = self.u8()? != 0; + let family = self.family()?; + Ok(TextLayout { + size, + weight, + italic, + align, + wrap, + ellipsis, + family, + }) + } + + fn finish(&self) -> Result<(), &'static str> { + (self.offset == self.bytes.len()) + .then_some(()) + .ok_or("validated payload has trailing bytes") + } +} + +pub fn prepare( + list: &ValidatedDrawList<'_>, + mut image: impl FnMut(u64) -> Option, +) -> Result { + let mut commands = Vec::with_capacity(list.commands.len()); + for command in &list.commands { + let mut reader = Reader::new(command.payload); + let operation = match command.opcode { + wire::PUSH_CLIP_RECT => DrawOp::PushClipRect(reader.rect()?), + wire::PUSH_CLIP_ROUND_RECT => DrawOp::PushClipRoundRect(reader.rect()?, reader.f32()?), + wire::POP_CLIP => DrawOp::PopClip, + wire::PUSH_TRANSFORM => { + let mut matrix = [0.0; 6]; + for value in &mut matrix { + *value = reader.f32()?; + } + DrawOp::PushTransform(matrix) + } + wire::POP_TRANSFORM => DrawOp::PopTransform, + wire::PUSH_ALPHA => DrawOp::PushAlpha(reader.u8()?), + wire::POP_ALPHA => DrawOp::PopAlpha, + wire::FILL_RECT => DrawOp::FillRect(reader.rect()?, reader.color()?), + wire::FILL_ROUND_RECT => { + DrawOp::FillRoundRect(reader.rect()?, reader.f32()?, reader.color()?) + } + wire::FILL_CIRCLE => { + DrawOp::FillCircle(reader.point()?, reader.f32()?, reader.color()?) + } + wire::FILL_CONVEX_POLYGON => { + let count = reader.u16()?; + let mut builder = PathBuilder::default(); + for index in 0..count { + let point = reader.point()?; + if index == 0 { + builder.move_to(point); + } else { + builder.line_to(point); + } + } + builder.close(); + DrawOp::FillPolygon(builder.detach(), reader.color()?) + } + wire::FILL_LINEAR_GRADIENT => { + let rect = reader.rect()?; + let angle = reader.f32()?.to_radians(); + let count = reader.u8()?; + let mut stops = Vec::with_capacity(count as usize); + for _ in 0..count { + stops.push(GradientStop { + offset: reader.f32()?, + color: reader.color()?, + }); + } + let direction = Vec2::new(angle.sin(), -angle.cos()); + let half = + (direction.x.abs() * rect.width() + direction.y.abs() * rect.height()) * 0.5; + let center = Point::new(rect.center_x(), rect.center_y()); + let from = Point::new(center.x - direction.x * half, center.y - direction.y * half); + let to = Point::new(center.x + direction.x * half, center.y + direction.y * half); + DrawOp::FillGradient(rect, from, to, stops) + } + wire::STROKE_LINE => { + let from = reader.point()?; + let to = reader.point()?; + DrawOp::StrokeLine(from, to, reader.f32()?, reader.color()?) + } + wire::STROKE_ROUND_RECT => DrawOp::StrokeRoundRect( + reader.rect()?, + reader.f32()?, + reader.f32()?, + reader.color()?, + ), + wire::STROKE_ARC => DrawOp::StrokeArc( + reader.rect()?, + reader.f32()?, + reader.f32()?, + reader.f32()?, + reader.color()?, + ), + wire::DRAW_IMAGE => { + let id = reader.u64()?; + let image = image(id).ok_or("validated image is no longer available")?; + let rect = reader.rect()?; + let fit = reader.fit()?; + DrawOp::Image(image, rect, fit, reader.u8()?) + } + wire::DRAW_IMAGE_PIXELS => { + let rect = reader.rect()?; + let fit = reader.fit()?; + let alpha = reader.u8()?; + let width = reader.u32()?; + let height = reader.u32()?; + let len = reader.u32()? as usize; + let rgba = reader.take(len)?; + let image = Image::from_rgba8(width as i32, height as i32, rgba) + .ok_or("inline image could not be decoded")?; + DrawOp::Image(image, rect, fit, alpha) + } + wire::DRAW_TEXT => { + let rect = reader.rect()?; + let text = reader.text()?; + let layout = reader.layout(false)?; + let color = reader.color()?; + FontManager::global() + .measure_plugin_text( + "", + layout.size, + layout.weight, + layout.italic, + &layout.family, + ) + .ok_or("text font is unavailable")?; + DrawOp::Text(text, rect, layout, color) + } + wire::DRAW_TEXT_RUNS => { + let rect = reader.rect()?; + let layout = reader.layout(true)?; + let count = reader.u8()?; + let mut runs = Vec::with_capacity(count as usize); + for _ in 0..count { + let text = reader.text()?; + let color = reader.color()?; + let weight = reader.u16()?; + FontManager::global() + .measure_plugin_text("", layout.size, weight, layout.italic, &layout.family) + .ok_or("text run font is unavailable")?; + runs.push(TextRun { + text, + color, + weight, + }); + } + DrawOp::TextRuns(runs, rect, layout) + } + wire::DRAW_SHADOW_ROUND_RECT => { + let rect = reader.rect()?; + let radius = reader.f32()?; + let sigma = reader.f32()?; + let offset = Vec2::new(reader.f32()?, reader.f32()?); + let color = reader.color()?; + DrawOp::Shadow( + Path::continuous_rounded_rect(rect.offset(offset), radius), + sigma, + color, + ) + } + _ => return Err("unknown validated opcode"), + }; + reader.finish()?; + commands.push(operation); + } + Ok(PreparedFrame { + logical_width: list.logical_width, + logical_height: list.logical_height, + commands, + }) +} diff --git a/crates/winisland-plugin-host/src/draw/mod.rs b/crates/winisland-plugin-host/src/draw/mod.rs new file mode 100644 index 00000000..3d074f42 --- /dev/null +++ b/crates/winisland-plugin-host/src/draw/mod.rs @@ -0,0 +1,3 @@ +mod decode; +pub mod replay; +pub mod validate; diff --git a/crates/winisland-plugin-host/src/draw/replay.rs b/crates/winisland-plugin-host/src/draw/replay.rs new file mode 100644 index 00000000..6dc644f1 --- /dev/null +++ b/crates/winisland-plugin-host/src/draw/replay.rs @@ -0,0 +1,186 @@ +use std::time::{Duration, Instant}; + +use winisland_render::text::{FontManager, PluginTextParams, PluginTextRun}; +use winisland_render::{ + Angle, BlurSpec, ImageOptions, Painter, Radius, Rect, Rgba, StrokeCap, TileMode, +}; + +use super::decode::DrawOp; +pub use super::decode::{PreparedFrame, prepare}; + +enum CanvasState { + ClipRect(Rect), + ClipRoundRect(Rect, f32), + Transform([f32; 6]), +} + +impl CanvasState { + fn apply(&self, painter: Painter<'_>) { + painter.save(); + match self { + Self::ClipRect(rect) => painter.clip_rect(*rect), + Self::ClipRoundRect(rect, radius) => { + painter.clip_round_rect(*rect, Radius::uniform(*radius)) + } + Self::Transform(matrix) => painter.concat_affine(*matrix), + } + } +} + +fn pop_canvas_state(states: &mut Vec, clip: bool, painter: Painter<'_>, base: usize) { + let index = states.iter().rposition(|state| { + if clip { + matches!( + state, + CanvasState::ClipRect(_) | CanvasState::ClipRoundRect(_, _) + ) + } else { + matches!(state, CanvasState::Transform(_)) + } + }); + let Some(index) = index else { return }; + let was_last = index + 1 == states.len(); + states.remove(index); + if was_last { + painter.restore(); + } else { + painter.restore_to(base); + for state in states.iter() { + state.apply(painter); + } + } +} + +fn color_with_alpha(color: Rgba, alpha: f32) -> Rgba { + color.with_alpha(((color.a() as f32) * alpha).round().clamp(0.0, 255.0) as u8) +} + +fn text_params<'a>( + painter: Painter<'a>, + rect: Rect, + layout: &'a super::decode::TextLayout, +) -> PluginTextParams<'a> { + PluginTextParams { + painter, + rect, + size: layout.size, + italic: layout.italic, + family: &layout.family, + align: layout.align, + wrap: layout.wrap, + ellipsis: layout.ellipsis, + } +} + +pub fn replay(frame: &PreparedFrame, painter: Painter<'_>, base_alpha: u8) -> Duration { + let started = Instant::now(); + let base = painter.save(); + let mut canvas_states = Vec::new(); + let mut alpha_stack = vec![base_alpha as f32 / 255.0]; + for command in &frame.commands { + let alpha = *alpha_stack.last().unwrap_or(&1.0); + match command { + DrawOp::PushClipRect(rect) => { + let state = CanvasState::ClipRect(*rect); + state.apply(painter); + canvas_states.push(state); + } + DrawOp::PushClipRoundRect(rect, radius) => { + let state = CanvasState::ClipRoundRect(*rect, *radius); + state.apply(painter); + canvas_states.push(state); + } + DrawOp::PopClip => pop_canvas_state(&mut canvas_states, true, painter, base), + DrawOp::PushTransform(matrix) => { + let state = CanvasState::Transform(*matrix); + state.apply(painter); + canvas_states.push(state); + } + DrawOp::PopTransform => pop_canvas_state(&mut canvas_states, false, painter, base), + DrawOp::PushAlpha(value) => alpha_stack.push(alpha * (*value as f32 / 255.0)), + DrawOp::PopAlpha => { + alpha_stack.pop(); + } + DrawOp::FillRect(rect, color) => { + painter.fill_rect(*rect, color_with_alpha(*color, alpha)) + } + DrawOp::FillRoundRect(rect, radius, color) => painter.fill_round_rect( + *rect, + Radius::uniform(*radius), + color_with_alpha(*color, alpha), + ), + DrawOp::FillCircle(center, radius, color) => { + painter.fill_circle(*center, *radius, color_with_alpha(*color, alpha)); + } + DrawOp::FillPolygon(path, color) => { + painter.fill_path(path, color_with_alpha(*color, alpha)) + } + DrawOp::FillGradient(rect, from, to, stops) => { + let stops = stops + .iter() + .map(|stop| winisland_render::GradientStop { + offset: stop.offset, + color: color_with_alpha(stop.color, alpha), + }) + .collect::>(); + painter.fill_rect_with_gradient(*rect, *from, *to, &stops, TileMode::Clamp); + } + DrawOp::StrokeLine(from, to, width, color) => painter.stroke_line( + *from, + *to, + *width, + color_with_alpha(*color, alpha), + StrokeCap::Butt, + ), + DrawOp::StrokeRoundRect(rect, radius, width, color) => painter.stroke_round_rect( + *rect, + Radius::uniform(*radius), + *width, + color_with_alpha(*color, alpha), + ), + DrawOp::StrokeArc(rect, start, sweep, width, color) => painter.stroke_arc( + *rect, + Angle::from_degrees(*start), + Angle::from_degrees(*sweep), + *width, + color_with_alpha(*color, alpha), + StrokeCap::Butt, + ), + DrawOp::Image(image, rect, fit, image_alpha) => { + let effective_alpha = + ((*image_alpha as f32) * alpha).round().clamp(0.0, 255.0) as u8; + let options = ImageOptions::default() + .with_fit(*fit) + .with_alpha(effective_alpha); + painter.draw_image(image, *rect, &options); + } + DrawOp::Text(value, rect, layout, color) => { + let _ = FontManager::global().draw_plugin_text( + text_params(painter, *rect, layout), + value, + layout.weight, + color_with_alpha(*color, alpha), + ); + } + DrawOp::TextRuns(runs, rect, layout) => { + let runs = runs + .iter() + .map(|run| PluginTextRun { + text: &run.text, + weight: run.weight, + color: color_with_alpha(run.color, alpha), + }) + .collect::>(); + let _ = FontManager::global() + .draw_plugin_runs(text_params(painter, *rect, layout), &runs); + } + DrawOp::Shadow(path, sigma, color) => painter.fill_path_blurred( + path, + color_with_alpha(*color, alpha), + BlurSpec::uniform(*sigma), + ), + } + } + painter.restore_to(base); + started.elapsed() +} diff --git a/crates/winisland-plugin-host/src/draw/validate.rs b/crates/winisland-plugin-host/src/draw/validate.rs new file mode 100644 index 00000000..10ee443e --- /dev/null +++ b/crates/winisland-plugin-host/src/draw/validate.rs @@ -0,0 +1,479 @@ +use winisland_plugin_api::draw::v2 as wire; + +#[derive(Clone, Copy, Debug)] +pub struct ValidationError { + pub offset: usize, + pub reason: &'static str, +} + +pub struct ValidatedCommand<'a> { + pub opcode: u16, + pub payload: &'a [u8], +} + +pub struct ValidatedDrawList<'a> { + pub logical_width: f32, + pub logical_height: f32, + pub commands: Vec>, +} + +struct Cursor<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> Cursor<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + fn take(&mut self, len: usize) -> Result<&'a [u8], &'static str> { + let end = self.offset.checked_add(len).ok_or("length overflow")?; + let bytes = self + .bytes + .get(self.offset..end) + .ok_or("truncated payload")?; + self.offset = end; + Ok(bytes) + } + + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes( + self.take(2)?.try_into().map_err(|_| "truncated u16")?, + )) + } + + fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes( + self.take(4)?.try_into().map_err(|_| "truncated u32")?, + )) + } + + fn u64(&mut self) -> Result { + Ok(u64::from_le_bytes( + self.take(8)?.try_into().map_err(|_| "truncated u64")?, + )) + } + + fn finite(&mut self) -> Result { + let value = f32::from_bits(self.u32()?); + if !value.is_finite() { + return Err("non-finite number"); + } + Ok(value) + } + + fn coordinate(&mut self) -> Result { + let value = self.finite()?; + if value.abs() > 1_000_000.0 { + return Err("coordinate exceeds limit"); + } + Ok(value) + } + + fn angle(&mut self) -> Result { + let value = self.finite()?; + if value.abs() > 1_000_000.0 { + return Err("angle exceeds limit"); + } + Ok(value) + } + + fn nonnegative(&mut self) -> Result { + let value = self.coordinate()?; + if value < 0.0 { + return Err("negative dimension or radius"); + } + Ok(value) + } + + fn stroke_width(&mut self) -> Result { + let width = self.finite()?; + if width <= 0.0 || width > 10_000.0 { + return Err("stroke width exceeds limit"); + } + Ok(width) + } + + fn rect(&mut self) -> Result<(), &'static str> { + self.coordinate()?; + self.coordinate()?; + self.nonnegative()?; + self.nonnegative()?; + Ok(()) + } + + fn text(&mut self, max: usize) -> Result { + let len = self.u32()? as usize; + if len > max { + return Err("text exceeds limit"); + } + std::str::from_utf8(self.take(len)?).map_err(|_| "invalid UTF-8")?; + Ok(len) + } + + fn family(&mut self) -> Result<(), &'static str> { + let len = self.u16()? as usize; + if len > wire::MAX_FAMILY_BYTES { + return Err("font family exceeds limit"); + } + std::str::from_utf8(self.take(len)?).map_err(|_| "invalid font family UTF-8")?; + Ok(()) + } + + fn text_flags(&mut self) -> Result<(), &'static str> { + if self.u8()? > 1 || self.u8()? > 2 || self.u8()? > 1 || self.u8()? > 1 { + return Err("invalid text style flags"); + } + Ok(()) + } + + fn weight(&mut self) -> Result<(), &'static str> { + if !(100..=900).contains(&self.u16()?) { + return Err("font weight exceeds limit"); + } + Ok(()) + } + + fn size(&mut self) -> Result<(), &'static str> { + let size = self.finite()?; + if size <= 0.0 || size > 10_000.0 { + return Err("text size exceeds limit"); + } + Ok(()) + } + + fn fit(&mut self) -> Result<(), &'static str> { + if self.u8()? > 2 { + return Err("invalid image fit"); + } + Ok(()) + } + + fn finish(self) -> Result<(), &'static str> { + if self.offset != self.bytes.len() { + return Err("payload has trailing bytes"); + } + Ok(()) + } +} + +fn convex(points: &[(f32, f32)]) -> bool { + let mut sign = 0_i8; + let mut fan_absolute = 0.0_f64; + let mut fan_signed = 0.0_f64; + for index in 0..points.len() { + let a = points[index]; + let b = points[(index + 1) % points.len()]; + let c = points[(index + 2) % points.len()]; + if a == b { + return false; + } + let cross = (b.0 as f64 - a.0 as f64) * (c.1 as f64 - b.1 as f64) + - (b.1 as f64 - a.1 as f64) * (c.0 as f64 - b.0 as f64); + if cross != 0.0 { + let next = if cross > 0.0 { 1 } else { -1 }; + if sign != 0 && sign != next { + return false; + } + sign = next; + } + } + for index in 1..points.len() - 1 { + let a = points[0]; + let b = points[index]; + let c = points[index + 1]; + let cross = (b.0 as f64 - a.0 as f64) * (c.1 as f64 - a.1 as f64) + - (b.1 as f64 - a.1 as f64) * (c.0 as f64 - a.0 as f64); + fan_absolute += cross.abs(); + fan_signed += cross; + } + sign != 0 && (fan_absolute - fan_signed.abs()) <= fan_absolute * 1e-9 +} + +fn concat_affine(current: [f64; 6], next: [f64; 6]) -> [f64; 6] { + let [a, b, c, d, e, f] = current; + let [g, h, i, j, k, l] = next; + [ + a * g + c * h, + b * g + d * h, + a * i + c * j, + b * i + d * j, + a * k + c * l + e, + b * k + d * l + f, + ] +} + +fn validate_payload( + opcode: u16, + payload: &[u8], + stacks: &mut [u8; 3], + transforms: &mut Vec<[f64; 6]>, + image_owned: &impl Fn(u64) -> bool, +) -> Result<(), &'static str> { + let mut cursor = Cursor::new(payload); + match opcode { + wire::PUSH_CLIP_RECT | wire::PUSH_CLIP_ROUND_RECT => { + cursor.rect()?; + if opcode == wire::PUSH_CLIP_ROUND_RECT { + cursor.nonnegative()?; + } + stacks[0] = stacks[0] + .checked_add(1) + .filter(|depth| *depth <= 64) + .ok_or("clip stack overflow")?; + } + wire::POP_CLIP => { + stacks[0] = stacks[0].checked_sub(1).ok_or("clip stack underflow")?; + } + wire::PUSH_TRANSFORM => { + let mut matrix = [0.0; 6]; + for component in &mut matrix { + *component = f64::from(cursor.coordinate()?); + } + let current = transforms + .last() + .copied() + .unwrap_or([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + let combined = concat_affine(current, matrix); + if combined + .iter() + .any(|value| !value.is_finite() || value.abs() > 1_000_000_000.0) + { + return Err("cumulative transform exceeds limit"); + } + transforms.push(combined); + stacks[1] = stacks[1] + .checked_add(1) + .filter(|depth| *depth <= 64) + .ok_or("transform stack overflow")?; + } + wire::POP_TRANSFORM => { + stacks[1] = stacks[1] + .checked_sub(1) + .ok_or("transform stack underflow")?; + transforms.pop(); + } + wire::PUSH_ALPHA => { + cursor.u8()?; + stacks[2] = stacks[2] + .checked_add(1) + .filter(|depth| *depth <= 64) + .ok_or("alpha stack overflow")?; + } + wire::POP_ALPHA => { + stacks[2] = stacks[2].checked_sub(1).ok_or("alpha stack underflow")?; + } + wire::FILL_RECT | wire::FILL_ROUND_RECT => { + cursor.rect()?; + if opcode == wire::FILL_ROUND_RECT { + cursor.nonnegative()?; + } + cursor.u32()?; + } + wire::FILL_CIRCLE => { + cursor.coordinate()?; + cursor.coordinate()?; + cursor.nonnegative()?; + cursor.u32()?; + } + wire::FILL_CONVEX_POLYGON => { + let count = cursor.u16()? as usize; + if !(3..=1024).contains(&count) { + return Err("polygon point count exceeds limit"); + } + let mut points = Vec::with_capacity(count); + for _ in 0..count { + points.push((cursor.coordinate()?, cursor.coordinate()?)); + } + if !convex(&points) { + return Err("polygon is not convex"); + } + cursor.u32()?; + } + wire::FILL_LINEAR_GRADIENT => { + cursor.rect()?; + cursor.angle()?; + let stops = cursor.u8()?; + if !(2..=16).contains(&stops) { + return Err("gradient stop count exceeds limit"); + } + let mut previous = 0.0; + for _ in 0..stops { + let position = cursor.finite()?; + if !(0.0..=1.0).contains(&position) || position < previous { + return Err("gradient stops are not ordered"); + } + previous = position; + cursor.u32()?; + } + } + wire::STROKE_LINE => { + for _ in 0..4 { + cursor.coordinate()?; + } + cursor.stroke_width()?; + cursor.u32()?; + } + wire::STROKE_ROUND_RECT => { + cursor.rect()?; + cursor.nonnegative()?; + cursor.stroke_width()?; + cursor.u32()?; + } + wire::STROKE_ARC => { + cursor.rect()?; + cursor.angle()?; + cursor.angle()?; + cursor.stroke_width()?; + cursor.u32()?; + } + wire::DRAW_IMAGE => { + let id = cursor.u64()?; + if id == 0 || !image_owned(id) { + return Err("image handle is stale or belongs to another plugin"); + } + cursor.rect()?; + cursor.fit()?; + cursor.u8()?; + } + wire::DRAW_IMAGE_PIXELS => { + cursor.rect()?; + cursor.fit()?; + cursor.u8()?; + let width = cursor.u32()?; + let height = cursor.u32()?; + let len = cursor.u32()? as usize; + if width == 0 + || height == 0 + || width > 4096 + || height > 4096 + || u64::from(width) * u64::from(height) * 4 != len as u64 + { + return Err("invalid inline image dimensions or length"); + } + cursor.take(len)?; + } + wire::DRAW_TEXT => { + cursor.rect()?; + cursor.text(wire::MAX_TEXT_BYTES)?; + cursor.size()?; + cursor.weight()?; + cursor.text_flags()?; + cursor.family()?; + cursor.u32()?; + } + wire::DRAW_TEXT_RUNS => { + cursor.rect()?; + cursor.size()?; + cursor.text_flags()?; + cursor.family()?; + let count = cursor.u8()?; + if !(1..=64).contains(&count) { + return Err("text run count exceeds limit"); + } + let mut total = 0_usize; + for _ in 0..count { + total = total + .checked_add(cursor.text(wire::MAX_TEXT_BYTES)?) + .ok_or("text length overflow")?; + if total > wire::MAX_TEXT_BYTES { + return Err("text runs exceed limit"); + } + cursor.u32()?; + cursor.weight()?; + } + } + wire::DRAW_SHADOW_ROUND_RECT => { + cursor.rect()?; + cursor.nonnegative()?; + let sigma = cursor.finite()?; + if !(0.0..=64.0).contains(&sigma) { + return Err("shadow sigma exceeds limit"); + } + cursor.coordinate()?; + cursor.coordinate()?; + cursor.u32()?; + } + _ => return Err("unknown opcode"), + } + cursor.finish() +} + +pub fn validate<'a>( + bytes: &'a [u8], + image_owned: impl Fn(u64) -> bool, +) -> Result, ValidationError> { + if bytes.len() > wire::MAX_LIST_BYTES { + return Err(ValidationError { + offset: 0, + reason: "draw list exceeds limit", + }); + } + let mut cursor = Cursor::new(bytes); + let header = (|| -> Result<(f32, f32, u32, u32), &'static str> { + if cursor.u32()? != wire::MAGIC { + return Err("invalid draw list magic"); + } + if cursor.u32()? != wire::VERSION { + return Err("unsupported draw list version"); + } + let width = cursor.nonnegative()?; + let height = cursor.nonnegative()?; + let count = cursor.u32()?; + let payload_len = cursor.u32()?; + if count > wire::MAX_COMMANDS { + return Err("too many draw commands"); + } + if payload_len as usize != bytes.len().saturating_sub(24) { + return Err("draw list length mismatch"); + } + Ok((width, height, count, payload_len)) + })() + .map_err(|reason| ValidationError { + offset: cursor.offset, + reason, + })?; + let mut commands = Vec::with_capacity(header.2 as usize); + let mut stacks = [0_u8; 3]; + let mut transforms = Vec::new(); + for _ in 0..header.2 { + let offset = cursor.offset; + let opcode = cursor + .u16() + .map_err(|reason| ValidationError { offset, reason })?; + let flags = cursor + .u16() + .map_err(|reason| ValidationError { offset, reason })?; + let len = cursor + .u32() + .map_err(|reason| ValidationError { offset, reason })? as usize; + if flags != 0 || len > wire::MAX_PAYLOAD_BYTES { + return Err(ValidationError { + offset, + reason: "invalid command flags or payload size", + }); + } + let payload = cursor + .take(len) + .map_err(|reason| ValidationError { offset, reason })?; + validate_payload(opcode, payload, &mut stacks, &mut transforms, &image_owned) + .map_err(|reason| ValidationError { offset, reason })?; + commands.push(ValidatedCommand { opcode, payload }); + } + if cursor.offset != bytes.len() || stacks != [0; 3] { + return Err(ValidationError { + offset: cursor.offset, + reason: "trailing bytes or unbalanced stack", + }); + } + Ok(ValidatedDrawList { + logical_width: header.0, + logical_height: header.1, + commands, + }) +} diff --git a/crates/winisland-plugin-host/src/fault.rs b/crates/winisland-plugin-host/src/fault.rs new file mode 100644 index 00000000..f0d02c4b --- /dev/null +++ b/crates/winisland-plugin-host/src/fault.rs @@ -0,0 +1,195 @@ +use std::cell::RefCell; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +const CRASH_PLUGIN_FILE: &str = ".crash_plugin"; +const DISABLED_PLUGINS_FILE: &str = ".disabled-plugins"; + +thread_local! { + static ACTIVE_PLUGIN: RefCell>> = const { RefCell::new(None) }; +} + +#[derive(Debug)] +pub struct PluginIdentity { + pub id: String, + pub version: String, + pub marker_dir: PathBuf, +} + +pub struct PluginCallGuard { + previous: Option>, + marker_path: PathBuf, +} + +fn marker_path(identity: &PluginIdentity) -> PathBuf { + identity + .marker_dir + .join(format!(".active-plugin-{:?}", std::thread::current().id())) +} + +fn write_marker(identity: &PluginIdentity) -> std::io::Result<()> { + std::fs::create_dir_all(&identity.marker_dir)?; + std::fs::write(marker_path(identity), identity.id.as_bytes()) +} + +impl PluginCallGuard { + pub fn enter(identity: &Arc) -> Self { + let active_path = marker_path(identity); + if let Err(error) = write_marker(identity) { + log::error!("Could not mark plugin callback {}: {error}", identity.id); + } + let previous = ACTIVE_PLUGIN.with(|active| active.replace(Some(Arc::clone(identity)))); + if let Some(previous) = &previous { + let previous_path = marker_path(previous); + if previous_path != active_path { + let _ = std::fs::remove_file(previous_path); + } + } + Self { + previous, + marker_path: active_path, + } + } +} + +impl Drop for PluginCallGuard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.marker_path); + if let Some(previous) = &self.previous + && let Err(error) = write_marker(previous) + { + log::error!("Could not restore plugin callback marker: {error}"); + } + ACTIVE_PLUGIN.with(|active| { + active.replace(self.previous.take()); + }); + } +} + +pub fn current_plugin() -> Option> { + ACTIVE_PLUGIN.with(|active| active.try_borrow().ok().and_then(|active| active.clone())) +} + +pub fn record_crash(config_dir: &Path) -> std::io::Result>> { + let identity = current_plugin(); + if let Some(identity) = &identity { + std::fs::create_dir_all(config_dir)?; + std::fs::write(config_dir.join(CRASH_PLUGIN_FILE), identity.id.as_bytes())?; + } + Ok(identity) +} + +fn valid_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 63 + && id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') +} + +fn disabled_path(plugin_dir: &Path) -> PathBuf { + plugin_dir.join(DISABLED_PLUGINS_FILE) +} + +pub fn disabled_plugin_ids(plugin_dir: &Path) -> HashSet { + std::fs::read_to_string(disabled_path(plugin_dir)) + .ok() + .map(|contents| { + contents + .lines() + .map(str::trim) + .filter(|id| valid_id(id)) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +pub fn set_plugin_disabled(plugin_dir: &Path, id: &str, disabled: bool) -> std::io::Result<()> { + if !valid_id(id) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid plugin id", + )); + } + let mut ids = disabled_plugin_ids(plugin_dir); + if disabled { + ids.insert(id.to_string()); + } else { + ids.remove(id); + } + let path = disabled_path(plugin_dir); + if ids.is_empty() { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } + } else { + std::fs::create_dir_all(plugin_dir)?; + let mut ids = ids.into_iter().collect::>(); + ids.sort_unstable(); + std::fs::write(path, ids.join("\n") + "\n") + } +} + +pub fn has_active_plugin_markers(plugin_dir: &Path) -> bool { + let Some(marker_dir) = plugin_dir.parent() else { + return false; + }; + std::fs::read_dir(marker_dir).is_ok_and(|entries| { + entries.flatten().any(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(".active-plugin-")) + }) + }) +} + +pub fn recover_crashed_plugin( + config_dir: &Path, + plugin_dir: &Path, +) -> std::io::Result> { + let path = config_dir.join(CRASH_PLUGIN_FILE); + let mut ids = HashSet::new(); + if let Ok(raw) = std::fs::read(&path) + && let Some(id) = std::str::from_utf8(&raw).ok().filter(|id| valid_id(id)) + { + ids.insert(id.to_string()); + } + let mut marker_paths = Vec::new(); + if let Some(marker_dir) = plugin_dir.parent() + && let Ok(entries) = std::fs::read_dir(marker_dir) + { + for entry in entries.flatten() { + let marker = entry.path(); + if !entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(".active-plugin-")) + { + continue; + } + if let Ok(raw) = std::fs::read(&marker) + && let Some(id) = std::str::from_utf8(&raw).ok().filter(|id| valid_id(id)) + { + ids.insert(id.to_string()); + } + marker_paths.push(marker); + } + } + let mut ids = ids.into_iter().collect::>(); + ids.sort_unstable(); + for id in &ids { + set_plugin_disabled(plugin_dir, id, true)?; + } + for marker in marker_paths { + std::fs::remove_file(marker)?; + } + if path.exists() { + std::fs::remove_file(path)?; + } + Ok(ids) +} diff --git a/crates/winisland-plugin-host/src/host.rs b/crates/winisland-plugin-host/src/host.rs new file mode 100644 index 00000000..0f6efecb --- /dev/null +++ b/crates/winisland-plugin-host/src/host.rs @@ -0,0 +1,476 @@ +use std::cell::{Cell, RefCell}; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::time::Duration; + +use winisland_core::widgets::PluginWidget; +use winisland_plugin_api::abi::{ABI_VERSION_2, PluginStatus}; +use winisland_plugin_api::types::v2::WidgetId; +use winisland_plugin_package::activate::read_manifest_file; +use winisland_plugin_package::manifest::PluginManifest; + +use crate::PluginHostError; +use crate::draw::replay::PreparedFrame; +use crate::fault::{disabled_plugin_ids, set_plugin_disabled}; +use crate::lifecycle::{LyricsBridge, PluginInstance}; +use crate::loader::PluginLibrary; +use crate::resources::ResourceKind; +use crate::runtime::{HostRuntime, WidgetFrameError}; + +mod context; +mod media; +mod settings; +mod state; +pub use media::MediaSnapshot; + +pub struct PluginHost { + entries: RefCell>, + runtime: Pin>, + plugin_dir: PathBuf, + lyrics_bridge: LyricsBridge, + retained_dll: Cell, +} + +impl PluginHost { + pub fn new(plugin_dir: PathBuf, host_build: u32) -> Result { + std::fs::create_dir_all(&plugin_dir) + .map_err(|error| PluginHostError::Io(format!("{}: {error}", plugin_dir.display())))?; + let plugin_dir = std::fs::canonicalize(&plugin_dir) + .map_err(|error| PluginHostError::Io(format!("{}: {error}", plugin_dir.display())))?; + Ok(Self { + entries: RefCell::new(Vec::new()), + runtime: HostRuntime::new(host_build, plugin_dir.clone()), + plugin_dir, + lyrics_bridge: LyricsBridge::default(), + retained_dll: Cell::new(false), + }) + } + + pub fn runtime(&self) -> &HostRuntime { + &self.runtime + } + + pub fn lyrics_bridge(&self) -> LyricsBridge { + self.lyrics_bridge.clone() + } + + pub fn len(&self) -> usize { + self.entries.borrow().len() + } + + pub fn is_empty(&self) -> bool { + self.entries.borrow().is_empty() + } + + pub fn widgets_snapshot(&self) -> Vec { + let entries = self.entries.borrow(); + let Ok(state) = self.runtime.state.lock() else { + return Vec::new(); + }; + let mut widgets = Vec::new(); + for entry in entries.iter() { + let token = entry.token(); + let plugin_id = &entry.library().metadata().id; + let Ok(ids) = self.runtime.resources.list(token, ResourceKind::Widget) else { + continue; + }; + for id in ids { + let Some(record) = state.widgets.get(&id) else { + continue; + }; + if record.disabled { + continue; + } + let key = fixed_text(&record.spec.key); + widgets.push(PluginWidget { + id, + plugin_id: plugin_id.clone(), + key: (!key.is_empty()).then_some(key), + span_cols: record.spec.span_cols, + span_rows: record.spec.span_rows, + title: fixed_text(&record.spec.title), + body: fixed_text(&record.spec.body), + }); + } + } + widgets.sort_by_key(|widget| widget.id); + widgets + } + + pub fn prepare_widget_frame( + &self, + widget_id: u64, + ) -> Result, WidgetFrameError> { + let token = self + .runtime + .resources + .owner(ResourceKind::Widget, widget_id) + .map_err(|_| WidgetFrameError { + reason: "widget handle is stale", + consecutive_failures: 0, + disabled: false, + })?; + let widget = unsafe { winisland_plugin_api::types::v2::WidgetId::from_raw(widget_id) }; + self.runtime.prepare_widget_frame(token, widget) + } + + pub fn set_widget_logical_size( + &self, + widget_id: u64, + width: f32, + height: f32, + ) -> Result<(), PluginStatus> { + self.runtime + .resources + .owner(ResourceKind::Widget, widget_id)?; + let widget = unsafe { WidgetId::from_raw(widget_id) }; + self.runtime.set_widget_logical_size(widget, width, height) + } + + pub fn load_all(&self) -> Vec { + let mut errors = Vec::new(); + let disabled = disabled_plugin_ids(&self.plugin_dir); + let Ok(entries) = std::fs::read_dir(&self.plugin_dir) else { + errors.push(format!("Cannot scan {}", self.plugin_dir.display())); + return errors; + }; + for entry in entries { + let Ok(entry) = entry else { + continue; + }; + let path = entry.path(); + if path.is_dir() { + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with('.')) + { + continue; + } + let manifest_path = path.join("plugin.yml"); + if !manifest_path.is_file() { + continue; + } + let manifest = match read_manifest_file(&manifest_path, ABI_VERSION_2) { + Ok(manifest) => manifest, + Err(error) => { + let reason = if error.contains("ABI version 1") { + "该插件使用已废弃的 ABI v1,请升级或联系作者;建议先禁用该插件。" + .to_string() + } else { + error + }; + errors.push(format!("{}: {reason}", path.display())); + continue; + } + }; + if disabled.contains(&manifest.id) { + continue; + } + let dll = path.join(&manifest.entry); + if let Err(error) = self.load_library(&dll, Some(&manifest)) { + errors.push(error.to_string()); + } + } else if path.extension().is_some_and(|extension| extension == "dll") + && let Err(error) = self.load_library(&path, None) + { + errors.push(error.to_string()); + } + } + errors + } + + pub fn load_library( + &self, + path: &Path, + manifest: Option<&PluginManifest>, + ) -> Result<(), PluginHostError> { + let library = PluginLibrary::open(path)?; + let metadata = library.metadata().clone(); + if let Some(manifest) = manifest { + for (field, packaged, declared) in [ + ("id", manifest.id.as_str(), metadata.id.as_str()), + ("name", manifest.name.as_str(), metadata.name.as_str()), + ( + "version", + manifest.version.as_str(), + metadata.version.as_str(), + ), + ("author", manifest.author.as_str(), metadata.author.as_str()), + ] { + if packaged != declared { + return Err(PluginHostError::Invalid(format!( + "{}: manifest {field} differs from DLL descriptor", + path.display() + ))); + } + } + } + if disabled_plugin_ids(&self.plugin_dir).contains(&metadata.id) { + return Err(PluginHostError::Invalid(format!( + "plugin '{}' is disabled", + metadata.id + ))); + } + if self + .entries + .borrow() + .iter() + .any(|entry| entry.library().metadata().id == metadata.id) + { + return Err(PluginHostError::Invalid(format!( + "plugin '{}' is already loaded", + metadata.id + ))); + } + let token = self.runtime.registry.register( + metadata.id.clone(), + metadata.version.clone(), + library.capabilities(), + )?; + let marker_dir = self + .plugin_dir + .parent() + .unwrap_or(&self.plugin_dir) + .to_path_buf(); + let instance = + unsafe { PluginInstance::create(library, token, self.runtime.host_api(), marker_dir) }; + let mut instance = match instance { + Ok(instance) => instance, + Err(error) => { + if matches!(error, PluginHostError::RetainedDll(_)) { + self.retained_dll.set(true); + } + let _ = self.runtime.revoke_plugin(token); + return Err(error); + } + }; + if let Ok(widgets) = self.runtime.widget_ids(token) { + let _ = instance.set_tick_widgets(widgets); + } + if let Err(error) = instance.start_tick_worker(Duration::from_millis(16), &self.runtime) { + if instance.shutdown().is_ok() { + let _ = self.runtime.revoke_plugin(token); + drop(instance); + } else { + self.entries.borrow_mut().push(instance); + } + return Err(error); + } + log::info!( + "Loaded ABI v2 plugin {} v{} (capabilities=0x{:x})", + metadata.id, + metadata.version, + instance.library().capabilities() + ); + self.lyrics_bridge.attach(&instance); + self.entries.borrow_mut().push(instance); + Ok(()) + } + + pub fn activate_staged_plugin( + &self, + manifest: &PluginManifest, + staging: &Path, + ) -> Result<(), PluginHostError> { + manifest + .validate(ABI_VERSION_2) + .map_err(PluginHostError::Invalid)?; + let staged_entry = staging.join(&manifest.entry); + let staged = PluginLibrary::open(&staged_entry)?; + let metadata = staged.metadata(); + if metadata.id != manifest.id + || metadata.name != manifest.name + || metadata.version != manifest.version + || metadata.author != manifest.author + { + return Err(PluginHostError::Invalid( + "staged plugin metadata differs from plugin.yml".into(), + )); + } + drop(staged); + let destination = self.plugin_dir.join(manifest.safe_dir_name()); + let previous = if destination.exists() { + read_manifest_file(&destination.join("plugin.yml"), ABI_VERSION_2).ok() + } else { + None + }; + let loaded_path = self + .entries + .borrow() + .iter() + .find(|entry| entry.library().metadata().id == manifest.id) + .map(|entry| entry.library().path().to_path_buf()); + let previous_entry = loaded_path + .clone() + .or_else(|| previous.as_ref().map(|old| destination.join(&old.entry))); + if loaded_path + .as_ref() + .is_some_and(|path| !path.starts_with(&destination)) + { + return Err(PluginHostError::Invalid( + "cannot replace a loose DLL with a packaged plugin".into(), + )); + } + let was_loaded = self.unload_if_loaded(&manifest.id)?; + let backup = self.plugin_dir.join(format!( + ".{}.backup-{}-{}", + manifest.safe_dir_name(), + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()) + )); + let had_previous = destination.exists(); + if had_previous && let Err(error) = std::fs::rename(&destination, &backup) { + if was_loaded && let Some(path) = &previous_entry { + let _ = self.load_library(path, previous.as_ref()); + } + return Err(PluginHostError::Io(format!( + "cannot back up plugin: {error}" + ))); + } + if let Err(error) = std::fs::rename(staging, &destination) { + if had_previous { + let _ = std::fs::rename(&backup, &destination); + } + if was_loaded && let Some(path) = &previous_entry { + let _ = self.load_library(path, previous.as_ref()); + } + return Err(PluginHostError::Io(format!( + "cannot activate plugin: {error}" + ))); + } + if let Err(error) = self.load_library(&destination.join(&manifest.entry), Some(manifest)) { + if self + .entries + .borrow() + .iter() + .any(|entry| entry.library().metadata().id == manifest.id) + { + return Err(PluginHostError::Execution(format!( + "new plugin could not be stopped after activation: {error}" + ))); + } + let rollback_new = std::fs::rename(&destination, staging); + let rollback_old = if had_previous { + std::fs::rename(&backup, &destination) + } else { + Ok(()) + }; + let reload = if was_loaded { + previous_entry + .as_ref() + .map_or(Ok(()), |path| self.load_library(path, previous.as_ref())) + } else { + Ok(()) + }; + return Err(PluginHostError::Execution(format!( + "plugin activation failed: {error}; rollback new={rollback_new:?}, old={rollback_old:?}, reload={reload:?}" + ))); + } + if had_previous && let Err(error) = std::fs::remove_dir_all(&backup) { + log::warn!( + "Plugin backup '{}' could not be removed: {error}", + backup.display() + ); + } + Ok(()) + } + + pub fn unload_if_loaded(&self, plugin_id: &str) -> Result { + let mut entries = self.entries.borrow_mut(); + let Some(index) = entries + .iter() + .position(|entry| entry.library().metadata().id == plugin_id) + else { + return Ok(false); + }; + let mut instance = entries.remove(index); + if let Err(error) = instance.shutdown() { + entries.insert(index, instance); + return Err(error); + } + let token = instance.token(); + self.lyrics_bridge.detach(plugin_id); + let revoke = self.runtime.revoke_plugin(token); + drop(instance); + revoke + .map_err(|status| PluginHostError::Execution(format!("resource revoke: {status:?}")))?; + Ok(true) + } + + pub fn refresh_tick_widgets(&self) -> Vec { + let entries = self.entries.borrow(); + let mut errors = Vec::new(); + for entry in entries.iter() { + if let Ok(widgets) = self.runtime.widget_ids(entry.token()) + && let Err(error) = entry.set_tick_widgets(widgets) + { + errors.push(format!("{}: {error}", entry.library().metadata().id)); + } + } + errors + } + + pub fn drain_failed_plugins(&self) -> Vec { + let failed = self + .entries + .borrow() + .iter() + .filter_map(|entry| { + entry + .tick_failure() + .map(|status| (entry.library().metadata().id.clone(), status)) + }) + .collect::>(); + let mut messages = Vec::new(); + for (id, status) in failed { + if let Err(error) = set_plugin_disabled(&self.plugin_dir, &id, true) { + messages.push(format!("{id}: could not persist disabled state: {error}")); + } + match self.unload_if_loaded(&id) { + Ok(_) => messages.push(format!( + "plugin {id} disabled after tick failure {status:?}" + )), + Err(error) => { + messages.push(format!("plugin {id} tick failed ({status:?}): {error}")) + } + } + } + messages + } + + pub fn shutdown_all(&self) -> Vec { + let ids = self + .entries + .borrow() + .iter() + .map(|entry| entry.library().metadata().id.clone()) + .collect::>(); + ids.into_iter() + .filter_map(|id| { + self.unload_if_loaded(&id) + .err() + .map(|error| error.to_string()) + }) + .collect() + } +} + +impl Drop for PluginHost { + fn drop(&mut self) { + for error in self.shutdown_all() { + log::error!("Plugin shutdown on host drop failed: {error}"); + } + if self.retained_dll.get() || !self.entries.get_mut().is_empty() { + let replacement = HostRuntime::new(0, self.plugin_dir.clone()); + let runtime = std::mem::replace(&mut self.runtime, replacement); + std::mem::forget(runtime); + } + } +} + +fn fixed_text(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes.split(|byte| *byte == 0).next().unwrap_or(bytes)).into_owned() +} diff --git a/crates/winisland-plugin-host/src/host/context.rs b/crates/winisland-plugin-host/src/host/context.rs new file mode 100644 index 00000000..3f96ef8f --- /dev/null +++ b/crates/winisland-plugin-host/src/host/context.rs @@ -0,0 +1,35 @@ +use std::time::Duration; + +use winisland_core::context::{PluginContext, Priority}; +use winisland_plugin_api::types::v2::context::CONTEXT_FLAG_SHOW_COMPACT; + +use super::{PluginHost, fixed_text}; + +impl PluginHost { + pub fn contexts_snapshot(&self) -> Option<(u64, Vec)> { + let state = self.runtime.state.lock().ok()?; + let contexts = state + .contexts + .iter() + .map(|(id, record)| { + let data = &record.data; + PluginContext { + id: *id, + priority: match data.priority { + 0 => Priority::Low, + 2 => Priority::High, + _ => Priority::Medium, + }, + title: fixed_text(&data.title), + body: fixed_text(&data.body), + compact_text: fixed_text(&data.compact_text), + show_compact: data.flags & CONTEXT_FLAG_SHOW_COMPACT != 0, + expires_at: (data.timeout_ms != 0) + .then(|| record.updated_at + Duration::from_millis(data.timeout_ms as u64)), + updated_at: record.updated_at, + } + }) + .collect(); + Some((state.context_revision, contexts)) + } +} diff --git a/crates/winisland-plugin-host/src/host/media.rs b/crates/winisland-plugin-host/src/host/media.rs new file mode 100644 index 00000000..c5c476f3 --- /dev/null +++ b/crates/winisland-plugin-host/src/host/media.rs @@ -0,0 +1,57 @@ +use super::PluginHost; + +#[derive(Clone)] +pub struct MediaSnapshot { + pub resource_id: u64, + pub title: String, + pub artist: String, + pub album: String, + pub is_playing: bool, + pub duration_ms: u64, + pub position_ms: u64, + pub available_controls: u32, + pub cover: Vec, +} + +impl PluginHost { + pub fn dispatch_media_command( + &self, + resource_id: u64, + command: u32, + position_ms: u64, + ) -> Result<(), crate::PluginHostError> { + use crate::resources::ResourceKind; + let token = self + .runtime + .resources + .owner(ResourceKind::Media, resource_id) + .map_err(|_| crate::PluginHostError::Invalid("media resource is stale".into()))?; + let entries = self.entries.borrow(); + let instance = entries + .iter() + .find(|entry| entry.token() == token) + .ok_or_else(|| crate::PluginHostError::Invalid("media owner is unavailable".into()))?; + instance.queue_media_command(resource_id, command, position_ms) + } + + pub fn media_snapshot(&self) -> Option<(u64, Option)> { + let state = self.runtime.state.lock().ok()?; + let source = state.media.iter().max_by_key(|(_, media)| media.sequence); + Some(( + state.media_revision, + source.map(|(id, media)| MediaSnapshot { + resource_id: *id, + title: media.title.clone(), + artist: media.artist.clone(), + album: media.album.clone(), + is_playing: media.flags + & winisland_plugin_api::types::v2::context::MEDIA_FLAG_PLAYING + != 0, + duration_ms: media.duration_ms, + position_ms: media.position_ms, + available_controls: media.available_controls, + cover: media.cover.clone(), + }), + )) + } +} diff --git a/crates/winisland-plugin-host/src/host/settings.rs b/crates/winisland-plugin-host/src/host/settings.rs new file mode 100644 index 00000000..58d7dbf6 --- /dev/null +++ b/crates/winisland-plugin-host/src/host/settings.rs @@ -0,0 +1,118 @@ +use winisland_core::plugin_settings::{ + PluginSettingsItem, PluginSettingsOption, PluginSettingsPage, +}; +use winisland_plugin_api::abi::PluginStatus; +use winisland_plugin_api::types::v2::settings::{ + SETTINGS_ITEM_BUTTON, SETTINGS_ITEM_FLAG_DISABLED, SETTINGS_ITEM_GROUP_END, + SETTINGS_ITEM_GROUP_START, SETTINGS_ITEM_LABEL, SETTINGS_ITEM_SECTION, SETTINGS_ITEM_SELECT, + SETTINGS_ITEM_STEPPER, SETTINGS_ITEM_SWITCH, +}; + +use super::PluginHost; +use crate::PluginHostError; +use crate::resources::ResourceKind; + +impl PluginHost { + pub fn settings_pages_snapshot(&self) -> Option<(u64, Vec)> { + let state = self.runtime.state.lock().ok()?; + let mut pages = state + .settings + .iter() + .map(|(id, page)| { + let items = page + .items + .iter() + .filter_map(|item| { + let raw = &item.raw; + let label = fixed(&raw.label); + let key = fixed(&raw.key); + let value = fixed(&raw.value); + let enabled = raw.flags & SETTINGS_ITEM_FLAG_DISABLED == 0; + match raw.kind { + SETTINGS_ITEM_SECTION => Some(PluginSettingsItem::Section(label)), + SETTINGS_ITEM_GROUP_START => Some(PluginSettingsItem::GroupStart), + SETTINGS_ITEM_GROUP_END => Some(PluginSettingsItem::GroupEnd), + SETTINGS_ITEM_LABEL => Some(PluginSettingsItem::Label(label)), + SETTINGS_ITEM_SWITCH => Some(PluginSettingsItem::Switch { + key, + label, + value: value == "true", + enabled, + }), + SETTINGS_ITEM_SELECT => Some(PluginSettingsItem::Select { + key, + label, + value, + options: item + .options + .iter() + .map(|option| PluginSettingsOption { + value: fixed(&option.value), + label: fixed(&option.label), + }) + .collect(), + enabled, + }), + SETTINGS_ITEM_STEPPER => Some(PluginSettingsItem::Stepper { + key, + label, + value: value.parse().unwrap_or(0.0), + minimum: raw.minimum, + maximum: raw.maximum, + step: raw.step, + enabled, + }), + SETTINGS_ITEM_BUTTON => Some(PluginSettingsItem::Button { + key, + label, + button_label: value, + enabled, + }), + _ => None, + } + }) + .collect(); + PluginSettingsPage { + resource_id: *id, + key: page.key.clone(), + title: page.title.clone(), + icon: page.icon.clone(), + items, + sequence: page.sequence, + } + }) + .collect::>(); + pages.sort_by_key(|page| page.sequence); + Some((state.settings_revision, pages)) + } + + pub fn dispatch_settings_change( + &self, + page_id: u64, + key: &str, + value: &str, + ) -> Result<(), PluginHostError> { + let token = self + .runtime + .resources + .owner(ResourceKind::Settings, page_id) + .map_err(|_| PluginHostError::Invalid("settings page is stale".into()))?; + let entries = self.entries.borrow(); + let instance = entries + .iter() + .find(|entry| entry.token() == token) + .ok_or_else(|| PluginHostError::Invalid("settings owner is unavailable".into()))?; + let status = instance.call_settings_change(page_id, key, value)?; + if status == PluginStatus::Ok { + Ok(()) + } else { + Err(PluginHostError::Execution(format!( + "settings callback returned {status:?}" + ))) + } + } +} + +fn fixed(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes.split(|byte| *byte == 0).next().unwrap_or(bytes)).into_owned() +} diff --git a/crates/winisland-plugin-host/src/host/state.rs b/crates/winisland-plugin-host/src/host/state.rs new file mode 100644 index 00000000..a4e1c55f --- /dev/null +++ b/crates/winisland-plugin-host/src/host/state.rs @@ -0,0 +1,20 @@ +use winisland_plugin_api::types::v2::context::HostStateV2; + +use super::PluginHost; +use crate::PluginHostError; + +impl PluginHost { + pub fn set_host_state(&self, snapshot: HostStateV2) -> Result<(), PluginHostError> { + let subscribers = self + .runtime + .set_host_state(snapshot) + .map_err(|status| PluginHostError::Execution(format!("host state: {status:?}")))?; + let entries = self.entries.borrow(); + for (token, id) in subscribers { + if let Some(entry) = entries.iter().find(|entry| entry.token() == token) { + entry.queue_host_state(id, snapshot)?; + } + } + Ok(()) + } +} diff --git a/crates/winisland-plugin-host/src/lib.rs b/crates/winisland-plugin-host/src/lib.rs new file mode 100644 index 00000000..37fbc127 --- /dev/null +++ b/crates/winisland-plugin-host/src/lib.rs @@ -0,0 +1,36 @@ +mod abi; + +pub mod draw; +pub mod fault; +pub mod host; +pub mod lifecycle; +pub mod loader; +pub mod registry; +pub mod resources; +pub mod runtime; +mod services; + +use std::fmt; + +#[derive(Debug)] +pub enum PluginHostError { + Io(String), + Invalid(String), + Execution(String), + RetainedDll(String), + Worker(String), +} + +impl fmt::Display for PluginHostError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(message) + | Self::Invalid(message) + | Self::Execution(message) + | Self::RetainedDll(message) + | Self::Worker(message) => formatter.write_str(message), + } + } +} + +impl std::error::Error for PluginHostError {} diff --git a/crates/winisland-plugin-host/src/lifecycle.rs b/crates/winisland-plugin-host/src/lifecycle.rs new file mode 100644 index 00000000..6d6633dd --- /dev/null +++ b/crates/winisland-plugin-host/src/lifecycle.rs @@ -0,0 +1,502 @@ +use std::mem::ManuallyDrop; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Sender}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use winisland_plugin_api::abi::{ABI_VERSION_2, PluginCreateInfoV2, PluginHostV2, PluginStatus}; +use winisland_plugin_api::types::v2::ResourceId; +use winisland_plugin_api::types::v2::context::{ + HostStateV2, MEDIA_COMMAND_NEXT, MEDIA_COMMAND_PREVIOUS, MEDIA_COMMAND_SEEK, + MEDIA_COMMAND_TOGGLE_PLAY, MEDIA_CONTROL_NEXT, MEDIA_CONTROL_PREVIOUS, MEDIA_CONTROL_SEEK, + MEDIA_CONTROL_TOGGLE_PLAY, MediaCommandV2, +}; +use winisland_plugin_api::types::v2::settings::SettingsChangeV2; +use winisland_plugin_api::types::v2::{PluginToken, WidgetId}; + +use crate::PluginHostError; +use crate::fault::{PluginCallGuard, PluginIdentity}; +use crate::loader::PluginLibrary; +use crate::runtime::HostRuntime; + +mod lyrics; +pub use lyrics::LyricsBridge; + +enum WorkerEvent { + LyricsTransform { + token: PluginToken, + time_ms: u64, + word_synced: bool, + text: String, + response: Sender>, + }, + MediaCommand { + id: u64, + command: u32, + position_ms: u64, + }, + HostState { + id: u64, + snapshot: Box, + }, + SettingsChange { + id: u64, + key: String, + value: String, + response: Sender, + }, +} + +pub struct PluginInstance { + library: ManuallyDrop, + handle: usize, + token: PluginToken, + identity: Arc, + stopped: bool, + stop_worker: Arc, + tick_widgets: Arc>>, + tick_failure: Arc>>, + worker: Option>, + event_tx: Option>, +} + +impl PluginInstance { + /// # Safety + /// `host_api` must remain allocated through the plugin's shutdown and destroy calls. + pub unsafe fn create( + library: PluginLibrary, + token: PluginToken, + host_api: *const PluginHostV2, + marker_dir: PathBuf, + ) -> Result { + if host_api.is_null() || token == PluginToken::INVALID { + return Err(PluginHostError::Invalid( + "invalid host table or plugin token".into(), + )); + } + let create = library.descriptor.create.ok_or_else(|| { + PluginHostError::Invalid("plugin descriptor has no create callback".into()) + })?; + let identity = Arc::new(PluginIdentity { + id: library.metadata().id.clone(), + version: library.metadata().version.clone(), + marker_dir, + }); + let info = PluginCreateInfoV2 { + struct_size: std::mem::size_of::() as u32, + abi_version: ABI_VERSION_2, + plugin_token: token, + host_api, + }; + let mut handle = std::ptr::null_mut(); + // SAFETY: The descriptor and host table satisfy the ABI v2 contract. + let result = { + let _guard = PluginCallGuard::enter(&identity); + unsafe { create(&info, &mut handle) } + }; + if result != PluginStatus::Ok || handle.is_null() { + if !handle.is_null() { + let can_unload = library.descriptor.shutdown.is_some_and(|shutdown| { + // SAFETY: A non-null partial handle was returned by the plugin. + let _guard = PluginCallGuard::enter(&identity); + unsafe { shutdown(handle) == PluginStatus::Ok } + }); + if can_unload { + if let Some(destroy) = library.descriptor.destroy { + // SAFETY: Successful shutdown permits destroying the partial instance. + let _guard = PluginCallGuard::enter(&identity); + unsafe { destroy(handle) }; + } + } else { + std::mem::forget(library); + return Err(PluginHostError::RetainedDll(format!( + "plugin create failed ({result:?}); shutdown failed, DLL kept loaded" + ))); + } + } + return Err(PluginHostError::Execution(format!( + "plugin create failed ({result:?}) or returned a null handle" + ))); + } + Ok(Self { + library: ManuallyDrop::new(library), + handle: handle as usize, + token, + identity, + stopped: false, + stop_worker: Arc::new(AtomicBool::new(false)), + tick_widgets: Arc::new(Mutex::new(Vec::new())), + tick_failure: Arc::new(Mutex::new(None)), + worker: None, + event_tx: None, + }) + } + + pub fn token(&self) -> PluginToken { + self.token + } + + pub fn library(&self) -> &PluginLibrary { + &self.library + } + + pub fn set_tick_widgets(&self, widgets: Vec) -> Result<(), PluginHostError> { + let mut current = self.tick_widgets.lock().map_err(|_| { + PluginHostError::Worker("plugin tick widget list lock is poisoned".into()) + })?; + *current = widgets; + Ok(()) + } + + pub fn tick_failure(&self) -> Option { + self.tick_failure.lock().ok().and_then(|failure| *failure) + } + + pub fn start_tick_worker( + &mut self, + period: Duration, + runtime: &HostRuntime, + ) -> Result<(), PluginHostError> { + if self.stopped || self.worker.is_some() || period.is_zero() { + return Err(PluginHostError::Worker("tick worker cannot start".into())); + } + let tick = self.library.descriptor.on_tick; + let (event_tx, event_rx) = mpsc::channel(); + let stop = Arc::clone(&self.stop_worker); + let widgets = Arc::clone(&self.tick_widgets); + let failure = Arc::clone(&self.tick_failure); + let handle = self.handle; + let identity = Arc::clone(&self.identity); + let runtime_address = runtime as *const HostRuntime as usize; + self.worker = Some( + thread::Builder::new() + .name(format!("plugin-tick-{}", self.library.metadata().id)) + .spawn(move || { + let mut previous = Instant::now(); + while !stop.load(Ordering::Acquire) { + while let Ok(event) = event_rx.try_recv() { + // SAFETY: The pinned runtime outlives this joined worker. + let runtime = unsafe { &*(runtime_address as *const HostRuntime) }; + dispatch_event(runtime, &identity, event); + } + let frame_start = Instant::now(); + let dt = frame_start.duration_since(previous).as_secs_f64(); + previous = frame_start; + if let Some(tick) = tick { + let snapshot = match widgets.lock() { + Ok(guard) => guard.clone(), + Err(_) => break, + }; + for widget in snapshot { + if stop.load(Ordering::Acquire) { + break; + } + // SAFETY: The instance remains alive until this worker is joined. + let status = { + let _guard = PluginCallGuard::enter(&identity); + unsafe { tick(handle as *mut _, widget, dt) } + }; + if status != PluginStatus::Ok { + if let Ok(mut failure) = failure.lock() { + *failure = Some(status); + } + stop.store(true, Ordering::Release); + break; + } + } + } + let remaining = period.saturating_sub(frame_start.elapsed()); + if !remaining.is_zero() { + match event_rx.recv_timeout(remaining) { + Ok(event) => { + // SAFETY: The pinned runtime outlives this joined worker. + let runtime = + unsafe { &*(runtime_address as *const HostRuntime) }; + dispatch_event(runtime, &identity, event); + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + } + }) + .map_err(|error| PluginHostError::Worker(error.to_string()))?, + ); + self.event_tx = Some(event_tx); + Ok(()) + } + + pub fn queue_media_command( + &self, + id: u64, + command: u32, + position_ms: u64, + ) -> Result<(), PluginHostError> { + if self.stopped || self.stop_worker.load(Ordering::Acquire) { + return Err(PluginHostError::Worker("plugin worker has stopped".into())); + } + self.event_tx + .as_ref() + .ok_or_else(|| PluginHostError::Worker("plugin worker is unavailable".into()))? + .send(WorkerEvent::MediaCommand { + id, + command, + position_ms, + }) + .map_err(|_| PluginHostError::Worker("plugin worker has exited".into())) + } + + pub fn queue_host_state(&self, id: u64, snapshot: HostStateV2) -> Result<(), PluginHostError> { + if self.stopped || self.stop_worker.load(Ordering::Acquire) { + return Err(PluginHostError::Worker("plugin worker has stopped".into())); + } + self.event_tx + .as_ref() + .ok_or_else(|| PluginHostError::Worker("plugin worker is unavailable".into()))? + .send(WorkerEvent::HostState { + id, + snapshot: Box::new(snapshot), + }) + .map_err(|_| PluginHostError::Worker("plugin worker has exited".into())) + } + + pub fn call_settings_change( + &self, + id: u64, + key: &str, + value: &str, + ) -> Result { + if self.stopped || self.stop_worker.load(Ordering::Acquire) { + return Err(PluginHostError::Worker("plugin worker has stopped".into())); + } + let (response, receive) = mpsc::channel(); + self.event_tx + .as_ref() + .ok_or_else(|| PluginHostError::Worker("plugin worker is unavailable".into()))? + .send(WorkerEvent::SettingsChange { + id, + key: key.to_string(), + value: value.to_string(), + response, + }) + .map_err(|_| PluginHostError::Worker("plugin worker has exited".into()))?; + receive + .recv_timeout(Duration::from_secs(2)) + .map_err(|error| { + PluginHostError::Worker(format!("settings callback timed out: {error}")) + }) + } + + pub fn shutdown(&mut self) -> Result<(), PluginHostError> { + if self.stopped { + return Ok(()); + } + self.stop_worker.store(true, Ordering::Release); + self.event_tx.take(); + if let Some(worker) = self.worker.take() { + worker.join().map_err(|_| { + PluginHostError::Worker("plugin tick worker panicked; DLL kept loaded".into()) + })?; + } + let callback = self.library.descriptor.shutdown.ok_or_else(|| { + PluginHostError::Invalid("plugin descriptor has no shutdown callback".into()) + })?; + // SAFETY: The tick worker has joined and the plugin still owns its handle. + let status = { + let _guard = PluginCallGuard::enter(&self.identity); + unsafe { callback(self.handle as *mut _) } + }; + if status != PluginStatus::Ok { + return Err(PluginHostError::Execution(format!( + "plugin shutdown failed ({status:?}); DLL kept loaded" + ))); + } + self.stopped = true; + Ok(()) + } +} + +fn dispatch_event(runtime: &HostRuntime, identity: &Arc, event: WorkerEvent) { + match event { + WorkerEvent::LyricsTransform { + token, + time_ms, + word_synced, + text, + response, + } => { + let _ = response.send(lyrics::dispatch_transform( + runtime, + identity, + token, + time_ms, + word_synced, + text, + )); + } + WorkerEvent::MediaCommand { + id, + command, + position_ms, + } => dispatch_media_command(runtime, identity, id, command, position_ms), + WorkerEvent::HostState { id, snapshot } => { + dispatch_host_state(runtime, identity, id, *snapshot) + } + WorkerEvent::SettingsChange { + id, + key, + value, + response, + } => { + let _ = response.send(dispatch_settings_change( + runtime, identity, id, &key, &value, + )); + } + } +} + +fn dispatch_settings_change( + runtime: &HostRuntime, + identity: &Arc, + id: u64, + key: &str, + value: &str, +) -> PluginStatus { + if key.is_empty() || key.len() >= 64 || value.len() >= 256 { + return PluginStatus::InvalidArgument; + } + let callback = { + let Ok(mut state) = runtime.state.lock() else { + return PluginStatus::Internal; + }; + let Some(page) = state.settings.get_mut(&id) else { + return PluginStatus::StaleHandle; + }; + if !page + .items + .iter() + .any(|item| item.raw.key.split(|byte| *byte == 0).next() == Some(key.as_bytes())) + { + return PluginStatus::InvalidArgument; + } + let Some(callback) = page.on_change else { + return PluginStatus::InvalidArgument; + }; + page.in_flight = page.in_flight.saturating_add(1); + (callback, page.callback_data) + }; + let mut change = SettingsChangeV2 { + struct_size: std::mem::size_of::() as u32, + key: [0; 64], + value: [0; 256], + }; + change.key[..key.len()].copy_from_slice(key.as_bytes()); + change.value[..value.len()].copy_from_slice(value.as_bytes()); + // SAFETY: The settings page remains registered and leased for the callback. + let status = { + let _guard = PluginCallGuard::enter(identity); + unsafe { (callback.0)(callback.1 as *mut _, ResourceId::from_raw(id), &change) } + }; + if let Ok(mut state) = runtime.state.lock() + && let Some(page) = state.settings.get_mut(&id) + { + page.in_flight = page.in_flight.saturating_sub(1); + } + status +} + +fn dispatch_host_state( + runtime: &HostRuntime, + identity: &Arc, + id: u64, + snapshot: HostStateV2, +) { + let callback = { + let Ok(mut state) = runtime.state.lock() else { + return; + }; + let Some(record) = state.subscriptions.get_mut(&id) else { + return; + }; + record.in_flight = record.in_flight.saturating_add(1); + (record.callback, record.callback_data) + }; + // SAFETY: The registered callback is leased until this worker decrements `in_flight`. + let status = { + let _guard = PluginCallGuard::enter(identity); + unsafe { (callback.0)(callback.1 as *mut _, &snapshot) } + }; + if status != PluginStatus::Ok { + log::warn!( + "Plugin '{}' host-state callback returned {status:?}", + identity.id + ); + } + if let Ok(mut state) = runtime.state.lock() + && let Some(record) = state.subscriptions.get_mut(&id) + { + record.in_flight = record.in_flight.saturating_sub(1); + } +} + +fn dispatch_media_command( + runtime: &HostRuntime, + identity: &Arc, + id: u64, + command: u32, + position_ms: u64, +) { + let required_control = match command { + MEDIA_COMMAND_TOGGLE_PLAY => MEDIA_CONTROL_TOGGLE_PLAY, + MEDIA_COMMAND_PREVIOUS => MEDIA_CONTROL_PREVIOUS, + MEDIA_COMMAND_NEXT => MEDIA_CONTROL_NEXT, + MEDIA_COMMAND_SEEK => MEDIA_CONTROL_SEEK, + _ => return, + }; + let callback = { + let Ok(mut state) = runtime.state.lock() else { + return; + }; + let Some(media) = state.media.get_mut(&id) else { + return; + }; + if media.available_controls & required_control == 0 { + return; + } + let Some(callback) = media.on_command else { + return; + }; + media.in_flight = media.in_flight.saturating_add(1); + (callback, media.callback_data) + }; + let event = MediaCommandV2 { + struct_size: std::mem::size_of::() as u32, + command, + position_ms, + }; + // SAFETY: The registered callback is leased until this worker releases `in_flight`. + let _guard = PluginCallGuard::enter(identity); + unsafe { (callback.0)(callback.1 as *mut _, ResourceId::from_raw(id), &event) }; + if let Ok(mut state) = runtime.state.lock() + && let Some(media) = state.media.get_mut(&id) + { + media.in_flight = media.in_flight.saturating_sub(1); + } +} + +impl Drop for PluginInstance { + fn drop(&mut self) { + if self.shutdown().is_err() { + return; + } + if let Some(destroy) = self.library.descriptor.destroy { + // SAFETY: Shutdown joined all host workers and the plugin reports its own threads joined. + let _guard = PluginCallGuard::enter(&self.identity); + unsafe { destroy(self.handle as *mut _) }; + } + // SAFETY: No plugin callbacks remain after shutdown and destroy. + unsafe { ManuallyDrop::drop(&mut self.library) }; + } +} diff --git a/crates/winisland-plugin-host/src/lifecycle/lyrics.rs b/crates/winisland-plugin-host/src/lifecycle/lyrics.rs new file mode 100644 index 00000000..f2ed05b8 --- /dev/null +++ b/crates/winisland-plugin-host/src/lifecycle/lyrics.rs @@ -0,0 +1,205 @@ +use std::collections::HashSet; +use std::sync::mpsc::{self, Sender}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use winisland_core::lyrics::LyricLine; +use winisland_plugin_api::abi::PluginStatus; +use winisland_plugin_api::types::v2::lyrics::{LYRICS_TEXT_FLAG_WORD_SYNCED, LyricsTextV2}; +use winisland_plugin_api::types::v2::{PluginToken, ResourceId, Utf8Slice}; + +use super::{PluginCallGuard, PluginIdentity, PluginInstance, WorkerEvent}; +use crate::resources::ResourceKind; +use crate::runtime::HostRuntime; + +#[derive(Clone, Default)] +pub struct LyricsBridge { + workers: Arc>>, +} + +#[derive(Clone)] +struct LyricsWorker { + id: String, + token: PluginToken, + sender: Sender, +} + +impl LyricsBridge { + pub(crate) fn attach(&self, instance: &PluginInstance) { + if let Some(sender) = &instance.event_tx + && let Ok(mut workers) = self.workers.lock() + { + workers.push(LyricsWorker { + id: instance.library().metadata().id.clone(), + token: instance.token(), + sender: sender.clone(), + }); + } + } + + pub(crate) fn detach(&self, plugin_id: &str) { + if let Ok(mut workers) = self.workers.lock() { + workers.retain(|worker| worker.id != plugin_id); + } + } + + pub fn apply(&self, mut lyrics: Arc>) -> Arc> { + let Ok(guard) = self.workers.lock() else { + return lyrics; + }; + let mut workers = guard + .iter() + .cloned() + .map(|worker| (worker, true)) + .collect::>(); + drop(guard); + if workers.is_empty() { + return lyrics; + } + let mut reported = HashSet::new(); + for line in Arc::make_mut(&mut lyrics) { + for (worker, active) in &mut workers { + if !*active { + continue; + } + let (response, receive) = mpsc::channel(); + let event = WorkerEvent::LyricsTransform { + token: worker.token, + time_ms: line.time_ms, + word_synced: line.is_word_synced(), + text: line.text.clone(), + response, + }; + let result = worker + .sender + .send(event) + .map_err(|_| PluginStatus::Internal) + .and_then(|_| { + receive + .recv_timeout(Duration::from_secs(2)) + .map_err(|_| PluginStatus::Internal)? + }); + match result { + Ok(text) => { + if !line.replace_text_preserving_timings(text) + && reported.insert(worker.id.clone()) + { + log::warn!( + "Lyrics transformer '{}' changed word timing boundaries", + worker.id + ); + } + } + Err(status) => { + *active = false; + if reported.insert(worker.id.clone()) { + log::warn!("Lyrics transformer '{}' failed: {status:?}", worker.id); + } + } + } + } + } + lyrics + } +} + +pub(super) fn dispatch_transform( + runtime: &HostRuntime, + identity: &Arc, + token: PluginToken, + time_ms: u64, + word_synced: bool, + mut text: String, +) -> Result { + let mut ids = runtime.resources.list(token, ResourceKind::Lyrics)?; + ids.sort_unstable(); + for id in ids { + let (callback, callback_data) = { + let mut state = runtime.state.lock().map_err(|_| PluginStatus::Internal)?; + let record = state.lyrics.get_mut(&id).ok_or(PluginStatus::StaleHandle)?; + record.in_flight = record.in_flight.saturating_add(1); + (record.on_transform, record.callback_data) + }; + let result = transform_one( + identity, + callback, + callback_data, + id, + time_ms, + word_synced, + &text, + ); + if let Ok(mut state) = runtime.state.lock() + && let Some(record) = state.lyrics.get_mut(&id) + { + record.in_flight = record.in_flight.saturating_sub(1); + } + text = result?; + } + Ok(text) +} + +fn transform_one( + identity: &Arc, + callback: winisland_plugin_api::types::v2::lyrics::LyricsTransformFnV2, + callback_data: usize, + id: u64, + time_ms: u64, + word_synced: bool, + text: &str, +) -> Result { + let input = LyricsTextV2 { + struct_size: std::mem::size_of::() as u32, + flags: if word_synced { + LYRICS_TEXT_FLAG_WORD_SYNCED + } else { + 0 + }, + line_time_ms: time_ms, + text: Utf8Slice::borrowed(text), + }; + let id = unsafe { ResourceId::from_raw(id) }; + let mut required = 0u32; + let _guard = PluginCallGuard::enter(identity); + // SAFETY: The callback is leased and the input remains live for the size query. + let status = unsafe { + callback( + callback_data as *mut _, + id, + &input, + std::ptr::null_mut(), + 0, + &mut required, + ) + }; + if status != PluginStatus::Ok { + return Err(status); + } + if required > 256 * 1024 { + return Err(PluginStatus::LimitExceeded); + } + if required == 0 { + return Ok(String::new()); + } + let mut output = vec![0u8; required as usize]; + let mut written = required; + // SAFETY: The callback is leased and the output buffer has the requested capacity. + let status = unsafe { + callback( + callback_data as *mut _, + id, + &input, + output.as_mut_ptr(), + required, + &mut written, + ) + }; + if status != PluginStatus::Ok { + return Err(status); + } + if written > required { + return Err(PluginStatus::InvalidArgument); + } + output.truncate(written as usize); + String::from_utf8(output).map_err(|_| PluginStatus::InvalidArgument) +} diff --git a/crates/winisland-plugin-host/src/loader.rs b/crates/winisland-plugin-host/src/loader.rs new file mode 100644 index 00000000..42f140df --- /dev/null +++ b/crates/winisland-plugin-host/src/loader.rs @@ -0,0 +1,148 @@ +use std::mem::ManuallyDrop; +use std::path::{Path, PathBuf}; + +use libloading::Library; +use libloading::os::windows::{ + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR, LOAD_LIBRARY_SEARCH_SYSTEM32, Library as WindowsLibrary, +}; +use winisland_plugin_api::abi::{ + ABI_VERSION_2, KNOWN_CAPABILITIES_V2, PLUGIN_ENTRY_SYMBOL_V2, PluginDescriptorV2, + PluginEntryFnV2, +}; +use winisland_plugin_api::types::metadata::PluginMetadataC; + +use crate::PluginHostError; + +pub struct PluginLibrary { + pub(crate) descriptor: PluginDescriptorV2, + pub(crate) library: ManuallyDrop, + path: PathBuf, + metadata: PluginMetadata, +} + +#[derive(Clone, Debug)] +pub struct PluginMetadata { + pub id: String, + pub name: String, + pub version: String, + pub author: String, + pub description: String, +} + +fn read_fixed(value: &[u8]) -> String { + String::from_utf8_lossy(value.split(|byte| *byte == 0).next().unwrap_or(value)).into_owned() +} + +impl From<&PluginMetadataC> for PluginMetadata { + fn from(value: &PluginMetadataC) -> Self { + Self { + id: read_fixed(&value.id), + name: read_fixed(&value.name), + version: read_fixed(&value.version), + author: read_fixed(&value.author), + description: read_fixed(&value.description), + } + } +} + +impl PluginLibrary { + pub fn open(path: &Path) -> Result { + let path = std::fs::canonicalize(path) + .map_err(|error| PluginHostError::Io(format!("{}: {error}", path.display())))?; + // SAFETY: The canonical path and restricted flags limit dependency resolution. + let library: Library = unsafe { + WindowsLibrary::load_with_flags( + &path, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32, + ) + } + .map(Into::into) + .map_err(|error| PluginHostError::Io(format!("{}: {error}", path.display())))?; + // SAFETY: A symbol lookup does not call plugin code. + let entry = unsafe { library.get::(PLUGIN_ENTRY_SYMBOL_V2) }; + let entry = entry.map_err(|error| { + PluginHostError::Invalid(format!( + "{}: No ABI v2 entry point: {error}", + path.display() + )) + })?; + // SAFETY: The exported entry point promises a stable descriptor allocation. + let descriptor_ptr = unsafe { entry() }; + if descriptor_ptr.is_null() { + return Err(PluginHostError::Invalid(format!( + "{} returned a null descriptor", + path.display() + ))); + } + // SAFETY: A valid ABI descriptor begins with a readable size field. + let struct_size = unsafe { std::ptr::read_unaligned(descriptor_ptr.cast::()) }; + if struct_size < std::mem::size_of::() as u32 { + return Err(PluginHostError::Invalid(format!( + "{} returned a truncated ABI v2 descriptor", + path.display() + ))); + } + // SAFETY: The size check covers the complete current descriptor layout. + let descriptor = unsafe { std::ptr::read_unaligned(descriptor_ptr) }; + if descriptor.abi_version != ABI_VERSION_2 { + return Err(PluginHostError::Invalid(format!( + "{} uses unsupported ABI version {}", + path.display(), + descriptor.abi_version + ))); + } + if descriptor.capabilities & !KNOWN_CAPABILITIES_V2 != 0 { + return Err(PluginHostError::Invalid(format!( + "{} requires unsupported capabilities 0x{:x}", + path.display(), + descriptor.capabilities & !KNOWN_CAPABILITIES_V2 + ))); + } + if descriptor.create.is_none() + || descriptor.shutdown.is_none() + || descriptor.destroy.is_none() + { + return Err(PluginHostError::Invalid(format!( + "{} is missing lifecycle callbacks", + path.display() + ))); + } + let metadata = PluginMetadata::from(&descriptor.metadata); + if metadata.id.is_empty() + || !metadata + .id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + { + return Err(PluginHostError::Invalid(format!( + "invalid plugin id '{}'", + metadata.id + ))); + } + Ok(Self { + descriptor, + library: ManuallyDrop::new(library), + path, + metadata, + }) + } + + pub fn metadata(&self) -> &PluginMetadata { + &self.metadata + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn capabilities(&self) -> u64 { + self.descriptor.capabilities + } +} + +impl Drop for PluginLibrary { + fn drop(&mut self) { + // SAFETY: PluginLibrary owns the DLL and has no active instance at this point. + unsafe { ManuallyDrop::drop(&mut self.library) }; + } +} diff --git a/crates/winisland-plugin-host/src/registry.rs b/crates/winisland-plugin-host/src/registry.rs new file mode 100644 index 00000000..ed50dc2e --- /dev/null +++ b/crates/winisland-plugin-host/src/registry.rs @@ -0,0 +1,120 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use winisland_plugin_api::abi::{KNOWN_CAPABILITIES_V2, PluginStatus}; +use winisland_plugin_api::types::v2::PluginToken; + +use crate::PluginHostError; + +pub struct Registry { + inner: Mutex, +} + +struct RegistryState { + next: u64, + plugins: HashMap, +} + +#[derive(Clone)] +pub struct Registration { + pub id: String, + pub version: String, + pub capabilities: u64, + pub stopping: bool, +} + +impl Default for Registry { + fn default() -> Self { + Self::new() + } +} + +impl Registry { + pub fn new() -> Self { + Self { + inner: Mutex::new(RegistryState { + next: 1_u64 << 32, + plugins: HashMap::new(), + }), + } + } + + pub fn register( + &self, + id: String, + version: String, + capabilities: u64, + ) -> Result { + if capabilities & !KNOWN_CAPABILITIES_V2 != 0 { + return Err(PluginHostError::Invalid( + "unsupported plugin capability".into(), + )); + } + let mut state = self + .inner + .lock() + .map_err(|_| PluginHostError::Execution("plugin registry lock is poisoned".into()))?; + if state.plugins.values().any(|plugin| plugin.id == id) { + return Err(PluginHostError::Invalid(format!( + "plugin '{id}' is already registered" + ))); + } + let raw = state.next; + state.next = state + .next + .checked_add(1) + .ok_or_else(|| PluginHostError::Execution("plugin token space exhausted".into()))?; + // SAFETY: Only this registry constructs nonzero tokens and never reuses them. + let token = unsafe { PluginToken::from_raw(raw) }; + state.plugins.insert( + token, + Registration { + id, + version, + capabilities, + stopping: false, + }, + ); + Ok(token) + } + + pub fn get(&self, token: PluginToken) -> Result { + self.inner + .lock() + .map_err(|_| PluginStatus::Internal)? + .plugins + .get(&token) + .cloned() + .ok_or(PluginStatus::StaleHandle) + } + + pub fn require(&self, token: PluginToken, capability: u64) -> Result<(), PluginStatus> { + let plugin = self.get(token)?; + if plugin.stopping { + return Err(PluginStatus::StaleHandle); + } + if plugin.capabilities & capability == 0 { + return Err(PluginStatus::CapabilityMissing); + } + Ok(()) + } + + pub fn begin_shutdown(&self, token: PluginToken) -> Result<(), PluginStatus> { + let mut state = self.inner.lock().map_err(|_| PluginStatus::Internal)?; + let plugin = state + .plugins + .get_mut(&token) + .ok_or(PluginStatus::StaleHandle)?; + plugin.stopping = true; + Ok(()) + } + + pub fn revoke(&self, token: PluginToken) -> Result { + self.inner + .lock() + .map_err(|_| PluginStatus::Internal)? + .plugins + .remove(&token) + .ok_or(PluginStatus::StaleHandle) + } +} diff --git a/crates/winisland-plugin-host/src/resources.rs b/crates/winisland-plugin-host/src/resources.rs new file mode 100644 index 00000000..ae159a30 --- /dev/null +++ b/crates/winisland-plugin-host/src/resources.rs @@ -0,0 +1,191 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use winisland_plugin_api::abi::PluginStatus; +use winisland_plugin_api::types::v2::PluginToken; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum ResourceKind { + Context, + Media, + I18n, + Widget, + Lyrics, + Settings, + Image, + HostStateSubscription, +} + +#[derive(Clone, Copy)] +struct Owner { + token: PluginToken, + kind: ResourceKind, + bytes: usize, +} + +struct ResourceState { + next: u64, + owners: HashMap, +} + +pub struct ResourceTable { + inner: Mutex, +} + +fn limits(kind: ResourceKind) -> (usize, usize) { + match kind { + ResourceKind::Context => (64, usize::MAX), + ResourceKind::Media => (4, 32 * 1024 * 1024), + ResourceKind::I18n => (16, 4 * 1024 * 1024), + ResourceKind::Widget => (8, 4 * 1024 * 1024), + ResourceKind::Lyrics => (4, usize::MAX), + ResourceKind::Settings => (1, 2 * 1024 * 1024), + ResourceKind::Image => (64, 64 * 1024 * 1024), + ResourceKind::HostStateSubscription => (16, usize::MAX), + } +} + +impl Default for ResourceTable { + fn default() -> Self { + Self::new() + } +} + +impl ResourceTable { + pub fn new() -> Self { + Self { + inner: Mutex::new(ResourceState { + next: 1_u64 << 32, + owners: HashMap::new(), + }), + } + } + + pub fn allocate( + &self, + token: PluginToken, + kind: ResourceKind, + bytes: usize, + ) -> Result { + if token == PluginToken::INVALID { + return Err(PluginStatus::StaleHandle); + } + let mut state = self.inner.lock().map_err(|_| PluginStatus::Internal)?; + let (max_count, max_bytes) = limits(kind); + let mut count = 0_usize; + let mut used_bytes = 0_usize; + for owner in state.owners.values() { + if owner.token == token && owner.kind == kind { + count += 1; + used_bytes = used_bytes + .checked_add(owner.bytes) + .ok_or(PluginStatus::LimitExceeded)?; + } + } + if count >= max_count || bytes > max_bytes.saturating_sub(used_bytes) { + return Err(PluginStatus::LimitExceeded); + } + let id = state.next; + state.next = state + .next + .checked_add(1) + .ok_or(PluginStatus::LimitExceeded)?; + state.owners.insert(id, Owner { token, kind, bytes }); + Ok(id) + } + + pub fn require( + &self, + token: PluginToken, + kind: ResourceKind, + id: u64, + ) -> Result<(), PluginStatus> { + let state = self.inner.lock().map_err(|_| PluginStatus::Internal)?; + match state.owners.get(&id) { + Some(owner) if owner.token == token && owner.kind == kind => Ok(()), + _ => Err(PluginStatus::StaleHandle), + } + } + + pub fn list(&self, token: PluginToken, kind: ResourceKind) -> Result, PluginStatus> { + let state = self.inner.lock().map_err(|_| PluginStatus::Internal)?; + Ok(state + .owners + .iter() + .filter_map(|(id, owner)| (owner.token == token && owner.kind == kind).then_some(*id)) + .collect()) + } + + pub fn owner(&self, kind: ResourceKind, id: u64) -> Result { + let state = self.inner.lock().map_err(|_| PluginStatus::Internal)?; + match state.owners.get(&id) { + Some(owner) if owner.kind == kind => Ok(owner.token), + _ => Err(PluginStatus::StaleHandle), + } + } + + pub fn resize( + &self, + token: PluginToken, + kind: ResourceKind, + id: u64, + bytes: usize, + ) -> Result<(), PluginStatus> { + let mut state = self.inner.lock().map_err(|_| PluginStatus::Internal)?; + let Some(owner) = state.owners.get(&id).copied() else { + return Err(PluginStatus::StaleHandle); + }; + if owner.token != token || owner.kind != kind { + return Err(PluginStatus::StaleHandle); + } + let (_, max_bytes) = limits(kind); + let used_other = state + .owners + .iter() + .filter(|(resource_id, entry)| { + **resource_id != id && entry.token == token && entry.kind == kind + }) + .try_fold(0_usize, |sum, (_, entry)| sum.checked_add(entry.bytes)) + .ok_or(PluginStatus::LimitExceeded)?; + if bytes > max_bytes.saturating_sub(used_other) { + return Err(PluginStatus::LimitExceeded); + } + if let Some(owner) = state.owners.get_mut(&id) { + owner.bytes = bytes; + } + Ok(()) + } + + pub fn release( + &self, + token: PluginToken, + kind: ResourceKind, + id: u64, + ) -> Result<(), PluginStatus> { + let mut state = self.inner.lock().map_err(|_| PluginStatus::Internal)?; + match state.owners.get(&id) { + Some(owner) if owner.token == token && owner.kind == kind => { + state.owners.remove(&id); + Ok(()) + } + _ => Err(PluginStatus::StaleHandle), + } + } + + pub fn revoke_plugin( + &self, + token: PluginToken, + ) -> Result, PluginStatus> { + let mut state = self.inner.lock().map_err(|_| PluginStatus::Internal)?; + let mut revoked = Vec::new(); + state.owners.retain(|id, owner| { + if owner.token == token { + revoked.push((*id, owner.kind)); + false + } else { + true + } + }); + Ok(revoked) + } +} diff --git a/crates/winisland-plugin-host/src/runtime.rs b/crates/winisland-plugin-host/src/runtime.rs new file mode 100644 index 00000000..253e811e --- /dev/null +++ b/crates/winisland-plugin-host/src/runtime.rs @@ -0,0 +1,387 @@ +use std::collections::HashMap; +use std::ffi::c_void; +use std::marker::PhantomPinned; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use winisland_plugin_api::abi::{PluginHostV2, PluginStatus}; +use winisland_plugin_api::types::v2::context::{ContextDataV2, HostStateV2, MediaCommandFnV2}; +use winisland_plugin_api::types::v2::lyrics::LyricsTransformFnV2; +use winisland_plugin_api::types::v2::settings::{ + SettingsChangedFnV2, SettingsItemV2, SettingsOptionV2, +}; +use winisland_plugin_api::types::v2::widget::WidgetSpecV2; +use winisland_plugin_api::types::v2::{HostStateChangedFnV2, PluginToken, WidgetId}; +use winisland_render::Image; + +use crate::abi::AbiTables; +use crate::draw::replay::{PreparedFrame, prepare}; +use crate::draw::validate::validate; +use crate::registry::Registry; +use crate::resources::ResourceTable; + +pub struct HostRuntime { + pub registry: Registry, + pub resources: ResourceTable, + pub(crate) tables: AbiTables, + pub(crate) state: Mutex, + pub(crate) store_root: PathBuf, + pub(crate) store_lock: Mutex<()>, + _pin: PhantomPinned, +} + +pub(crate) struct ServiceState { + pub contexts: HashMap, + pub context_revision: u64, + pub media: HashMap, + pub media_revision: u64, + pub translations: HashMap, + pub lyrics: HashMap, + pub settings: HashMap, + pub settings_revision: u64, + pub widgets: HashMap, + pub images: HashMap, + pub album_art: Option, + pub host_state: HostStateV2, + pub subscriptions: HashMap, + pub disabled_widgets: Vec, +} + +pub(crate) struct ContextRecord { + pub data: ContextDataV2, + pub updated_at: Instant, +} + +pub(crate) struct WidgetRecord { + pub spec: WidgetSpecV2, + pub draw_list: Arc<[u8]>, + pub logical_width: f32, + pub logical_height: f32, + pub redraw: bool, + pub failures: u32, + pub disabled: bool, +} + +#[derive(Debug)] +pub struct WidgetFrameError { + pub reason: &'static str, + pub consecutive_failures: u32, + pub disabled: bool, +} + +pub struct MediaRecord { + pub sequence: u64, + pub in_flight: u32, + pub title: String, + pub artist: String, + pub album: String, + pub flags: u32, + pub duration_ms: u64, + pub position_ms: u64, + pub available_controls: u32, + pub cover: Vec, + pub on_command: Option, + pub callback_data: usize, +} + +pub(crate) struct TranslationBundle { + pub locale: String, + pub entries: HashMap, +} + +pub struct LyricsRecord { + pub on_transform: LyricsTransformFnV2, + pub callback_data: usize, + pub in_flight: u32, +} + +pub struct SettingsItemRecord { + pub raw: SettingsItemV2, + pub options: Vec, +} + +pub struct SettingsRecord { + pub sequence: u64, + pub key: String, + pub title: String, + pub icon: Vec, + pub items: Vec, + pub on_change: Option, + pub callback_data: usize, + pub in_flight: u32, +} + +pub struct SubscriptionRecord { + pub token: PluginToken, + pub callback: HostStateChangedFnV2, + pub callback_data: usize, + pub in_flight: u32, +} + +// SAFETY: Service tables are immutable after construction; all mutable state uses synchronized +// registries and mutexes, and the context pointer remains stable inside Box. +unsafe impl Send for HostRuntime {} +// SAFETY: Concurrent FFI calls access mutable state only through synchronization. +unsafe impl Sync for HostRuntime {} + +impl HostRuntime { + pub fn new(host_build: u32, store_root: PathBuf) -> Pin> { + let mut runtime = Box::pin(Self { + registry: Registry::new(), + resources: ResourceTable::new(), + tables: AbiTables::new(host_build), + state: Mutex::new(ServiceState { + contexts: HashMap::new(), + context_revision: 0, + media: HashMap::new(), + media_revision: 0, + translations: HashMap::new(), + lyrics: HashMap::new(), + settings: HashMap::new(), + settings_revision: 0, + widgets: HashMap::new(), + images: HashMap::new(), + album_art: None, + host_state: HostStateV2::default(), + subscriptions: HashMap::new(), + disabled_widgets: Vec::new(), + }), + store_root, + store_lock: Mutex::new(()), + _pin: PhantomPinned, + }); + // SAFETY: The Box allocation is pinned before binding its address into the tables. + let runtime_mut = unsafe { Pin::as_mut(&mut runtime).get_unchecked_mut() }; + let context = (runtime_mut as *mut Self).cast::(); + runtime_mut.tables.bind(context); + runtime + } + + pub fn host_api(&self) -> *const PluginHostV2 { + &self.tables.host + } + + pub fn set_host_state( + &self, + snapshot: HostStateV2, + ) -> Result, PluginStatus> { + let mut state = self.state.lock().map_err(|_| PluginStatus::Internal)?; + let old = &state.host_state; + if old.flags == snapshot.flags + && old.is_playing == snapshot.is_playing + && old.media_title == snapshot.media_title + && old.media_artist == snapshot.media_artist + && old.theme == snapshot.theme + { + return Ok(Vec::new()); + } + state.host_state = snapshot; + Ok(state + .subscriptions + .iter() + .map(|(id, record)| (record.token, *id)) + .collect()) + } + + pub fn set_album_art(&self, image: Option) -> Result<(), PluginStatus> { + self.state + .lock() + .map_err(|_| PluginStatus::Internal)? + .album_art = image; + Ok(()) + } + + pub fn image(&self, token: PluginToken, id: u64) -> Result { + use crate::resources::ResourceKind; + self.resources.require(token, ResourceKind::Image, id)?; + self.state + .lock() + .map_err(|_| PluginStatus::Internal)? + .images + .get(&id) + .cloned() + .ok_or(PluginStatus::StaleHandle) + } + + pub fn widget_draw_list( + &self, + token: PluginToken, + widget: WidgetId, + ) -> Result, PluginStatus> { + use crate::resources::ResourceKind; + self.resources + .require(token, ResourceKind::Widget, widget.get())?; + let state = self.state.lock().map_err(|_| PluginStatus::Internal)?; + let record = state + .widgets + .get(&widget.get()) + .ok_or(PluginStatus::StaleHandle)?; + Ok(Arc::clone(&record.draw_list)) + } + + pub fn widget_ids(&self, token: PluginToken) -> Result, PluginStatus> { + use crate::resources::ResourceKind; + self.registry + .require(token, winisland_plugin_api::abi::CAP_WIDGET)?; + self.resources.list(token, ResourceKind::Widget).map(|ids| { + ids.into_iter() + .map(|id| unsafe { WidgetId::from_raw(id) }) + .collect() + }) + } + + pub fn prepare_widget_frame( + &self, + token: PluginToken, + widget: WidgetId, + ) -> Result, WidgetFrameError> { + use crate::resources::ResourceKind; + let bytes = self + .widget_draw_list(token, widget) + .map_err(|_| WidgetFrameError { + reason: "widget handle is stale", + consecutive_failures: 0, + disabled: false, + })?; + if bytes.is_empty() { + return Ok(None); + } + let result = validate(&bytes, |id| { + self.resources + .require(token, ResourceKind::Image, id) + .is_ok() + }) + .map_err(|error| error.reason) + .and_then(|validated| prepare(&validated, |id| self.image(token, id).ok())); + let mut state = self.state.lock().map_err(|_| WidgetFrameError { + reason: "plugin state lock is poisoned", + consecutive_failures: 0, + disabled: false, + })?; + let record = state + .widgets + .get_mut(&widget.get()) + .ok_or(WidgetFrameError { + reason: "widget was released during frame preparation", + consecutive_failures: 0, + disabled: false, + })?; + if record.disabled { + return Err(WidgetFrameError { + reason: "widget was disabled after repeated failures", + consecutive_failures: record.failures, + disabled: true, + }); + } + if !Arc::ptr_eq(&record.draw_list, &bytes) { + return match result { + Ok(frame) => Ok(Some(frame)), + Err(_) => Ok(None), + }; + } + match result { + Ok(frame) => { + record.failures = 0; + Ok(Some(frame)) + } + Err(reason) => { + record.failures = record.failures.saturating_add(1); + let failures = record.failures; + let disabled = failures >= 30; + if disabled { + record.disabled = true; + state.disabled_widgets.push(widget); + } + Err(WidgetFrameError { + reason, + consecutive_failures: failures, + disabled, + }) + } + } + } + + pub fn drain_disabled_widgets(&self) -> Result, PluginStatus> { + let mut state = self.state.lock().map_err(|_| PluginStatus::Internal)?; + Ok(std::mem::take(&mut state.disabled_widgets)) + } + + pub fn set_widget_logical_size( + &self, + widget: WidgetId, + width: f32, + height: f32, + ) -> Result<(), PluginStatus> { + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return Err(PluginStatus::InvalidArgument); + } + let mut state = self.state.lock().map_err(|_| PluginStatus::Internal)?; + let record = state + .widgets + .get_mut(&widget.get()) + .ok_or(PluginStatus::StaleHandle)?; + record.logical_width = width; + record.logical_height = height; + Ok(()) + } + + pub fn translation(&self, locale: &str, key: &str) -> Result, PluginStatus> { + let state = self.state.lock().map_err(|_| PluginStatus::Internal)?; + Ok(state + .translations + .values() + .filter(|bundle| bundle.locale == locale) + .find_map(|bundle| bundle.entries.get(key).cloned())) + } + + pub fn revoke_plugin(&self, token: PluginToken) -> Result { + use crate::resources::ResourceKind; + self.registry.begin_shutdown(token)?; + let mut state = self.state.lock().map_err(|_| PluginStatus::Internal)?; + let revoked = self.resources.revoke_plugin(token)?; + let mut translations = Vec::new(); + for (id, kind) in &revoked { + match kind { + ResourceKind::Context => { + if state.contexts.remove(id).is_some() { + state.context_revision = state.context_revision.wrapping_add(1); + } + } + ResourceKind::Media => { + if state.media.remove(id).is_some() { + state.media_revision = state.media_revision.wrapping_add(1); + } + } + ResourceKind::I18n => { + state.translations.remove(id); + translations.push(*id); + } + ResourceKind::Widget => { + state.widgets.remove(id); + } + ResourceKind::Lyrics => { + state.lyrics.remove(id); + } + ResourceKind::Settings => { + if state.settings.remove(id).is_some() { + state.settings_revision = state.settings_revision.wrapping_add(1); + } + } + ResourceKind::HostStateSubscription => { + state.subscriptions.remove(id); + } + ResourceKind::Image => { + state.images.remove(id); + } + } + } + self.registry.revoke(token)?; + drop(state); + for id in translations { + let _ = winisland_core::i18n::release_plugin_translation_bundle(id); + } + Ok(revoked.len()) + } +} diff --git a/crates/winisland-plugin-host/src/services/context.rs b/crates/winisland-plugin-host/src/services/context.rs new file mode 100644 index 00000000..09c53e1a --- /dev/null +++ b/crates/winisland-plugin-host/src/services/context.rs @@ -0,0 +1,123 @@ +use std::ffi::c_void; +use std::time::Instant; + +use winisland_plugin_api::abi::{CAP_CONTEXT, PluginStatus}; +use winisland_plugin_api::types::v2::context::{CONTEXT_FLAG_SHOW_COMPACT, ContextDataV2}; +use winisland_plugin_api::types::v2::{PluginToken, ResourceId}; + +use super::{read_struct, runtime}; +use crate::resources::ResourceKind; +use crate::runtime::ContextRecord; + +fn valid(data: &ContextDataV2) -> bool { + data.priority <= 2 + && data.flags & !CONTEXT_FLAG_SHOW_COMPACT == 0 + && data.title.first().is_some_and(|byte| *byte != 0) +} + +pub unsafe extern "C" fn create( + context: *mut c_void, + token: PluginToken, + data: *const ContextDataV2, + out: *mut ResourceId, +) -> PluginStatus { + if out.is_null() { + return PluginStatus::InvalidArgument; + } + // SAFETY: ABI callers provide a live instance context and a readable sized input. + let (host, data) = match unsafe { + runtime(context).and_then(|host| read_struct(data).map(|data| (host, data))) + } { + Ok(value) => value, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_CONTEXT) { + return status; + } + if !valid(&data) { + return PluginStatus::InvalidArgument; + } + let id = match host.resources.allocate(token, ResourceKind::Context, 0) { + Ok(id) => id, + Err(status) => return status, + }; + let Ok(mut state) = host.state.lock() else { + let _ = host.resources.release(token, ResourceKind::Context, id); + return PluginStatus::Internal; + }; + state.contexts.insert( + id, + ContextRecord { + data, + updated_at: Instant::now(), + }, + ); + state.context_revision = state.context_revision.wrapping_add(1); + // SAFETY: The caller supplied a writable ResourceId output pointer. + unsafe { *out = ResourceId::from_raw(id) }; + PluginStatus::Ok +} + +pub unsafe extern "C" fn update( + context: *mut c_void, + token: PluginToken, + id: ResourceId, + data: *const ContextDataV2, +) -> PluginStatus { + // SAFETY: ABI callers provide a live instance context and a readable sized input. + let (host, data) = match unsafe { + runtime(context).and_then(|host| read_struct(data).map(|data| (host, data))) + } { + Ok(value) => value, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_CONTEXT) { + return status; + } + if let Err(status) = host + .resources + .require(token, ResourceKind::Context, id.get()) + { + return status; + } + if !valid(&data) { + return PluginStatus::InvalidArgument; + } + let Ok(mut state) = host.state.lock() else { + return PluginStatus::Internal; + }; + let Some(record) = state.contexts.get_mut(&id.get()) else { + return PluginStatus::StaleHandle; + }; + record.data = data; + record.updated_at = Instant::now(); + state.context_revision = state.context_revision.wrapping_add(1); + PluginStatus::Ok +} + +pub unsafe extern "C" fn release( + context: *mut c_void, + token: PluginToken, + id: ResourceId, +) -> PluginStatus { + // SAFETY: ABI callers provide a live instance context. + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_CONTEXT) { + return status; + } + let Ok(mut state) = host.state.lock() else { + return PluginStatus::Internal; + }; + if let Err(status) = host + .resources + .release(token, ResourceKind::Context, id.get()) + { + return status; + } + state.contexts.remove(&id.get()); + state.context_revision = state.context_revision.wrapping_add(1); + PluginStatus::Ok +} diff --git a/crates/winisland-plugin-host/src/services/host_state.rs b/crates/winisland-plugin-host/src/services/host_state.rs new file mode 100644 index 00000000..af86c989 --- /dev/null +++ b/crates/winisland-plugin-host/src/services/host_state.rs @@ -0,0 +1,117 @@ +use std::ffi::c_void; + +use winisland_plugin_api::abi::{CAP_HOST_STATE, PluginStatus}; +use winisland_plugin_api::types::v2::context::HostStateV2; +use winisland_plugin_api::types::v2::{HostStateChangedFnV2, PluginToken, ResourceId}; + +use super::runtime; +use crate::resources::ResourceKind; +use crate::runtime::SubscriptionRecord; + +pub unsafe extern "C" fn get( + context: *mut c_void, + token: PluginToken, + out: *mut HostStateV2, +) -> PluginStatus { + if out.is_null() { + return PluginStatus::InvalidArgument; + } + // SAFETY: ABI callers provide a live instance context. + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_HOST_STATE) { + return status; + } + let Ok(state) = host.state.lock() else { + return PluginStatus::Internal; + }; + // SAFETY: The caller supplied a writable HostStateV2 output pointer. + unsafe { *out = state.host_state }; + PluginStatus::Ok +} + +pub unsafe extern "C" fn subscribe( + context: *mut c_void, + token: PluginToken, + callback: HostStateChangedFnV2, + callback_data: *mut c_void, + out: *mut ResourceId, +) -> PluginStatus { + if out.is_null() { + return PluginStatus::InvalidArgument; + } + // SAFETY: ABI callers provide a live instance context. + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_HOST_STATE) { + return status; + } + let id = match host + .resources + .allocate(token, ResourceKind::HostStateSubscription, 0) + { + Ok(id) => id, + Err(status) => return status, + }; + let Ok(mut state) = host.state.lock() else { + let _ = host + .resources + .release(token, ResourceKind::HostStateSubscription, id); + return PluginStatus::Internal; + }; + state.subscriptions.insert( + id, + SubscriptionRecord { + token, + callback, + callback_data: callback_data as usize, + in_flight: 0, + }, + ); + // SAFETY: The caller supplied a writable ResourceId output pointer. + unsafe { *out = ResourceId::from_raw(id) }; + PluginStatus::Ok +} + +pub unsafe extern "C" fn release_subscription( + context: *mut c_void, + token: PluginToken, + id: ResourceId, +) -> PluginStatus { + // SAFETY: ABI callers provide a live instance context. + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_HOST_STATE) { + return status; + } + if let Err(status) = + host.resources + .require(token, ResourceKind::HostStateSubscription, id.get()) + { + return status; + } + let Ok(mut state) = host.state.lock() else { + return PluginStatus::Internal; + }; + if state + .subscriptions + .get(&id.get()) + .is_some_and(|record| record.in_flight > 0) + { + return PluginStatus::LimitExceeded; + } + if let Err(status) = + host.resources + .release(token, ResourceKind::HostStateSubscription, id.get()) + { + return status; + } + state.subscriptions.remove(&id.get()); + PluginStatus::Ok +} diff --git a/crates/winisland-plugin-host/src/services/i18n.rs b/crates/winisland-plugin-host/src/services/i18n.rs new file mode 100644 index 00000000..9a0a3820 --- /dev/null +++ b/crates/winisland-plugin-host/src/services/i18n.rs @@ -0,0 +1,123 @@ +use std::collections::HashMap; +use std::ffi::c_void; + +use winisland_plugin_api::abi::{CAP_I18N, PluginStatus}; +use winisland_plugin_api::types::v2::i18n::TranslationPairV2; +use winisland_plugin_api::types::v2::{PluginToken, ResourceId, Utf8Slice}; + +use super::{read_utf8, runtime}; +use crate::resources::ResourceKind; +use crate::runtime::TranslationBundle; + +pub unsafe extern "C" fn register_bundle( + context: *mut c_void, + token: PluginToken, + locale: Utf8Slice, + pairs: *const TranslationPairV2, + count: u32, + out: *mut ResourceId, +) -> PluginStatus { + if out.is_null() || pairs.is_null() || count == 0 || count > 4096 { + return PluginStatus::InvalidArgument; + } + // SAFETY: The ABI caller provides a live instance context. + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_I18N) { + return status; + } + // SAFETY: The borrowed locale is bounded and copied before return. + let locale = match unsafe { read_utf8(locale, 64) } { + Ok(value) if !value.is_empty() => value, + _ => return PluginStatus::InvalidArgument, + }; + // SAFETY: The ABI caller keeps the bounded pair array readable during this call. + let pairs = unsafe { std::slice::from_raw_parts(pairs, count as usize) }; + let mut entries = HashMap::with_capacity(pairs.len()); + let mut bytes = 0_usize; + for pair in pairs { + // SAFETY: Both borrowed strings are copied during this call. + let key = match unsafe { read_utf8(pair.key, 64 * 1024) } { + Ok(value) if !value.is_empty() => value, + _ => return PluginStatus::InvalidArgument, + }; + // SAFETY: Both borrowed strings are copied during this call. + let value = match unsafe { read_utf8(pair.value, 64 * 1024) } { + Ok(value) => value, + Err(status) => return status, + }; + bytes = match bytes + .checked_add(key.len()) + .and_then(|n| n.checked_add(value.len())) + { + Some(bytes) if bytes <= 1024 * 1024 => bytes, + _ => return PluginStatus::LimitExceeded, + }; + if entries.insert(key, value).is_some() { + return PluginStatus::InvalidArgument; + } + } + let id = match host.resources.allocate(token, ResourceKind::I18n, bytes) { + Ok(id) => id, + Err(status) => return status, + }; + let Ok(mut state) = host.state.lock() else { + let _ = host.resources.release(token, ResourceKind::I18n, id); + return PluginStatus::Internal; + }; + state.translations.insert( + id, + TranslationBundle { + locale: locale.clone(), + entries: entries.clone(), + }, + ); + drop(state); + if winisland_core::i18n::register_plugin_translation_bundle( + id, + locale, + entries.into_iter().collect(), + ) + .is_err() + { + if let Ok(mut state) = host.state.lock() { + state.translations.remove(&id); + } + let _ = host.resources.release(token, ResourceKind::I18n, id); + return PluginStatus::Internal; + } + // SAFETY: The ABI caller supplied a writable ResourceId output pointer. + unsafe { *out = ResourceId::from_raw(id) }; + PluginStatus::Ok +} + +pub unsafe extern "C" fn release_bundle( + context: *mut c_void, + token: PluginToken, + id: ResourceId, +) -> PluginStatus { + // SAFETY: The ABI caller provides a live instance context. + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_I18N) { + return status; + } + let Ok(mut state) = host.state.lock() else { + return PluginStatus::Internal; + }; + if let Err(status) = host.resources.require(token, ResourceKind::I18n, id.get()) { + return status; + } + if winisland_core::i18n::release_plugin_translation_bundle(id.get()).is_err() { + return PluginStatus::Internal; + } + if let Err(status) = host.resources.release(token, ResourceKind::I18n, id.get()) { + return status; + } + state.translations.remove(&id.get()); + PluginStatus::Ok +} diff --git a/crates/winisland-plugin-host/src/services/image.rs b/crates/winisland-plugin-host/src/services/image.rs new file mode 100644 index 00000000..202de447 --- /dev/null +++ b/crates/winisland-plugin-host/src/services/image.rs @@ -0,0 +1,150 @@ +use std::ffi::c_void; + +use winisland_plugin_api::abi::{CAP_IMAGE, PluginStatus}; +use winisland_plugin_api::types::v2::{ByteSlice, ImageId, PluginToken}; +use winisland_render::Image; + +use super::{read_bytes, runtime}; +use crate::resources::ResourceKind; +use crate::runtime::HostRuntime; + +fn insert(host: &HostRuntime, token: PluginToken, image: Image, out: *mut ImageId) -> PluginStatus { + if out.is_null() { + return PluginStatus::InvalidArgument; + } + let (width, height) = image.dimensions(); + let Some(bytes) = usize::try_from(width) + .ok() + .and_then(|width| { + usize::try_from(height) + .ok() + .and_then(|height| width.checked_mul(height)) + }) + .and_then(|pixels| pixels.checked_mul(4)) + else { + return PluginStatus::LimitExceeded; + }; + let id = match host.resources.allocate(token, ResourceKind::Image, bytes) { + Ok(id) => id, + Err(status) => return status, + }; + let Ok(mut state) = host.state.lock() else { + let _ = host.resources.release(token, ResourceKind::Image, id); + return PluginStatus::Internal; + }; + state.images.insert(id, image); + // SAFETY: The ABI caller supplies a writable ImageId output pointer. + unsafe { *out = ImageId::from_raw(id) }; + PluginStatus::Ok +} + +pub unsafe extern "C" fn decode( + context: *mut c_void, + token: PluginToken, + encoded: ByteSlice, + out: *mut ImageId, +) -> PluginStatus { + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_IMAGE) { + return status; + } + let encoded = match unsafe { read_bytes(encoded, 16 * 1024 * 1024) } { + Ok(bytes) => bytes, + Err(status) => return status, + }; + let Some(image) = Image::decode(&encoded) else { + return PluginStatus::InvalidArgument; + }; + insert(host, token, image, out) +} + +pub unsafe extern "C" fn upload_rgba( + context: *mut c_void, + token: PluginToken, + width: u32, + height: u32, + rgba: ByteSlice, + out: *mut ImageId, +) -> PluginStatus { + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_IMAGE) { + return status; + } + let Some(expected) = usize::try_from(width) + .ok() + .and_then(|width| { + usize::try_from(height) + .ok() + .and_then(|height| width.checked_mul(height)) + }) + .and_then(|pixels| pixels.checked_mul(4)) + else { + return PluginStatus::InvalidArgument; + }; + if width == 0 || height == 0 || width > 4096 || height > 4096 || expected != rgba.len as usize { + return PluginStatus::InvalidArgument; + } + let rgba = match unsafe { read_bytes(rgba, 64 * 1024 * 1024) } { + Ok(bytes) => bytes, + Err(status) => return status, + }; + let Some(image) = Image::from_rgba8(width as i32, height as i32, &rgba) else { + return PluginStatus::InvalidArgument; + }; + insert(host, token, image, out) +} + +pub unsafe extern "C" fn album_art( + context: *mut c_void, + token: PluginToken, + out: *mut ImageId, +) -> PluginStatus { + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_IMAGE) { + return status; + } + let image = match host.state.lock() { + Ok(state) => state.album_art.clone(), + Err(_) => return PluginStatus::Internal, + }; + let Some(image) = image else { + return PluginStatus::IoError; + }; + insert(host, token, image, out) +} + +pub unsafe extern "C" fn release( + context: *mut c_void, + token: PluginToken, + image: ImageId, +) -> PluginStatus { + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_IMAGE) { + return status; + } + if let Err(status) = host + .resources + .release(token, ResourceKind::Image, image.get()) + { + return status; + } + match host.state.lock() { + Ok(mut state) => { + state.images.remove(&image.get()); + PluginStatus::Ok + } + Err(_) => PluginStatus::Internal, + } +} diff --git a/crates/winisland-plugin-host/src/services/log.rs b/crates/winisland-plugin-host/src/services/log.rs new file mode 100644 index 00000000..3d3341ce --- /dev/null +++ b/crates/winisland-plugin-host/src/services/log.rs @@ -0,0 +1,37 @@ +use std::ffi::c_void; + +use winisland_plugin_api::abi::PluginStatus; +use winisland_plugin_api::types::v2::{PluginToken, Utf8Slice}; + +use super::{read_utf8, runtime}; + +pub unsafe extern "C" fn write( + context: *mut c_void, + token: PluginToken, + level: u32, + message: Utf8Slice, +) -> PluginStatus { + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + let plugin = match host.registry.get(token) { + Ok(plugin) if !plugin.stopping => plugin, + Ok(_) => return PluginStatus::StaleHandle, + Err(status) => return status, + }; + let message = match unsafe { read_utf8(message, 64 * 1024) } { + Ok(message) => message, + Err(status) => return status, + }; + let level = match level { + 0 => log::Level::Error, + 1 => log::Level::Warn, + 2 => log::Level::Info, + 3 => log::Level::Debug, + 4 => log::Level::Trace, + _ => return PluginStatus::InvalidArgument, + }; + log::log!(target: "winisland::plugin", level, "[{} {}] {}", plugin.id, plugin.version, message); + PluginStatus::Ok +} diff --git a/crates/winisland-plugin-host/src/services/lyrics.rs b/crates/winisland-plugin-host/src/services/lyrics.rs new file mode 100644 index 00000000..d48d56a2 --- /dev/null +++ b/crates/winisland-plugin-host/src/services/lyrics.rs @@ -0,0 +1,94 @@ +use std::ffi::c_void; + +use winisland_plugin_api::abi::{CAP_LYRICS, PluginStatus}; +use winisland_plugin_api::types::v2::lyrics::LyricsTransformerDataV2; +use winisland_plugin_api::types::v2::{PluginToken, ResourceId}; + +use super::{read_struct, runtime}; +use crate::resources::ResourceKind; +use crate::runtime::LyricsRecord; + +pub unsafe extern "C" fn register( + context: *mut c_void, + token: PluginToken, + data: *const LyricsTransformerDataV2, + out: *mut ResourceId, +) -> PluginStatus { + if out.is_null() { + return PluginStatus::InvalidArgument; + } + // SAFETY: The ABI caller provides a live instance context and sized input. + let (host, data) = match unsafe { + runtime(context).and_then(|host| read_struct(data).map(|data| (host, data))) + } { + Ok(value) => value, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_LYRICS) { + return status; + } + if data.flags != 0 { + return PluginStatus::InvalidArgument; + } + let Some(on_transform) = data.on_transform else { + return PluginStatus::InvalidArgument; + }; + let id = match host.resources.allocate(token, ResourceKind::Lyrics, 0) { + Ok(id) => id, + Err(status) => return status, + }; + let Ok(mut state) = host.state.lock() else { + let _ = host.resources.release(token, ResourceKind::Lyrics, id); + return PluginStatus::Internal; + }; + state.lyrics.insert( + id, + LyricsRecord { + on_transform, + callback_data: data.callback_data as usize, + in_flight: 0, + }, + ); + // SAFETY: The ABI caller supplied a writable ResourceId output pointer. + unsafe { *out = ResourceId::from_raw(id) }; + PluginStatus::Ok +} + +pub unsafe extern "C" fn release( + context: *mut c_void, + token: PluginToken, + id: ResourceId, +) -> PluginStatus { + // SAFETY: The ABI caller provides a live instance context. + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_LYRICS) { + return status; + } + if let Err(status) = host + .resources + .require(token, ResourceKind::Lyrics, id.get()) + { + return status; + } + let Ok(mut state) = host.state.lock() else { + return PluginStatus::Internal; + }; + if state + .lyrics + .get(&id.get()) + .is_some_and(|record| record.in_flight > 0) + { + return PluginStatus::LimitExceeded; + } + if let Err(status) = host + .resources + .release(token, ResourceKind::Lyrics, id.get()) + { + return status; + } + state.lyrics.remove(&id.get()); + PluginStatus::Ok +} diff --git a/crates/winisland-plugin-host/src/services/media.rs b/crates/winisland-plugin-host/src/services/media.rs new file mode 100644 index 00000000..8ef96172 --- /dev/null +++ b/crates/winisland-plugin-host/src/services/media.rs @@ -0,0 +1,203 @@ +use std::ffi::c_void; + +use winisland_plugin_api::abi::{CAP_MEDIA, PluginStatus}; +use winisland_plugin_api::types::v2::context::{ + MEDIA_CONTROL_NEXT, MEDIA_CONTROL_PREVIOUS, MEDIA_CONTROL_SEEK, MEDIA_CONTROL_TOGGLE_PLAY, + MEDIA_FLAG_PLAYING, MediaSourceDataV2, +}; +use winisland_plugin_api::types::v2::{PluginToken, ResourceId}; + +use super::{read_bytes, read_struct, runtime, write_buffer}; +use crate::resources::ResourceKind; +use crate::runtime::MediaRecord; + +fn fixed_text(value: &[u8]) -> String { + String::from_utf8_lossy(value.split(|byte| *byte == 0).next().unwrap_or(value)).into_owned() +} + +unsafe fn copy_media(data: &MediaSourceDataV2) -> Result { + let title = fixed_text(&data.title); + let controls = MEDIA_CONTROL_NEXT + | MEDIA_CONTROL_PREVIOUS + | MEDIA_CONTROL_SEEK + | MEDIA_CONTROL_TOGGLE_PLAY; + if title.is_empty() + || data.flags & !MEDIA_FLAG_PLAYING != 0 + || data.available_controls & !controls != 0 + || (data.available_controls != 0 && data.on_command.is_none()) + { + return Err(PluginStatus::InvalidArgument); + } + // SAFETY: The caller keeps the bounded cover slice readable until return. + let cover = unsafe { read_bytes(data.cover, 16 * 1024 * 1024) }?; + Ok(MediaRecord { + sequence: 0, + in_flight: 0, + title, + artist: fixed_text(&data.artist), + album: fixed_text(&data.album), + flags: data.flags, + duration_ms: data.duration_ms, + position_ms: data.position_ms, + available_controls: data.available_controls, + cover, + on_command: data.on_command, + callback_data: data.callback_data as usize, + }) +} + +pub unsafe extern "C" fn create( + context: *mut c_void, + token: PluginToken, + data: *const MediaSourceDataV2, + out: *mut ResourceId, +) -> PluginStatus { + if out.is_null() { + return PluginStatus::InvalidArgument; + } + // SAFETY: The ABI caller provides a live instance context and sized input. + let (host, data) = match unsafe { + runtime(context).and_then(|host| read_struct(data).map(|data| (host, data))) + } { + Ok(value) => value, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_MEDIA) { + return status; + } + // SAFETY: The borrowed cover is copied before returning. + let record = match unsafe { copy_media(&data) } { + Ok(record) => record, + Err(status) => return status, + }; + let id = match host + .resources + .allocate(token, ResourceKind::Media, record.cover.len()) + { + Ok(id) => id, + Err(status) => return status, + }; + let Ok(mut state) = host.state.lock() else { + let _ = host.resources.release(token, ResourceKind::Media, id); + return PluginStatus::Internal; + }; + state.media_revision = state.media_revision.wrapping_add(1); + let mut record = record; + record.sequence = state.media_revision; + state.media.insert(id, record); + // SAFETY: The ABI caller supplied a writable ResourceId output pointer. + unsafe { *out = ResourceId::from_raw(id) }; + PluginStatus::Ok +} + +pub unsafe extern "C" fn update( + context: *mut c_void, + token: PluginToken, + id: ResourceId, + data: *const MediaSourceDataV2, +) -> PluginStatus { + // SAFETY: The ABI caller provides a live instance context and sized input. + let (host, data) = match unsafe { + runtime(context).and_then(|host| read_struct(data).map(|data| (host, data))) + } { + Ok(value) => value, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_MEDIA) { + return status; + } + if let Err(status) = host.resources.require(token, ResourceKind::Media, id.get()) { + return status; + } + // SAFETY: The borrowed cover is copied before returning. + let record = match unsafe { copy_media(&data) } { + Ok(record) => record, + Err(status) => return status, + }; + let Ok(mut state) = host.state.lock() else { + return PluginStatus::Internal; + }; + if !state.media.contains_key(&id.get()) { + return PluginStatus::StaleHandle; + } + if state + .media + .get(&id.get()) + .is_some_and(|media| media.in_flight > 0) + { + return PluginStatus::LimitExceeded; + } + if let Err(status) = + host.resources + .resize(token, ResourceKind::Media, id.get(), record.cover.len()) + { + return status; + } + state.media_revision = state.media_revision.wrapping_add(1); + let mut record = record; + record.sequence = state.media_revision; + state.media.insert(id.get(), record); + PluginStatus::Ok +} + +pub unsafe extern "C" fn release( + context: *mut c_void, + token: PluginToken, + id: ResourceId, +) -> PluginStatus { + // SAFETY: The ABI caller provides a live instance context. + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_MEDIA) { + return status; + } + if let Err(status) = host.resources.require(token, ResourceKind::Media, id.get()) { + return status; + } + let Ok(mut state) = host.state.lock() else { + return PluginStatus::Internal; + }; + if state + .media + .get(&id.get()) + .is_some_and(|media| media.in_flight > 0) + { + return PluginStatus::LimitExceeded; + } + if let Err(status) = host.resources.release(token, ResourceKind::Media, id.get()) { + return status; + } + state.media.remove(&id.get()); + state.media_revision = state.media_revision.wrapping_add(1); + PluginStatus::Ok +} + +pub unsafe extern "C" fn current_title( + context: *mut c_void, + token: PluginToken, + buffer: *mut u8, + capacity: u32, + required: *mut u32, +) -> PluginStatus { + // SAFETY: The ABI caller provides a live instance context. + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_MEDIA) { + return status; + } + let Ok(state) = host.state.lock() else { + return PluginStatus::Internal; + }; + let title = state + .host_state + .media_title + .split(|byte| *byte == 0) + .next() + .unwrap_or(&[]); + // SAFETY: The helper checks output pointers and capacity before writing. + unsafe { write_buffer(title, buffer, capacity, required) } +} diff --git a/crates/winisland-plugin-host/src/services/mod.rs b/crates/winisland-plugin-host/src/services/mod.rs new file mode 100644 index 00000000..7a2fb889 --- /dev/null +++ b/crates/winisland-plugin-host/src/services/mod.rs @@ -0,0 +1,90 @@ +pub mod context; +pub mod host_state; +pub mod i18n; +pub mod image; +pub mod log; +pub mod lyrics; +pub mod media; +pub mod settings; +pub mod store; +pub mod text; +pub mod widget; + +use std::ffi::c_void; + +use winisland_plugin_api::abi::PluginStatus; +use winisland_plugin_api::types::v2::{ByteSlice, Utf8Slice}; + +use crate::runtime::HostRuntime; + +pub(crate) unsafe fn runtime<'a>(context: *mut c_void) -> Result<&'a HostRuntime, PluginStatus> { + if context.is_null() { + return Err(PluginStatus::InvalidArgument); + } + // SAFETY: Every service table is bound to a live Box address. + Ok(unsafe { &*context.cast::() }) +} + +pub(crate) unsafe fn read_struct(value: *const T) -> Result { + if value.is_null() { + return Err(PluginStatus::InvalidArgument); + } + // SAFETY: The plugin promises a readable ABI struct header. + let size = unsafe { std::ptr::read_unaligned(value.cast::()) }; + if size < std::mem::size_of::() as u32 { + return Err(PluginStatus::InvalidArgument); + } + // SAFETY: The validated struct header covers the current ABI layout. + Ok(unsafe { std::ptr::read_unaligned(value) }) +} + +pub(crate) unsafe fn read_bytes(value: ByteSlice, max_len: usize) -> Result, PluginStatus> { + if value.len as usize > max_len || (value.ptr.is_null() && value.len != 0) { + return Err(PluginStatus::InvalidArgument); + } + if value.len == 0 { + return Ok(Vec::new()); + } + // SAFETY: The plugin keeps this bounded borrowed slice live until return. + Ok(unsafe { std::slice::from_raw_parts(value.ptr, value.len as usize) }.to_vec()) +} + +pub(crate) unsafe fn read_utf8(value: Utf8Slice, max_len: usize) -> Result { + let bytes = unsafe { + read_bytes( + ByteSlice { + ptr: value.ptr, + len: value.len, + }, + max_len, + ) + }?; + String::from_utf8(bytes).map_err(|_| PluginStatus::InvalidArgument) +} + +pub(crate) unsafe fn write_buffer( + value: &[u8], + buffer: *mut u8, + capacity: u32, + required: *mut u32, +) -> PluginStatus { + if required.is_null() { + return PluginStatus::InvalidArgument; + } + let Ok(len) = u32::try_from(value.len()) else { + return PluginStatus::LimitExceeded; + }; + // SAFETY: The caller supplies a writable required-length pointer. + unsafe { *required = len }; + if capacity < len { + return PluginStatus::LimitExceeded; + } + if len != 0 { + if buffer.is_null() { + return PluginStatus::InvalidArgument; + } + // SAFETY: The caller supplies at least `capacity` writable bytes. + unsafe { std::ptr::copy_nonoverlapping(value.as_ptr(), buffer, len as usize) }; + } + PluginStatus::Ok +} diff --git a/crates/winisland-plugin-host/src/services/settings.rs b/crates/winisland-plugin-host/src/services/settings.rs new file mode 100644 index 00000000..c548370b --- /dev/null +++ b/crates/winisland-plugin-host/src/services/settings.rs @@ -0,0 +1,321 @@ +use std::collections::HashSet; +use std::ffi::c_void; + +use winisland_plugin_api::abi::{CAP_SETTINGS, PluginStatus}; +use winisland_plugin_api::types::v2::settings::{ + SETTINGS_ITEM_BUTTON, SETTINGS_ITEM_FLAG_DISABLED, SETTINGS_ITEM_GROUP_END, + SETTINGS_ITEM_GROUP_START, SETTINGS_ITEM_LABEL, SETTINGS_ITEM_SECTION, SETTINGS_ITEM_SELECT, + SETTINGS_ITEM_STEPPER, SETTINGS_ITEM_SWITCH, SettingsItemV2, SettingsOptionV2, + SettingsPageDataV2, +}; +use winisland_plugin_api::types::v2::{PluginToken, ResourceId}; + +use super::{read_bytes, read_struct, runtime}; +use crate::resources::ResourceKind; +use crate::runtime::{SettingsItemRecord, SettingsRecord}; + +fn fixed(value: &[u8]) -> String { + String::from_utf8_lossy(value.split(|byte| *byte == 0).next().unwrap_or(value)).into_owned() +} + +fn key(value: &[u8; 64]) -> Result { + let Some(end) = value.iter().position(|byte| *byte == 0) else { + return Err(PluginStatus::InvalidArgument); + }; + if end == 0 + || !value[..end] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-' || *byte == b'_') + { + return Err(PluginStatus::InvalidArgument); + } + Ok(String::from_utf8_lossy(&value[..end]).into_owned()) +} + +unsafe fn copy_page(data: SettingsPageDataV2) -> Result<(SettingsRecord, usize), PluginStatus> { + let page_key = key(&data.key)?; + let title = fixed(&data.title); + if title.trim().is_empty() + || data.items.is_null() + || data.item_count == 0 + || data.item_count > 64 + { + return Err(PluginStatus::InvalidArgument); + } + // SAFETY: The caller keeps the bounded icon slice readable until return. + let icon = unsafe { read_bytes(data.icon, 1024 * 1024) }?; + // SAFETY: The caller keeps the bounded item array readable until return. + let input_items = unsafe { std::slice::from_raw_parts(data.items, data.item_count as usize) }; + let mut items = Vec::with_capacity(input_items.len()); + let mut keys = HashSet::new(); + let mut group_open = false; + let mut has_actions = false; + let mut bytes = page_key.len() + title.len() + icon.len(); + for input in input_items { + if input.struct_size < std::mem::size_of::() as u32 + || input.flags & !SETTINGS_ITEM_FLAG_DISABLED != 0 + { + return Err(PluginStatus::InvalidArgument); + } + let label = fixed(&input.label); + let interactive = match input.kind { + SETTINGS_ITEM_SECTION => { + if group_open || label.trim().is_empty() { + return Err(PluginStatus::InvalidArgument); + } + false + } + SETTINGS_ITEM_GROUP_START => { + if group_open { + return Err(PluginStatus::InvalidArgument); + } + group_open = true; + false + } + SETTINGS_ITEM_GROUP_END => { + if !group_open { + return Err(PluginStatus::InvalidArgument); + } + group_open = false; + false + } + SETTINGS_ITEM_LABEL => { + if !group_open || label.trim().is_empty() { + return Err(PluginStatus::InvalidArgument); + } + false + } + SETTINGS_ITEM_SWITCH + | SETTINGS_ITEM_SELECT + | SETTINGS_ITEM_STEPPER + | SETTINGS_ITEM_BUTTON => { + if !group_open || label.trim().is_empty() { + return Err(PluginStatus::InvalidArgument); + } + let item_key = key(&input.key)?; + if !keys.insert(item_key) { + return Err(PluginStatus::InvalidArgument); + } + true + } + _ => return Err(PluginStatus::InvalidArgument), + }; + has_actions |= interactive; + if input.kind == SETTINGS_ITEM_SWITCH + && !matches!(fixed(&input.value).as_str(), "true" | "false") + { + return Err(PluginStatus::InvalidArgument); + } + if input.kind == SETTINGS_ITEM_STEPPER { + let Ok(value) = fixed(&input.value).parse::() else { + return Err(PluginStatus::InvalidArgument); + }; + if !value.is_finite() + || !input.minimum.is_finite() + || !input.maximum.is_finite() + || !input.step.is_finite() + || input.minimum > input.maximum + || input.step <= 0.0 + || !(input.minimum..=input.maximum).contains(&value) + { + return Err(PluginStatus::InvalidArgument); + } + } + let options = if input.kind == SETTINGS_ITEM_SELECT { + if input.options.is_null() || input.option_count == 0 || input.option_count > 64 { + return Err(PluginStatus::InvalidArgument); + } + // SAFETY: The caller keeps the bounded option array readable until return. + let options = + unsafe { std::slice::from_raw_parts(input.options, input.option_count as usize) }; + let mut values = HashSet::new(); + let mut copied = Vec::with_capacity(options.len()); + for option in options { + if option.struct_size < std::mem::size_of::() as u32 + || fixed(&option.label).trim().is_empty() + { + return Err(PluginStatus::InvalidArgument); + } + let value = fixed(&option.value); + if value.is_empty() || !values.insert(value) { + return Err(PluginStatus::InvalidArgument); + } + bytes = bytes + .checked_add(option.value.len() + option.label.len()) + .ok_or(PluginStatus::LimitExceeded)?; + copied.push(*option); + } + if !copied + .iter() + .any(|option| fixed(&option.value) == fixed(&input.value)) + { + return Err(PluginStatus::InvalidArgument); + } + copied + } else { + Vec::new() + }; + bytes = bytes + .checked_add(input.key.len() + label.len() + input.value.len()) + .ok_or(PluginStatus::LimitExceeded)?; + if bytes > 2 * 1024 * 1024 { + return Err(PluginStatus::LimitExceeded); + } + let mut raw = *input; + raw.options = std::ptr::null(); + items.push(SettingsItemRecord { raw, options }); + } + if group_open || (has_actions && data.on_change.is_none()) { + return Err(PluginStatus::InvalidArgument); + } + Ok(( + SettingsRecord { + sequence: 0, + key: page_key, + title, + icon, + items, + on_change: data.on_change, + callback_data: data.callback_data as usize, + in_flight: 0, + }, + bytes, + )) +} + +pub unsafe extern "C" fn create( + context: *mut c_void, + token: PluginToken, + data: *const SettingsPageDataV2, + out: *mut ResourceId, +) -> PluginStatus { + if out.is_null() { + return PluginStatus::InvalidArgument; + } + // SAFETY: The ABI caller provides a live instance context and sized input. + let (host, data) = match unsafe { + runtime(context).and_then(|host| read_struct(data).map(|data| (host, data))) + } { + Ok(value) => value, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_SETTINGS) { + return status; + } + // SAFETY: Borrowed icon, items, and options are copied before return. + let (record, bytes) = match unsafe { copy_page(data) } { + Ok(value) => value, + Err(status) => return status, + }; + let id = match host + .resources + .allocate(token, ResourceKind::Settings, bytes) + { + Ok(id) => id, + Err(status) => return status, + }; + let Ok(mut state) = host.state.lock() else { + let _ = host.resources.release(token, ResourceKind::Settings, id); + return PluginStatus::Internal; + }; + state.settings_revision = state.settings_revision.wrapping_add(1); + let mut record = record; + record.sequence = state.settings_revision; + state.settings.insert(id, record); + // SAFETY: The ABI caller supplied a writable ResourceId output pointer. + unsafe { *out = ResourceId::from_raw(id) }; + PluginStatus::Ok +} + +pub unsafe extern "C" fn update( + context: *mut c_void, + token: PluginToken, + id: ResourceId, + data: *const SettingsPageDataV2, +) -> PluginStatus { + // SAFETY: The ABI caller provides a live instance context and sized input. + let (host, data) = match unsafe { + runtime(context).and_then(|host| read_struct(data).map(|data| (host, data))) + } { + Ok(value) => value, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_SETTINGS) { + return status; + } + if let Err(status) = host + .resources + .require(token, ResourceKind::Settings, id.get()) + { + return status; + } + // SAFETY: Borrowed icon, items, and options are copied before return. + let (record, bytes) = match unsafe { copy_page(data) } { + Ok(value) => value, + Err(status) => return status, + }; + let Ok(mut state) = host.state.lock() else { + return PluginStatus::Internal; + }; + let Some(previous) = state.settings.get(&id.get()) else { + return PluginStatus::StaleHandle; + }; + if previous.in_flight > 0 { + return PluginStatus::LimitExceeded; + } + if previous.key != record.key { + return PluginStatus::InvalidArgument; + } + let sequence = previous.sequence; + if let Err(status) = host + .resources + .resize(token, ResourceKind::Settings, id.get(), bytes) + { + return status; + } + state.settings_revision = state.settings_revision.wrapping_add(1); + let mut record = record; + record.sequence = sequence; + state.settings.insert(id.get(), record); + PluginStatus::Ok +} + +pub unsafe extern "C" fn release( + context: *mut c_void, + token: PluginToken, + id: ResourceId, +) -> PluginStatus { + // SAFETY: The ABI caller provides a live instance context. + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_SETTINGS) { + return status; + } + if let Err(status) = host + .resources + .require(token, ResourceKind::Settings, id.get()) + { + return status; + } + let Ok(mut state) = host.state.lock() else { + return PluginStatus::Internal; + }; + if state + .settings + .get(&id.get()) + .is_some_and(|record| record.in_flight > 0) + { + return PluginStatus::LimitExceeded; + } + if let Err(status) = host + .resources + .release(token, ResourceKind::Settings, id.get()) + { + return status; + } + state.settings.remove(&id.get()); + state.settings_revision = state.settings_revision.wrapping_add(1); + PluginStatus::Ok +} diff --git a/crates/winisland-plugin-host/src/services/store.rs b/crates/winisland-plugin-host/src/services/store.rs new file mode 100644 index 00000000..89364148 --- /dev/null +++ b/crates/winisland-plugin-host/src/services/store.rs @@ -0,0 +1,164 @@ +use std::ffi::c_void; +use std::io::Read; +use std::path::PathBuf; + +use sha2::{Digest, Sha256}; +use winisland_plugin_api::abi::{CAP_STORE, PluginStatus}; +use winisland_plugin_api::types::v2::{ByteSlice, PluginToken, Utf8Slice}; + +use super::{read_bytes, read_utf8, runtime, write_buffer}; +use crate::runtime::HostRuntime; + +const MAX_VALUE: usize = 1024 * 1024; + +fn path(host: &HostRuntime, token: PluginToken, key: &str) -> Result { + let plugin = host.registry.get(token)?; + if plugin.stopping { + return Err(PluginStatus::StaleHandle); + } + let digest = Sha256::digest(key.as_bytes()); + let mut name = String::with_capacity(68); + for byte in digest { + use std::fmt::Write; + write!(name, "{byte:02x}").map_err(|_| PluginStatus::Internal)?; + } + name.push_str(".bin"); + Ok(host.store_root.join(plugin.id).join("store").join(name)) +} + +fn key(value: Utf8Slice) -> Result { + if value.len > 255 { + return Err(PluginStatus::LimitExceeded); + } + let key = unsafe { read_utf8(value, 255) }?; + if key.is_empty() { + return Err(PluginStatus::InvalidArgument); + } + Ok(key) +} + +pub unsafe extern "C" fn get( + context: *mut c_void, + token: PluginToken, + raw_key: Utf8Slice, + buffer: *mut u8, + capacity: u32, + required: *mut u32, + found: *mut u8, +) -> PluginStatus { + if required.is_null() || found.is_null() { + return PluginStatus::InvalidArgument; + } + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_STORE) { + return status; + } + let key = match key(raw_key) { + Ok(key) => key, + Err(status) => return status, + }; + let path = match path(host, token, &key) { + Ok(path) => path, + Err(status) => return status, + }; + let Ok(_guard) = host.store_lock.lock() else { + return PluginStatus::Internal; + }; + let file = match std::fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + unsafe { + *required = 0; + *found = 0; + } + return PluginStatus::Ok; + } + Err(_) => return PluginStatus::IoError, + }; + let mut bytes = Vec::new(); + if file + .take(MAX_VALUE as u64 + 1) + .read_to_end(&mut bytes) + .is_err() + { + return PluginStatus::IoError; + } + if bytes.len() > MAX_VALUE { + return PluginStatus::LimitExceeded; + } + unsafe { *found = 1 }; + unsafe { write_buffer(&bytes, buffer, capacity, required) } +} + +pub unsafe extern "C" fn set( + context: *mut c_void, + token: PluginToken, + raw_key: Utf8Slice, + value: ByteSlice, +) -> PluginStatus { + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_STORE) { + return status; + } + let key = match key(raw_key) { + Ok(key) => key, + Err(status) => return status, + }; + if value.len as usize > MAX_VALUE { + return PluginStatus::LimitExceeded; + } + let value = match unsafe { read_bytes(value, MAX_VALUE) } { + Ok(value) => value, + Err(status) => return status, + }; + let path = match path(host, token, &key) { + Ok(path) => path, + Err(status) => return status, + }; + let Ok(_guard) = host.store_lock.lock() else { + return PluginStatus::Internal; + }; + let Some(parent) = path.parent() else { + return PluginStatus::Internal; + }; + if std::fs::create_dir_all(parent).is_err() || std::fs::write(path, value).is_err() { + return PluginStatus::IoError; + } + PluginStatus::Ok +} + +pub unsafe extern "C" fn delete( + context: *mut c_void, + token: PluginToken, + raw_key: Utf8Slice, +) -> PluginStatus { + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_STORE) { + return status; + } + let key = match key(raw_key) { + Ok(key) => key, + Err(status) => return status, + }; + let path = match path(host, token, &key) { + Ok(path) => path, + Err(status) => return status, + }; + let Ok(_guard) = host.store_lock.lock() else { + return PluginStatus::Internal; + }; + match std::fs::remove_file(path) { + Ok(()) => PluginStatus::Ok, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => PluginStatus::Ok, + Err(_) => PluginStatus::IoError, + } +} diff --git a/crates/winisland-plugin-host/src/services/text.rs b/crates/winisland-plugin-host/src/services/text.rs new file mode 100644 index 00000000..85279d40 --- /dev/null +++ b/crates/winisland-plugin-host/src/services/text.rs @@ -0,0 +1,88 @@ +use std::ffi::c_void; + +use winisland_plugin_api::abi::{CAP_TEXT, PluginStatus}; +use winisland_plugin_api::types::v2::{PluginToken, TextMetricsV2, TextStyleV2, Utf8Slice}; +use winisland_render::text::FontManager; + +use super::{read_utf8, runtime, write_buffer}; + +pub unsafe extern "C" fn measure( + context: *mut c_void, + token: PluginToken, + text: Utf8Slice, + style: *const TextStyleV2, + out: *mut TextMetricsV2, +) -> PluginStatus { + if style.is_null() || out.is_null() { + return PluginStatus::InvalidArgument; + } + // SAFETY: The ABI caller provides a live instance context. + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_TEXT) { + return status; + } + // SAFETY: TextStyleV2 has a fixed layout and the plugin keeps it readable during this call. + let style = unsafe { std::ptr::read_unaligned(style) }; + if !style.size.is_finite() + || style.size <= 0.0 + || style.size > 10_000.0 + || !(100..=900).contains(&style.weight) + || style.italic > 1 + || style.reserved != 0 + { + return PluginStatus::InvalidArgument; + } + // SAFETY: Borrowed UTF-8 slices are bounded and copied before font measurement. + let (text, family) = match unsafe { + read_utf8(text, 64 * 1024) + .and_then(|text| read_utf8(style.family, 255).map(|family| (text, family))) + } { + Ok(value) => value, + Err(status) => return status, + }; + let Some(metrics) = FontManager::global().measure_plugin_text( + &text, + style.size, + style.weight, + style.italic != 0, + &family, + ) else { + return PluginStatus::Internal; + }; + // SAFETY: The ABI caller supplied a writable output metrics pointer. + unsafe { + *out = TextMetricsV2 { + width: metrics.width, + height: metrics.height, + ascent: metrics.ascent, + descent: metrics.descent, + } + }; + PluginStatus::Ok +} + +pub unsafe extern "C" fn font_family( + context: *mut c_void, + token: PluginToken, + index: u32, + buffer: *mut u8, + capacity: u32, + required: *mut u32, +) -> PluginStatus { + // SAFETY: The ABI caller provides a live instance context. + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_TEXT) { + return status; + } + let Some(family) = FontManager::global().plugin_font_family(index) else { + return PluginStatus::InvalidArgument; + }; + // SAFETY: The helper checks output pointers and capacity before writing. + unsafe { write_buffer(family.as_bytes(), buffer, capacity, required) } +} diff --git a/crates/winisland-plugin-host/src/services/widget.rs b/crates/winisland-plugin-host/src/services/widget.rs new file mode 100644 index 00000000..79b8a622 --- /dev/null +++ b/crates/winisland-plugin-host/src/services/widget.rs @@ -0,0 +1,246 @@ +use std::ffi::c_void; +use std::sync::Arc; + +use winisland_plugin_api::abi::{CAP_WIDGET, PluginStatus}; +use winisland_plugin_api::draw::v2::MAX_LIST_BYTES; +use winisland_plugin_api::types::v2::widget::{WIDGET_FLAG_SHOW_COMPACT, WidgetSpecV2}; +use winisland_plugin_api::types::v2::{PluginToken, WidgetId}; + +use super::{read_struct, runtime}; +use crate::resources::ResourceKind; +use crate::runtime::WidgetRecord; + +fn valid_spec(spec: &WidgetSpecV2) -> bool { + (1..=4).contains(&spec.span_cols) + && (1..=4).contains(&spec.span_rows) + && spec.flags & !WIDGET_FLAG_SHOW_COMPACT == 0 + && spec.min_width.is_finite() + && spec.min_height.is_finite() + && spec.min_width >= 0.0 + && spec.min_height >= 0.0 +} + +pub unsafe extern "C" fn create( + context: *mut c_void, + token: PluginToken, + spec: *const WidgetSpecV2, + out: *mut WidgetId, +) -> PluginStatus { + if out.is_null() { + return PluginStatus::InvalidArgument; + } + // SAFETY: ABI callers provide a live instance context and a readable sized spec. + let (host, spec) = match unsafe { + runtime(context).and_then(|host| read_struct(spec).map(|spec| (host, spec))) + } { + Ok(value) => value, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_WIDGET) { + return status; + } + if !valid_spec(&spec) { + return PluginStatus::InvalidArgument; + } + let id = match host.resources.allocate(token, ResourceKind::Widget, 0) { + Ok(id) => id, + Err(status) => return status, + }; + let Ok(mut state) = host.state.lock() else { + let _ = host.resources.release(token, ResourceKind::Widget, id); + return PluginStatus::Internal; + }; + state.widgets.insert( + id, + WidgetRecord { + spec, + draw_list: Arc::from([]), + logical_width: spec.min_width.max(spec.span_cols as f32 * 60.0), + logical_height: spec.min_height.max(spec.span_rows as f32 * 48.0), + redraw: true, + failures: 0, + disabled: false, + }, + ); + // SAFETY: The caller supplied a writable WidgetId output pointer. + unsafe { *out = WidgetId::from_raw(id) }; + PluginStatus::Ok +} + +pub unsafe extern "C" fn update( + context: *mut c_void, + token: PluginToken, + id: WidgetId, + spec: *const WidgetSpecV2, +) -> PluginStatus { + // SAFETY: ABI callers provide a live instance context and a readable sized spec. + let (host, spec) = match unsafe { + runtime(context).and_then(|host| read_struct(spec).map(|spec| (host, spec))) + } { + Ok(value) => value, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_WIDGET) { + return status; + } + if !valid_spec(&spec) { + return PluginStatus::InvalidArgument; + } + if let Err(status) = host + .resources + .require(token, ResourceKind::Widget, id.get()) + { + return status; + } + let Ok(mut state) = host.state.lock() else { + return PluginStatus::Internal; + }; + let Some(record) = state.widgets.get_mut(&id.get()) else { + return PluginStatus::StaleHandle; + }; + if record.disabled { + return PluginStatus::StaleHandle; + } + record.spec = spec; + record.redraw = true; + PluginStatus::Ok +} + +pub unsafe extern "C" fn release( + context: *mut c_void, + token: PluginToken, + id: WidgetId, +) -> PluginStatus { + // SAFETY: ABI callers provide a live instance context. + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_WIDGET) { + return status; + } + let Ok(mut state) = host.state.lock() else { + return PluginStatus::Internal; + }; + if let Err(status) = host + .resources + .release(token, ResourceKind::Widget, id.get()) + { + return status; + } + state.widgets.remove(&id.get()); + PluginStatus::Ok +} + +pub unsafe extern "C" fn submit_draw_list( + context: *mut c_void, + token: PluginToken, + id: WidgetId, + data: *const u8, + len: u32, +) -> PluginStatus { + if data.is_null() { + return PluginStatus::InvalidArgument; + } + if len as usize > MAX_LIST_BYTES { + return PluginStatus::LimitExceeded; + } + // SAFETY: ABI callers provide a live instance context and `len` readable data bytes. + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_WIDGET) { + return status; + } + if let Err(status) = host + .resources + .require(token, ResourceKind::Widget, id.get()) + { + return status; + } + // SAFETY: The caller keeps the borrowed list readable until this call returns. + let bytes = unsafe { std::slice::from_raw_parts(data, len as usize) }; + let Ok(mut state) = host.state.lock() else { + return PluginStatus::Internal; + }; + let Some(record) = state.widgets.get_mut(&id.get()) else { + return PluginStatus::StaleHandle; + }; + if record.disabled { + return PluginStatus::StaleHandle; + } + record.draw_list = Arc::from(bytes); + record.redraw = true; + PluginStatus::Ok +} + +pub unsafe extern "C" fn request_redraw( + context: *mut c_void, + token: PluginToken, + id: WidgetId, +) -> PluginStatus { + // SAFETY: ABI callers provide a live instance context. + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_WIDGET) { + return status; + } + if let Err(status) = host + .resources + .require(token, ResourceKind::Widget, id.get()) + { + return status; + } + let Ok(mut state) = host.state.lock() else { + return PluginStatus::Internal; + }; + let Some(record) = state.widgets.get_mut(&id.get()) else { + return PluginStatus::StaleHandle; + }; + if record.disabled { + return PluginStatus::StaleHandle; + } + record.redraw = true; + PluginStatus::Ok +} + +pub unsafe extern "C" fn logical_size( + context: *mut c_void, + token: PluginToken, + id: WidgetId, + out_width: *mut f32, + out_height: *mut f32, +) -> PluginStatus { + if out_width.is_null() || out_height.is_null() { + return PluginStatus::InvalidArgument; + } + // SAFETY: ABI callers provide a live instance context. + let host = match unsafe { runtime(context) } { + Ok(host) => host, + Err(status) => return status, + }; + if let Err(status) = host.registry.require(token, CAP_WIDGET) { + return status; + } + if let Err(status) = host + .resources + .require(token, ResourceKind::Widget, id.get()) + { + return status; + } + let Ok(state) = host.state.lock() else { + return PluginStatus::Internal; + }; + let Some(record) = state.widgets.get(&id.get()) else { + return PluginStatus::StaleHandle; + }; + // SAFETY: The caller supplied two writable float pointers. + unsafe { + *out_width = record.logical_width; + *out_height = record.logical_height; + } + PluginStatus::Ok +} diff --git a/crates/winisland-plugin-package/Cargo.toml b/crates/winisland-plugin-package/Cargo.toml new file mode 100644 index 00000000..417c1c0a --- /dev/null +++ b/crates/winisland-plugin-package/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "winisland-plugin-package" +version = "0.8.0" +edition = "2024" +description = "Packaging, activation, and marketplace catalog support for WinIsland plugins" +repository = "https://github.com/WinIslandProject/WinIsland" +license = "GPL-3.0-only" + +[features] +default = [] +marketplace = ["dep:base64", "dep:dirs", "dep:image", "dep:log", "dep:reqwest", "dep:tokio"] + +[dependencies] +base64 = { version = "0.23.1", optional = true } +dirs = { version = "6.0", optional = true } +ed25519-dalek = { version = "2", features = ["pem", "pkcs8"] } +hex = "0.4" +image = { version = "0.25", optional = true } +log = { version = "0.4", optional = true } +reqwest = { version = "0.13.4", features = ["json"], optional = true } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0.149" +serde_yaml = "0.9" +sha2 = "0.11.0" +thiserror = "2" +tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"], optional = true } +zip = "8.6.0" diff --git a/src/plugin/zip_loader.rs b/crates/winisland-plugin-package/src/activate.rs similarity index 71% rename from src/plugin/zip_loader.rs rename to crates/winisland-plugin-package/src/activate.rs index 60b7b6fd..9bae847b 100644 --- a/src/plugin/zip_loader.rs +++ b/crates/winisland-plugin-package/src/activate.rs @@ -1,9 +1,10 @@ -use serde::Deserialize; use std::collections::HashSet; use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +use crate::manifest::PluginManifest; + const MAX_FILENAME_COMPONENT: usize = 255; const MAX_MANIFEST_BYTES: u64 = 1024 * 1024; pub const MAX_PLUGIN_ICON_BYTES: u64 = 4 * 1024 * 1024; @@ -12,110 +13,6 @@ const MAX_ZIP_ENTRIES: usize = 4096; const MAX_ENTRY_BYTES: u64 = 256 * 1024 * 1024; const MAX_TOTAL_BYTES: u64 = 512 * 1024 * 1024; static NEXT_STAGING_ID: AtomicU64 = AtomicU64::new(1); - -#[derive(Debug, Clone, Deserialize)] -pub struct PluginManifest { - pub id: String, - pub name: String, - pub author: String, - pub version: String, - pub description: String, - #[serde(rename = "github-link")] - pub github_link: String, - #[serde(rename = "abi-version")] - pub abi_version: u32, - pub entry: String, - #[serde(default)] - pub icon: Option, - #[serde(default)] - pub readme: Option, -} - -impl PluginManifest { - pub fn validate(&self) -> Result<(), String> { - if self.id.is_empty() - || self.id.len() > 63 - || !self - .id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') - { - return Err("'id' must be at most 63 bytes and match [a-zA-Z0-9_-]+".into()); - } - validate_text("name", &self.name, 127)?; - validate_text("author", &self.author, 127)?; - validate_text("version", &self.version, 31)?; - validate_text("description", &self.description, 255)?; - validate_text("github-link", &self.github_link, 2048)?; - if self.abi_version != winisland_plugin_api::ABI_VERSION_1 { - return Err(format!( - "Unsupported plugin ABI version {}", - self.abi_version - )); - } - let entry = Path::new(&self.entry); - if self.entry.is_empty() - || self.entry.len() > MAX_FILENAME_COMPONENT - || entry.components().count() != 1 - || !entry - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("dll")) - { - return Err("'entry' must be a root-level .dll filename".into()); - } - validate_asset_path( - "icon", - self.icon.as_deref(), - &["png", "jpg", "jpeg", "webp"], - )?; - validate_asset_path("readme", self.readme.as_deref(), &["md", "markdown", "txt"])?; - Ok(()) - } - - pub fn safe_dir_name(&self) -> &str { - &self.id - } -} - -fn validate_asset_path( - field: &str, - value: Option<&str>, - extensions: &[&str], -) -> Result<(), String> { - let Some(value) = value else { - return Ok(()); - }; - let path = Path::new(value); - if value.is_empty() - || value.len() > 512 - || path - .components() - .any(|component| !matches!(component, std::path::Component::Normal(_))) - || !path - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| { - extensions - .iter() - .any(|allowed| extension.eq_ignore_ascii_case(allowed)) - }) - { - return Err(format!("'{field}' must be a safe relative asset path")); - } - Ok(()) -} - -fn validate_text(field: &str, value: &str, max_bytes: usize) -> Result<(), String> { - if value.trim().is_empty() { - return Err(format!("'{field}' is empty")); - } - if value.len() > max_bytes { - return Err(format!("'{field}' exceeds {max_bytes} UTF-8 bytes")); - } - Ok(()) -} - fn validate_entry(entry: &zip::read::ZipFile<'_, std::fs::File>) -> Result { let name = entry.name(); if entry.is_symlink() { @@ -247,22 +144,28 @@ fn read_zip_entry( Ok(bytes) } -pub fn read_manifest_from_zip(zip_path: &Path) -> Result { +pub fn read_manifest_from_zip( + zip_path: &Path, + expected_abi: u32, +) -> Result { let file = std::fs::File::open(zip_path).map_err(|error| format!("Cannot open zip: {error}"))?; let mut zip = zip::ZipArchive::new(file).map_err(|error| format!("Invalid zip: {error}"))?; - read_manifest(&mut zip) + read_manifest(&mut zip, expected_abi) } -fn read_manifest(zip: &mut zip::ZipArchive) -> Result { +fn read_manifest( + zip: &mut zip::ZipArchive, + expected_abi: u32, +) -> Result { let bytes = read_zip_entry(zip, "plugin.yml", MAX_MANIFEST_BYTES)?; let manifest: PluginManifest = serde_yaml::from_slice(&bytes).map_err(|error| format!("Invalid plugin.yml: {error}"))?; - manifest.validate()?; + manifest.validate(expected_abi)?; Ok(manifest) } -pub fn read_manifest_file(path: &Path) -> Result { +pub fn read_manifest_file(path: &Path, expected_abi: u32) -> Result { let metadata = std::fs::metadata(path) .map_err(|error| format!("Cannot inspect '{}': {error}", path.display()))?; if metadata.len() > MAX_MANIFEST_BYTES { @@ -272,7 +175,7 @@ pub fn read_manifest_file(path: &Path) -> Result { .map_err(|error| format!("Cannot read '{}': {error}", path.display()))?; let manifest: PluginManifest = serde_yaml::from_slice(&bytes) .map_err(|error| format!("Invalid '{}': {error}", path.display()))?; - manifest.validate()?; + manifest.validate(expected_abi)?; Ok(manifest) } @@ -288,11 +191,12 @@ pub fn read_bounded_file(path: &Path, max_bytes: u64) -> Result, String> pub fn extract_plugin( zip_path: &Path, plugin_dir: &Path, + expected_abi: u32, ) -> Result<(PluginManifest, PathBuf), String> { let file = std::fs::File::open(zip_path).map_err(|error| format!("Cannot open zip: {error}"))?; let mut zip = zip::ZipArchive::new(file).map_err(|error| format!("Invalid zip: {error}"))?; - let manifest = read_manifest(&mut zip)?; + let manifest = read_manifest(&mut zip, expected_abi)?; validate_archive(&mut zip, &manifest)?; std::fs::create_dir_all(plugin_dir) diff --git a/crates/winisland-plugin-package/src/lib.rs b/crates/winisland-plugin-package/src/lib.rs new file mode 100644 index 00000000..35e8e8c3 --- /dev/null +++ b/crates/winisland-plugin-package/src/lib.rs @@ -0,0 +1,6 @@ +pub mod activate; +pub mod manifest; +#[cfg(feature = "marketplace")] +pub mod marketplace; +pub mod packaging; +pub mod signing; diff --git a/crates/winisland-plugin-api/src/packager/manifest.rs b/crates/winisland-plugin-package/src/manifest.rs similarity index 90% rename from crates/winisland-plugin-api/src/packager/manifest.rs rename to crates/winisland-plugin-package/src/manifest.rs index 41da03fa..c761c817 100644 --- a/crates/winisland-plugin-api/src/packager/manifest.rs +++ b/crates/winisland-plugin-package/src/manifest.rs @@ -60,21 +60,12 @@ impl PluginManifest { } /// Compute a safe directory name from the plugin name. - pub fn safe_dir_name(&self) -> String { - self.id - .chars() - .map(|c| { - if c.is_alphanumeric() || c == '-' || c == '_' { - c - } else { - '_' - } - }) - .collect() + pub fn safe_dir_name(&self) -> &str { + &self.id } /// Validate required fields are non-empty. - pub fn validate(&self) -> Result<(), String> { + pub fn validate(&self, expected_abi: u32) -> Result<(), String> { if self.id.is_empty() || self.id.len() > 63 || !self @@ -89,8 +80,11 @@ impl PluginManifest { validate_text("version", &self.version, 31)?; validate_text("description", &self.description, 255)?; validate_text("github-link", &self.github_link, 2048)?; - if self.abi_version != crate::ABI_VERSION_1 { - return Err(format!("'abi-version' must be {}", crate::ABI_VERSION_1)); + if self.abi_version != expected_abi { + return Err(format!( + "Unsupported plugin ABI version {}; expected {expected_abi}", + self.abi_version + )); } let entry = Path::new(&self.entry); if self.entry.is_empty() diff --git a/src/plugin/marketplace.rs b/crates/winisland-plugin-package/src/marketplace.rs similarity index 77% rename from src/plugin/marketplace.rs rename to crates/winisland-plugin-package/src/marketplace.rs index bfa0d043..e7f41235 100644 --- a/src/plugin/marketplace.rs +++ b/crates/winisland-plugin-package/src/marketplace.rs @@ -1,6 +1,6 @@ use std::cmp::Ordering; use std::collections::HashSet; -use std::io::{Read, Write}; +use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; use std::sync::{Arc, LazyLock}; @@ -14,8 +14,13 @@ use sha2::{Digest, Sha256}; use tokio::sync::Semaphore; use tokio::task::JoinSet; -use crate::plugin::zip_loader; -use winisland_core::config::APP_VERSION; +use crate::activate as zip_loader; + +mod cache; +mod version; + +use cache::{hash_file, hex_digest, marketplace_cache_dir, safe_version_component, sha256_hex}; +use version::compare_versions; const CATALOG_URL: &str = "https://github.com/WinIslandProject/PluginMarketplace/releases/download/catalog-v1/catalog-v1.json"; const SIGNATURE_URL: &str = "https://github.com/WinIslandProject/PluginMarketplace/releases/download/catalog-v1/catalog-v1.sig"; @@ -65,8 +70,8 @@ pub struct MarketplacePlugin { } impl MarketplacePlugin { - pub fn is_compatible(&self) -> bool { - compare_versions(APP_VERSION, &self.min_winisland_version) + pub fn is_compatible(&self, app_version: &str) -> bool { + compare_versions(app_version, &self.min_winisland_version) .is_some_and(|ordering| ordering != Ordering::Less) } @@ -111,16 +116,19 @@ struct CatalogRevocation { reason: String, } -pub async fn load_catalog() -> Result { +pub async fn load_catalog(expected_abi: u32) -> Result { let catalog_bytes = download_bytes(CATALOG_URL, MAX_CATALOG_BYTES).await?; let signature_bytes = download_bytes(SIGNATURE_URL, MAX_SIGNATURE_BYTES).await?; verify_catalog_signature(&catalog_bytes, &signature_bytes)?; let document: CatalogDocument = serde_json::from_slice(&catalog_bytes) .map_err(|error| format!("The marketplace catalog is invalid: {error}"))?; - validate_catalog(document).await + validate_catalog(document, expected_abi).await } -pub async fn download_plugin(plugin: &MarketplacePlugin) -> Result { +pub async fn download_plugin( + plugin: &MarketplacePlugin, + expected_abi: u32, +) -> Result { validate_github_release_url(&plugin.download_url)?; validate_sha256(&plugin.sha256)?; if plugin.size == 0 || plugin.size > MAX_PACKAGE_BYTES { @@ -137,14 +145,14 @@ pub async fn download_plugin(plugin: &MarketplacePlugin) -> Result Result Result { +async fn validate_catalog( + document: CatalogDocument, + expected_abi: u32, +) -> Result { if document.schema != 1 { return Err(format!( "Unsupported marketplace catalog schema {}", @@ -185,6 +196,14 @@ async fn validate_catalog(document: CatalogDocument) -> Result Result<(), String> { if entry.size == 0 || entry.size > MAX_PACKAGE_BYTES { return Err(format!("Plugin '{}' has an invalid package size", entry.id)); } - if entry.abi_version != winisland_plugin_api::ABI_VERSION_1 { - return Err(format!( - "Plugin '{}' requires unsupported ABI version {}", - entry.id, entry.abi_version - )); - } validate_version(&entry.min_winisland_version)?; if entry.categories.len() > 16 || entry.categories.iter().any(|category| { @@ -463,8 +476,12 @@ fn validate_icon(plugin_id: &str, bytes: &[u8]) -> Result<(), String> { Ok(()) } -fn validate_package_manifest(plugin: &MarketplacePlugin, path: &Path) -> Result<(), String> { - let manifest = zip_loader::read_manifest_from_zip(path)?; +fn validate_package_manifest( + plugin: &MarketplacePlugin, + path: &Path, + expected_abi: u32, +) -> Result<(), String> { + let manifest = zip_loader::read_manifest_from_zip(path, expected_abi)?; let metadata_matches = manifest.id == plugin.id && manifest.name == plugin.name && manifest.author == plugin.author @@ -557,149 +574,3 @@ fn validate_marketplace_asset_url(value: &str) -> Result<(), String> { } Ok(()) } - -fn compare_versions(left: &str, right: &str) -> Option { - let left = ParsedVersion::parse(left)?; - let right = ParsedVersion::parse(right)?; - Some(left.cmp(&right)) -} - -#[derive(Eq, PartialEq)] -struct ParsedVersion<'a> { - core: [u64; 4], - pre_release: Option>, -} - -impl<'a> ParsedVersion<'a> { - fn parse(value: &'a str) -> Option { - let (value, build) = value - .split_once('+') - .map_or((value, None), |(value, build)| (value, Some(build))); - if build.is_some_and(|build| !valid_version_identifiers(build, false)) { - return None; - } - let (core, pre_release) = value - .split_once('-') - .map_or((value, None), |(core, pre)| (core, Some(pre))); - let parts = core.split('.').collect::>(); - if !(2..=4).contains(&parts.len()) || pre_release == Some("") { - return None; - } - let mut numbers = [0; 4]; - for (index, part) in parts.into_iter().enumerate() { - if part.is_empty() || (part.len() > 1 && part.starts_with('0')) { - return None; - } - numbers[index] = part.parse().ok()?; - } - let pre_release = pre_release - .filter(|value| valid_version_identifiers(value, true)) - .map(|value| value.split('.').collect::>()); - if value.contains('-') && pre_release.is_none() { - return None; - } - Some(Self { - core: numbers, - pre_release, - }) - } -} - -fn valid_version_identifiers(value: &str, reject_numeric_leading_zero: bool) -> bool { - value.split('.').all(|part| { - !part.is_empty() - && part - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') - && (!reject_numeric_leading_zero - || !part.bytes().all(|byte| byte.is_ascii_digit()) - || part.len() == 1 - || !part.starts_with('0')) - }) -} - -impl Ord for ParsedVersion<'_> { - fn cmp(&self, other: &Self) -> Ordering { - self.core - .cmp(&other.core) - .then_with(|| match (&self.pre_release, &other.pre_release) { - (None, None) => Ordering::Equal, - (None, Some(_)) => Ordering::Greater, - (Some(_), None) => Ordering::Less, - (Some(left), Some(right)) => compare_pre_release(left, right), - }) - } -} - -impl PartialOrd for ParsedVersion<'_> { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -fn compare_pre_release(left: &[&str], right: &[&str]) -> Ordering { - for (left, right) in left.iter().zip(right) { - let ordering = match (left.parse::(), right.parse::()) { - (Ok(left), Ok(right)) => left.cmp(&right), - (Ok(_), Err(_)) => Ordering::Less, - (Err(_), Ok(_)) => Ordering::Greater, - (Err(_), Err(_)) => left.cmp(right), - }; - if ordering != Ordering::Equal { - return ordering; - } - } - left.len().cmp(&right.len()) -} - -fn marketplace_cache_dir() -> PathBuf { - dirs::cache_dir() - .or_else(dirs::data_local_dir) - .unwrap_or_else(std::env::temp_dir) - .join("WinIsland") - .join("PluginMarketplace") -} - -fn safe_version_component(version: &str) -> String { - version - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { - character - } else { - '_' - } - }) - .collect() -} - -fn hash_file(path: &Path) -> Result { - let mut file = std::fs::File::open(path) - .map_err(|error| format!("Could not open the cached plugin: {error}"))?; - let mut hasher = Sha256::new(); - let mut buffer = [0_u8; 64 * 1024]; - loop { - let read = file - .read(&mut buffer) - .map_err(|error| format!("Could not verify the cached plugin: {error}"))?; - if read == 0 { - break; - } - hasher.update(&buffer[..read]); - } - Ok(hex_digest(hasher.finalize().as_slice())) -} - -fn sha256_hex(bytes: &[u8]) -> String { - hex_digest(Sha256::digest(bytes).as_slice()) -} - -fn hex_digest(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut output = String::with_capacity(bytes.len() * 2); - for byte in bytes { - output.push(HEX[(byte >> 4) as usize] as char); - output.push(HEX[(byte & 0x0f) as usize] as char); - } - output -} diff --git a/crates/winisland-plugin-package/src/marketplace/cache.rs b/crates/winisland-plugin-package/src/marketplace/cache.rs new file mode 100644 index 00000000..924bc9f0 --- /dev/null +++ b/crates/winisland-plugin-package/src/marketplace/cache.rs @@ -0,0 +1,56 @@ +use std::io::Read; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +pub(super) fn marketplace_cache_dir() -> PathBuf { + dirs::cache_dir() + .or_else(dirs::data_local_dir) + .unwrap_or_else(std::env::temp_dir) + .join("WinIsland") + .join("PluginMarketplace") +} + +pub(super) fn safe_version_component(version: &str) -> String { + version + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { + character + } else { + '_' + } + }) + .collect() +} + +pub(super) fn hash_file(path: &Path) -> Result { + let mut file = std::fs::File::open(path) + .map_err(|error| format!("Could not open the cached plugin: {error}"))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .map_err(|error| format!("Could not verify the cached plugin: {error}"))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hex_digest(hasher.finalize().as_slice())) +} + +pub(super) fn sha256_hex(bytes: &[u8]) -> String { + hex_digest(Sha256::digest(bytes).as_slice()) +} + +pub(super) fn hex_digest(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} diff --git a/crates/winisland-plugin-package/src/marketplace/version.rs b/crates/winisland-plugin-package/src/marketplace/version.rs new file mode 100644 index 00000000..3cf1cc01 --- /dev/null +++ b/crates/winisland-plugin-package/src/marketplace/version.rs @@ -0,0 +1,95 @@ +use std::cmp::Ordering; + +pub(super) fn compare_versions(left: &str, right: &str) -> Option { + let left = ParsedVersion::parse(left)?; + let right = ParsedVersion::parse(right)?; + Some(left.cmp(&right)) +} + +#[derive(Eq, PartialEq)] +struct ParsedVersion<'a> { + core: [u64; 4], + pre_release: Option>, +} + +impl<'a> ParsedVersion<'a> { + fn parse(value: &'a str) -> Option { + let (value, build) = value + .split_once('+') + .map_or((value, None), |(value, build)| (value, Some(build))); + if build.is_some_and(|build| !valid_version_identifiers(build, false)) { + return None; + } + let (core, pre_release) = value + .split_once('-') + .map_or((value, None), |(core, pre)| (core, Some(pre))); + let parts = core.split('.').collect::>(); + if !(2..=4).contains(&parts.len()) || pre_release == Some("") { + return None; + } + let mut numbers = [0; 4]; + for (index, part) in parts.into_iter().enumerate() { + if part.is_empty() || (part.len() > 1 && part.starts_with('0')) { + return None; + } + numbers[index] = part.parse().ok()?; + } + let pre_release = pre_release + .filter(|value| valid_version_identifiers(value, true)) + .map(|value| value.split('.').collect::>()); + if value.contains('-') && pre_release.is_none() { + return None; + } + Some(Self { + core: numbers, + pre_release, + }) + } +} + +fn valid_version_identifiers(value: &str, reject_numeric_leading_zero: bool) -> bool { + value.split('.').all(|part| { + !part.is_empty() + && part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + && (!reject_numeric_leading_zero + || !part.bytes().all(|byte| byte.is_ascii_digit()) + || part.len() == 1 + || !part.starts_with('0')) + }) +} + +impl Ord for ParsedVersion<'_> { + fn cmp(&self, other: &Self) -> Ordering { + self.core + .cmp(&other.core) + .then_with(|| match (&self.pre_release, &other.pre_release) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(left), Some(right)) => compare_pre_release(left, right), + }) + } +} + +impl PartialOrd for ParsedVersion<'_> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +fn compare_pre_release(left: &[&str], right: &[&str]) -> Ordering { + for (left, right) in left.iter().zip(right) { + let ordering = match (left.parse::(), right.parse::()) { + (Ok(left), Ok(right)) => left.cmp(&right), + (Ok(_), Err(_)) => Ordering::Less, + (Err(_), Ok(_)) => Ordering::Greater, + (Err(_), Err(_)) => left.cmp(right), + }; + if ordering != Ordering::Equal { + return ordering; + } + } + left.len().cmp(&right.len()) +} diff --git a/crates/winisland-plugin-api/src/packager/packaging.rs b/crates/winisland-plugin-package/src/packaging.rs similarity index 100% rename from crates/winisland-plugin-api/src/packager/packaging.rs rename to crates/winisland-plugin-package/src/packaging.rs diff --git a/crates/winisland-plugin-api/src/packager/signing.rs b/crates/winisland-plugin-package/src/signing.rs similarity index 100% rename from crates/winisland-plugin-api/src/packager/signing.rs rename to crates/winisland-plugin-package/src/signing.rs diff --git a/crates/winisland-render/src/lib.rs b/crates/winisland-render/src/lib.rs index 5d526121..773fbda5 100644 --- a/crates/winisland-render/src/lib.rs +++ b/crates/winisland-render/src/lib.rs @@ -21,7 +21,6 @@ pub use image::Image; pub use painter::Painter; pub use path::{Path, PathBuilder}; #[doc(hidden)] -pub use skia_safe as plugin_v1_backend; pub use surface::{NativeSurface, RasterSurface, SURFACE_TAG_WIN32_HWND}; pub use types::{ Angle, BlurSpec, FontStyle, FontWeight, FontWidth, GradientStop, ImageFit, ImageOptions, diff --git a/crates/winisland-render/src/painter.rs b/crates/winisland-render/src/painter.rs index c688acf1..710138c0 100644 --- a/crates/winisland-render/src/painter.rs +++ b/crates/winisland-render/src/painter.rs @@ -1,4 +1,4 @@ -use skia_safe::{Canvas, ClipOp, image_filters}; +use skia_safe::{Canvas, ClipOp, Matrix, image_filters}; use crate::convert::{ filled, stroked, to_skia_cap, to_skia_gradient, to_skia_join, to_skia_point, to_skia_rect, @@ -41,6 +41,19 @@ impl<'a> Painter<'a> { self.canvas.scale((factor.x, factor.y)); } + pub fn concat_affine(&self, affine: [f32; 6]) { + let [a, b, c, d, e, f] = affine; + self.canvas + .concat(&Matrix::new_all(a, c, e, b, d, f, 0.0, 0.0, 1.0)); + } + + pub fn save_alpha(&self, alpha: u8) { + let mut paint = skia_safe::Paint::default(); + paint.set_alpha(alpha); + self.canvas + .save_layer(&skia_safe::canvas::SaveLayerRec::default().paint(&paint)); + } + pub fn rotate_degrees(&self, degrees: f32) { self.canvas.rotate(degrees, None); } @@ -230,10 +243,6 @@ impl<'a> Painter<'a> { .draw_image(image.as_skia(), to_skia_point(position), None); } - pub fn plugin_canvas_handle_v1(&self) -> *mut std::ffi::c_void { - self.canvas as *const Canvas as *mut std::ffi::c_void - } - pub fn draw_image(&self, image: &Image, dst: Rect, options: &ImageOptions) { let (width, height) = image.dimensions(); if width <= 0 || height <= 0 { diff --git a/crates/winisland-render/src/text/mod.rs b/crates/winisland-render/src/text/mod.rs index 72b18834..3eff0dcf 100644 --- a/crates/winisland-render/src/text/mod.rs +++ b/crates/winisland-render/src/text/mod.rs @@ -10,6 +10,9 @@ use skia_safe::{Font, FontMgr, FontStyle as SkFontStyle, Paint, Path, Typeface, use crate::painter::Painter; use crate::types::{BlurSpec, FontStyle, Rgba, Slant}; +mod plugin; +pub use plugin::{PluginTextMetrics, PluginTextParams, PluginTextRun}; + static GLOBAL_FONT_MANAGER: OnceLock = OnceLock::new(); type TextGroup = (String, Typeface, bool, f32); @@ -362,18 +365,6 @@ impl FontManager { painter.canvas().draw_str(text, (at.x, at.y), &font, &paint); } - pub fn draw_plugin_str_v1( - &self, - canvas: &skia_safe::Canvas, - text: &str, - at: crate::types::Point, - size: f32, - bold: bool, - color: Rgba, - ) { - self.draw_str(Painter { canvas }, text, at, size, bold, color); - } - pub fn draw_text_in_rect(&self, params: DrawTextInRectParams<'_>) { let font = self.get_font(params.size, params.bold); let paint = text_paint(params.color, params.blur); diff --git a/crates/winisland-render/src/text/plugin.rs b/crates/winisland-render/src/text/plugin.rs new file mode 100644 index 00000000..9a158168 --- /dev/null +++ b/crates/winisland-render/src/text/plugin.rs @@ -0,0 +1,238 @@ +use std::collections::HashMap; + +use skia_safe::{Font, FontStyle as SkFontStyle}; + +use super::{FONT_MGR, FontManager, get_custom_typeface, text_paint}; +use crate::painter::Painter; +use crate::types::{Rect, Rgba}; + +#[derive(Clone, Copy)] +pub struct PluginTextMetrics { + pub width: f32, + pub height: f32, + pub ascent: f32, + pub descent: f32, +} + +#[derive(Clone, Copy)] +pub struct PluginTextParams<'a> { + pub painter: Painter<'a>, + pub rect: Rect, + pub size: f32, + pub italic: bool, + pub family: &'a str, + pub align: u8, + pub wrap: bool, + pub ellipsis: bool, +} + +#[derive(Clone, Copy)] +pub struct PluginTextRun<'a> { + pub text: &'a str, + pub weight: u16, + pub color: Rgba, +} + +#[derive(Clone, Copy)] +struct Glyph { + character: char, + weight: u16, + color: Rgba, + width: f32, +} + +impl FontManager { + fn plugin_font(&self, size: f32, weight: u16, italic: bool, family: &str) -> Option { + let slant = if italic { + skia_safe::font_style::Slant::Italic + } else { + skia_safe::font_style::Slant::Upright + }; + let style = SkFontStyle::new(i32::from(weight).into(), 5.into(), slant); + let typeface = if family.is_empty() { + get_custom_typeface() + } else { + None + } + .or_else(|| { + FONT_MGR.with(|manager| { + (!family.is_empty()) + .then(|| manager.match_family_style(family, style)) + .flatten() + .or_else(|| manager.match_family_style("Microsoft YaHei", style)) + .or_else(|| manager.match_family_style("Segoe UI", style)) + .or_else(|| manager.legacy_make_typeface(None, style)) + }) + })?; + let mut font = Font::from_typeface(typeface.clone(), size); + font.set_subpixel(true); + if weight >= 600 && *typeface.font_style().weight() < 600 { + font.set_embolden(true); + } + if italic && typeface.font_style().slant() == skia_safe::font_style::Slant::Upright { + font.set_skew_x(-0.25); + } + Some(font) + } + + pub fn measure_plugin_text( + &self, + text: &str, + size: f32, + weight: u16, + italic: bool, + family: &str, + ) -> Option { + let font = self.plugin_font(size, weight, italic, family)?; + let (width, _) = font.measure_str(text, None); + let (_, metrics) = font.metrics(); + Some(PluginTextMetrics { + width, + height: metrics.descent - metrics.ascent, + ascent: -metrics.ascent, + descent: metrics.descent, + }) + } + + pub fn plugin_font_family(&self, index: u32) -> Option { + FONT_MGR.with(|manager| { + ((index as usize) < manager.count_families()) + .then(|| manager.family_name(index as usize)) + }) + } + + pub fn draw_plugin_text( + &self, + params: PluginTextParams<'_>, + text: &str, + weight: u16, + color: Rgba, + ) -> bool { + self.draw_plugin_runs( + params, + &[PluginTextRun { + text, + weight, + color, + }], + ) + } + + pub fn draw_plugin_runs( + &self, + params: PluginTextParams<'_>, + runs: &[PluginTextRun<'_>], + ) -> bool { + if params.rect.width() <= 0.0 || params.rect.height() <= 0.0 { + return true; + } + let Some(base_font) = self.plugin_font(params.size, 400, params.italic, params.family) + else { + return false; + }; + let (_, metrics) = base_font.metrics(); + let line_height = (metrics.descent - metrics.ascent).max(params.size * 1.1); + let max_lines = ((params.rect.height() / line_height).ceil() as usize).clamp(1, 256); + let mut fonts = HashMap::new(); + fonts.insert(400_u16, base_font); + let mut lines: Vec> = vec![Vec::new()]; + let mut widths = vec![0.0_f32]; + let mut truncated = false; + 'runs: for run in runs { + if let std::collections::hash_map::Entry::Vacant(entry) = fonts.entry(run.weight) { + let Some(font) = + self.plugin_font(params.size, run.weight, params.italic, params.family) + else { + return false; + }; + entry.insert(font); + } + let font = &fonts[&run.weight]; + for character in run.text.chars() { + if character == '\n' { + if lines.len() >= max_lines { + truncated = true; + break 'runs; + } + lines.push(Vec::new()); + widths.push(0.0); + continue; + } + let mut buffer = [0_u8; 4]; + let width = font.measure_str(character.encode_utf8(&mut buffer), None).0; + let index = lines.len() - 1; + if widths[index] + width > params.rect.width() && !lines[index].is_empty() { + if !params.wrap || lines.len() >= max_lines { + truncated = true; + break 'runs; + } + lines.push(Vec::new()); + widths.push(0.0); + } + let index = lines.len() - 1; + widths[index] += width; + lines[index].push(Glyph { + character, + weight: run.weight, + color: run.color, + width, + }); + } + } + if truncated && params.ellipsis { + let index = lines.len() - 1; + let weight = lines[index].last().map_or(400, |glyph| glyph.weight); + let color = lines[index].last().map_or(Rgba::WHITE, |glyph| glyph.color); + let ellipsis_width = fonts[&weight].measure_str("…", None).0; + while widths[index] + ellipsis_width > params.rect.width() { + let Some(glyph) = lines[index].pop() else { + break; + }; + widths[index] -= glyph.width; + } + if ellipsis_width <= params.rect.width() { + lines[index].push(Glyph { + character: '…', + weight, + color, + width: ellipsis_width, + }); + widths[index] += ellipsis_width; + } + } + params.painter.save(); + params.painter.clip_rect(params.rect); + for (index, line) in lines.iter().enumerate() { + let mut x = match params.align { + 1 => params.rect.left + (params.rect.width() - widths[index]) * 0.5, + 2 => params.rect.right - widths[index], + _ => params.rect.left, + }; + let baseline = params.rect.top - metrics.ascent + index as f32 * line_height; + let mut start = 0; + while start < line.len() { + let weight = line[start].weight; + let color = line[start].color; + let mut end = start + 1; + while end < line.len() && line[end].weight == weight && line[end].color == color { + end += 1; + } + let value: String = line[start..end] + .iter() + .map(|glyph| glyph.character) + .collect(); + let font = &fonts[&weight]; + params.painter.canvas().draw_str( + &value, + (x, baseline), + font, + &text_paint(color, None), + ); + x += font.measure_str(&value, None).0; + start = end; + } + } + params.painter.restore(); + true + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index dc25d95b..3c94a137 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,4 +1,3 @@ pub mod audio; pub mod persistence; -pub mod plugin_settings; pub mod smtc; diff --git a/src/core/smtc.rs b/src/core/smtc.rs index 830c6552..21448324 100644 --- a/src/core/smtc.rs +++ b/src/core/smtc.rs @@ -195,6 +195,7 @@ impl SmtcListener { local_dir: Option, allowed: Vec, known_apps: Vec, + lyrics_bridge: Option, ) -> Self { let (info_tx, info_rx) = watch::channel(MediaInfo::default()); let (enabled_tx, enabled_rx) = watch::channel(enabled); @@ -227,6 +228,7 @@ impl SmtcListener { allowed_apps_rx, wake_rx, known_apps, + lyrics_bridge, }, cancel, ); @@ -335,6 +337,14 @@ pub(super) struct LyricsFetchRequest { pub(super) request_id: u64, } +#[derive(Clone, Copy)] +pub(super) struct LyricsFetchConfig<'a> { + pub(super) mode: LyricsMode, + pub(super) source: &'a str, + pub(super) local_dir: Option<&'a str>, + pub(super) bridge: Option<&'a winisland_plugin_host::lifecycle::LyricsBridge>, +} + impl LyricsFetchRequest { fn matches(&self, media: &MediaInfo) -> bool { media.title == self.title @@ -343,7 +353,11 @@ impl LyricsFetchRequest { } } -pub(super) fn spawn_lyrics_fetch(info_tx: &watch::Sender, request: LyricsFetchRequest) { +pub(super) fn spawn_lyrics_fetch( + info_tx: &watch::Sender, + request: LyricsFetchRequest, + lyrics_bridge: Option, +) { let info_tx = info_tx.clone(); tokio::spawn(async move { let lyrics = if request.mode == LyricsMode::Lrc { @@ -371,7 +385,14 @@ pub(super) fn spawn_lyrics_fetch(info_tx: &watch::Sender, request: Ly ) .await }; - let lyrics = lyrics.map(crate::plugin::manager::apply_lyrics_transforms); + let lyrics = match (lyrics, lyrics_bridge) { + (Some(lyrics), Some(bridge)) => { + tokio::task::spawn_blocking(move || bridge.apply(lyrics)) + .await + .ok() + } + (lyrics, _) => lyrics, + }; let applied = info_tx.send_if_modified(|current| { if !request.matches(current) { return false; diff --git a/src/core/smtc/properties.rs b/src/core/smtc/properties.rs index 4393e5bb..92be09f8 100644 --- a/src/core/smtc/properties.rs +++ b/src/core/smtc/properties.rs @@ -6,9 +6,7 @@ use std::time::{Duration, Instant}; use tokio::sync::watch; use winisland_platform::{MediaSessionHandle, PlatformError, ThumbnailError, TrackInfo}; -use winisland_core::lyrics::LyricsMode; - -use super::{LyricsFetchRequest, MediaInfo, spawn_lyrics_fetch}; +use super::{LyricsFetchConfig, LyricsFetchRequest, MediaInfo, spawn_lyrics_fetch}; const TIMELINE_REFRESH_INTERVAL: Duration = Duration::from_millis(500); const THUMBNAIL_RETRY_INTERVAL: Duration = Duration::from_secs(5); @@ -183,9 +181,7 @@ struct PendingMediaRequests { pub(super) fn fetch_properties( session: &Arc, info_tx: &watch::Sender, - lyrics_mode: LyricsMode, - lyrics_source: &str, - local_dir: Option<&str>, + lyrics: LyricsFetchConfig<'_>, thumbnail_fetcher: Option<&ThumbnailFetcher>, timeline_cache: &mut TimelineCache, ) -> Result<(), PlatformError> { @@ -248,11 +244,12 @@ pub(super) fn fetch_properties( title, artist, duration_secs: timeline.duration_secs, - mode: lyrics_mode, - source: lyrics_source.to_string(), - local_dir: local_dir.map(str::to_string), + mode: lyrics.mode, + source: lyrics.source.to_string(), + local_dir: lyrics.local_dir.map(str::to_string), request_id, }, + lyrics.bridge.cloned(), ); } Ok(()) diff --git a/src/core/smtc/worker.rs b/src/core/smtc/worker.rs index 48eb4f5f..d4e4026c 100644 --- a/src/core/smtc/worker.rs +++ b/src/core/smtc/worker.rs @@ -8,7 +8,9 @@ use winisland_core::lyrics::LyricsMode; use super::properties::{ThumbnailFetcher, TimelineCache, fetch_properties}; use super::session::{auto_allow_new_apps, get_target_session}; -use super::{LyricsFetchRequest, MediaInfo, PlaybackCommand, spawn_lyrics_fetch}; +use super::{ + LyricsFetchConfig, LyricsFetchRequest, MediaInfo, PlaybackCommand, spawn_lyrics_fetch, +}; pub(super) struct WorkerChannels { pub(super) info_tx: watch::Sender, @@ -21,6 +23,7 @@ pub(super) struct WorkerChannels { pub(super) allowed_apps_rx: mpsc::UnboundedReceiver>, pub(super) wake_rx: std::sync::mpsc::Receiver<()>, pub(super) known_apps: Vec, + pub(super) lyrics_bridge: Option, } pub(super) fn smtc_poll_loop(channels: WorkerChannels, cancel: CancellationToken) { @@ -35,6 +38,7 @@ pub(super) fn smtc_poll_loop(channels: WorkerChannels, cancel: CancellationToken mut allowed_apps_rx, wake_rx, known_apps, + lyrics_bridge, } = channels; let manager = match crate::platform::media().open_context() { Ok(manager) => manager, @@ -81,9 +85,12 @@ pub(super) fn smtc_poll_loop(channels: WorkerChannels, cancel: CancellationToken media_state.update( manager.as_ref(), &info_tx, - current_lyrics_mode, - ¤t_lyrics_source, - current_lyrics_local_dir.as_deref(), + LyricsFetchConfig { + mode: current_lyrics_mode, + source: ¤t_lyrics_source, + local_dir: current_lyrics_local_dir.as_deref(), + bridge: lyrics_bridge.as_ref(), + }, true, ); let info = info_tx.borrow(); @@ -134,6 +141,7 @@ pub(super) fn smtc_poll_loop(channels: WorkerChannels, cancel: CancellationToken current_lyrics_mode, ¤t_lyrics_source, current_lyrics_local_dir.as_deref(), + lyrics_bridge.as_ref(), ); } while let Ok(apps) = allowed_apps_rx.try_recv() { @@ -199,9 +207,12 @@ pub(super) fn smtc_poll_loop(channels: WorkerChannels, cancel: CancellationToken media_state.update( manager.as_ref(), &info_tx, - current_lyrics_mode, - ¤t_lyrics_source, - current_lyrics_local_dir.as_deref(), + LyricsFetchConfig { + mode: current_lyrics_mode, + source: ¤t_lyrics_source, + local_dir: current_lyrics_local_dir.as_deref(), + bridge: lyrics_bridge.as_ref(), + }, true, ); last_regular_update = Instant::now(); @@ -213,9 +224,12 @@ pub(super) fn smtc_poll_loop(channels: WorkerChannels, cancel: CancellationToken media_state.update( manager.as_ref(), &info_tx, - current_lyrics_mode, - ¤t_lyrics_source, - current_lyrics_local_dir.as_deref(), + LyricsFetchConfig { + mode: current_lyrics_mode, + source: ¤t_lyrics_source, + local_dir: current_lyrics_local_dir.as_deref(), + bridge: lyrics_bridge.as_ref(), + }, do_auto_allow, ); last_regular_update = Instant::now(); @@ -274,9 +288,7 @@ impl MediaUpdateState { &mut self, manager: &dyn MediaContext, info_tx: &watch::Sender, - lyrics_mode: LyricsMode, - lyrics_source: &str, - local_dir: Option<&str>, + lyrics: LyricsFetchConfig<'_>, auto_allow: bool, ) { if auto_allow { @@ -288,9 +300,7 @@ impl MediaUpdateState { match fetch_properties( &session, info_tx, - lyrics_mode, - lyrics_source, - local_dir, + lyrics, self.thumbnail_fetcher.as_ref(), &mut self.timeline_cache, ) { @@ -338,6 +348,7 @@ fn refresh_current_lyrics( lyrics_mode: LyricsMode, lyrics_source: &str, local_dir: Option<&str>, + lyrics_bridge: Option<&winisland_plugin_host::lifecycle::LyricsBridge>, ) { let mut request = None; info_tx.send_if_modified(|info| { @@ -368,5 +379,6 @@ fn refresh_current_lyrics( local_dir: local_dir.map(str::to_string), request_id, }, + lyrics_bridge.cloned(), ); } diff --git a/src/main.rs b/src/main.rs index e93d0d88..15b1dd7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,7 +31,6 @@ fn main() { platform::update_capabilities(|caps| caps.autostart = false); log::warn!("Autostart is unavailable: {error}"); } - logger::check_crash_flag(); set_system_locale_provider(platform::system_locale); winisland_core::lyrics::set_simplify_hook(platform::to_simplified); init_i18n(&config.language); @@ -50,6 +49,7 @@ fn main() { let Some(_instance_mutex) = acquire_instance_mutex(restart_requested) else { return; }; + logger::check_crash_flag(); let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) diff --git a/src/plugin/inventory.rs b/src/plugin/inventory.rs new file mode 100644 index 00000000..89317827 --- /dev/null +++ b/src/plugin/inventory.rs @@ -0,0 +1,281 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; + +use winisland_plugin_api::abi::ABI_VERSION_2; +use winisland_plugin_host::fault::{disabled_plugin_ids, set_plugin_disabled}; +use winisland_plugin_host::loader::PluginLibrary; +use winisland_plugin_package::activate::{ + MAX_PLUGIN_ICON_BYTES, MAX_PLUGIN_README_BYTES, read_bounded_file, read_manifest_file, +}; + +#[derive(Clone)] +pub struct InstalledPlugin { + pub id: String, + pub name: String, + pub author: String, + pub version: String, + pub description: String, + pub github_link: String, + pub enabled: bool, + pub icon: Option>, + pub readme: Option, +} + +pub struct PluginManager { + pub(crate) plugin_dir: PathBuf, +} + +impl PluginManager { + pub fn new>(plugin_dir: P) -> Self { + let plugin_dir = plugin_dir.as_ref().to_path_buf(); + let _ = std::fs::create_dir_all(&plugin_dir); + Self { plugin_dir } + } + + pub fn installed_plugins(&self) -> Vec { + scan(&self.plugin_dir) + } + + pub fn installed_plugins_async(&self) -> mpsc::Receiver> { + let directory = self.plugin_dir.clone(); + let (tx, rx) = mpsc::channel(); + let result = std::thread::Builder::new() + .name("winisland-plugin-scan".to_string()) + .spawn(move || { + let _ = tx.send(scan(&directory)); + crate::platform::wake(); + }); + if let Err(error) = result { + log::warn!("Failed to start plugin scan: {error}"); + } + rx + } + + pub fn set_plugin_enabled(&self, id: &str, enabled: bool) -> Result<(), String> { + validate_id(id)?; + if !self + .installed_plugins() + .iter() + .any(|plugin| plugin.id == id) + { + return Err(format!("Plugin '{id}' is not installed")); + } + set_plugin_disabled(&self.plugin_dir, id, !enabled).map_err(|error| error.to_string()) + } + + pub fn uninstall_plugin(&self, id: &str) -> Result<(), String> { + validate_id(id)?; + let targets = uninstall_targets(&self.plugin_dir, id); + if targets.is_empty() { + return Err(format!("Plugin '{id}' is not installed")); + } + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| error.to_string())? + .as_nanos(); + let mut moved = Vec::with_capacity(targets.len()); + for (index, source) in targets.into_iter().enumerate() { + let backup = self.plugin_dir.join(format!( + ".{id}.uninstall-{}-{stamp}-{index}", + std::process::id() + )); + if let Err(error) = std::fs::rename(&source, &backup) { + return Err(with_rollback( + format!("Cannot remove plugin files: {error}"), + &moved, + )); + } + moved.push((source, backup)); + } + if let Err(error) = set_plugin_disabled(&self.plugin_dir, id, false) { + return Err(with_rollback(error.to_string(), &moved)); + } + for (_, backup) in moved { + let result = if backup.is_dir() { + std::fs::remove_dir_all(&backup) + } else { + std::fs::remove_file(&backup) + }; + if let Err(error) = result { + log::warn!( + "Could not remove plugin backup '{}': {error}", + backup.display() + ); + } + } + Ok(()) + } +} + +impl Default for PluginManager { + fn default() -> Self { + let directory = dirs::config_dir() + .unwrap_or_default() + .join("WinIsland") + .join("plugins"); + Self::new(directory) + } +} + +fn scan(directory: &Path) -> Vec { + let disabled = disabled_plugin_ids(directory); + let Ok(entries) = std::fs::read_dir(directory) else { + return Vec::new(); + }; + let mut plugins = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with('.')) + { + continue; + } + let plugin = if path.is_dir() { + packaged(&path, &disabled) + } else if is_dll(&path) { + manual(&path, &disabled) + } else { + None + }; + if let Some(plugin) = plugin + && !plugins + .iter() + .any(|entry: &InstalledPlugin| entry.id == plugin.id) + { + plugins.push(plugin); + } + } + plugins.sort_by_key(|plugin| plugin.name.to_lowercase()); + plugins +} + +fn packaged(directory: &Path, disabled: &HashSet) -> Option { + let manifest = read_manifest_file(&directory.join("plugin.yml"), ABI_VERSION_2).ok()?; + if !directory.join(&manifest.entry).is_file() { + return None; + } + let icon = asset_path( + directory, + manifest.icon.as_deref(), + &["icon.png", "icon.jpg", "icon.jpeg", "icon.webp"], + ) + .and_then(|path| read_icon(&path)); + let readme = asset_path( + directory, + manifest.readme.as_deref(), + &["README.md", "README.markdown", "README.txt"], + ) + .and_then(|path| read_bounded_file(&path, MAX_PLUGIN_README_BYTES).ok()) + .and_then(|bytes| String::from_utf8(bytes).ok()); + Some(InstalledPlugin { + enabled: !disabled.contains(&manifest.id), + id: manifest.id, + name: manifest.name, + author: manifest.author, + version: manifest.version, + description: manifest.description, + github_link: manifest.github_link, + icon, + readme, + }) +} + +fn manual(path: &Path, disabled: &HashSet) -> Option { + let library = PluginLibrary::open(path).ok()?; + let metadata = library.metadata(); + Some(InstalledPlugin { + id: metadata.id.clone(), + name: metadata.name.clone(), + author: metadata.author.clone(), + version: metadata.version.clone(), + description: metadata.description.clone(), + github_link: String::new(), + enabled: !disabled.contains(&metadata.id), + icon: None, + readme: None, + }) +} + +fn uninstall_targets(directory: &Path, id: &str) -> Vec { + let Ok(entries) = std::fs::read_dir(directory) else { + return Vec::new(); + }; + entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + if path.is_dir() { + read_manifest_file(&path.join("plugin.yml"), ABI_VERSION_2) + .ok() + .filter(|manifest| manifest.id == id) + .map(|_| path) + } else if is_dll(&path) { + PluginLibrary::open(&path) + .ok() + .filter(|library| library.metadata().id == id) + .map(|_| path) + } else { + None + } + }) + .collect() +} + +fn is_dll(path: &Path) -> bool { + path.is_file() + && path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("dll")) +} + +fn validate_id(id: &str) -> Result<(), String> { + if id.is_empty() + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + { + return Err("Invalid plugin ID".to_string()); + } + Ok(()) +} + +fn with_rollback(message: String, moved: &[(PathBuf, PathBuf)]) -> String { + let errors = moved + .iter() + .rev() + .filter_map(|(source, backup)| { + std::fs::rename(backup, source) + .err() + .map(|error| format!("cannot restore '{}': {error}", source.display())) + }) + .collect::>(); + if errors.is_empty() { + message + } else { + format!("{message}; rollback also failed: {}", errors.join("; ")) + } +} + +fn read_icon(path: &Path) -> Option> { + let bytes = read_bounded_file(path, MAX_PLUGIN_ICON_BYTES).ok()?; + let reader = image::ImageReader::new(std::io::Cursor::new(&bytes)) + .with_guessed_format() + .ok()?; + let (width, height) = reader.into_dimensions().ok()?; + (width > 0 && height > 0 && width <= 2048 && height <= 2048).then_some(bytes) +} + +fn asset_path(directory: &Path, declared: Option<&str>, fallbacks: &[&str]) -> Option { + declared + .map(|path| directory.join(path)) + .filter(|path| path.is_file()) + .or_else(|| { + fallbacks + .iter() + .map(|path| directory.join(path)) + .find(|path| path.is_file()) + }) +} diff --git a/src/plugin/loader.rs b/src/plugin/loader.rs deleted file mode 100644 index 57a7b33e..00000000 --- a/src/plugin/loader.rs +++ /dev/null @@ -1,206 +0,0 @@ -use std::mem::ManuallyDrop; -use std::path::{Path, PathBuf}; - -use libloading::Library; -use libloading::os::windows::{ - LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR, LOAD_LIBRARY_SEARCH_SYSTEM32, Library as WindowsLibrary, -}; - -use super::types::{ - ABI_VERSION_1, HostApiV1, KNOWN_CAPABILITIES, PLUGIN_ENTRY_SYMBOL_V1, PluginCreateInfoV1, - PluginDescriptorV1, PluginEntryFnV1, PluginError, PluginHandle, PluginMetadata, PluginToken, -}; - -pub struct NativePlugin { - metadata: PluginMetadata, - descriptor: PluginDescriptorV1, - handle: PluginHandle, - token: PluginToken, - created: bool, - shutdown: bool, - path: PathBuf, - library: ManuallyDrop, -} - -impl NativePlugin { - pub fn load(path: &Path) -> Result { - let path = std::fs::canonicalize(path) - .map_err(|error| PluginError::LoadFailed(format!("{}: {error}", path.display())))?; - // SAFETY: Loading a native plugin executes trusted plugin code. The canonical path and - // restricted flags ensure its dependencies come only from its directory or System32. - let library: Library = unsafe { - WindowsLibrary::load_with_flags( - &path, - LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32, - ) - } - .map(Into::into) - .map_err(|error| PluginError::LoadFailed(format!("{}: {error}", path.display())))?; - // SAFETY: The symbol is validated against the documented ABI v1 signature. - let entry = - unsafe { library.get::(PLUGIN_ENTRY_SYMBOL_V1) }.map_err(|error| { - PluginError::InvalidPlugin(format!( - "{} does not export winisland_plugin_entry_v1: {error}", - path.display() - )) - })?; - // SAFETY: Calling the trusted plugin entry point does not transfer ownership. - let descriptor_ptr = unsafe { entry() }; - if descriptor_ptr.is_null() { - return Err(PluginError::InvalidPlugin(format!( - "{} returned a null descriptor", - path.display() - ))); - } - - // SAFETY: A valid descriptor starts with a readable u32 struct_size field. - let struct_size = unsafe { std::ptr::read_unaligned(descriptor_ptr.cast::()) }; - if struct_size < std::mem::size_of::() as u32 { - return Err(PluginError::InvalidPlugin(format!( - "{} returned a truncated ABI v1 descriptor", - path.display() - ))); - } - // SAFETY: struct_size proves the complete ABI v1 prefix is available. - let descriptor = unsafe { std::ptr::read_unaligned(descriptor_ptr) }; - if descriptor.abi_version != ABI_VERSION_1 { - return Err(PluginError::InvalidPlugin(format!( - "{} uses unsupported ABI version {}", - path.display(), - descriptor.abi_version - ))); - } - if descriptor.capabilities & !KNOWN_CAPABILITIES != 0 { - return Err(PluginError::InvalidPlugin(format!( - "{} requires unsupported capabilities 0x{:x}", - path.display(), - descriptor.capabilities & !KNOWN_CAPABILITIES - ))); - } - if descriptor.create.is_none() - || descriptor.shutdown.is_none() - || descriptor.destroy.is_none() - { - return Err(PluginError::InvalidPlugin(format!( - "{} is missing required lifecycle functions", - path.display() - ))); - } - - let metadata = PluginMetadata::from(&descriptor.metadata); - if metadata.id.is_empty() - || !metadata - .id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') - { - return Err(PluginError::InvalidPlugin(format!( - "plugin id '{}' must match [a-zA-Z0-9_-]+", - metadata.id - ))); - } - - Ok(Self { - metadata, - descriptor, - handle: std::ptr::null_mut(), - token: 0, - created: false, - shutdown: false, - path: path.to_path_buf(), - library: ManuallyDrop::new(library), - }) - } - - pub fn initialize( - &mut self, - token: PluginToken, - host_api: *const HostApiV1, - ) -> Result<(), PluginError> { - let create_info = PluginCreateInfoV1 { - struct_size: std::mem::size_of::() as u32, - abi_version: ABI_VERSION_1, - plugin_token: token, - host_api, - }; - let create = self - .descriptor - .create - .ok_or_else(|| PluginError::InvalidPlugin("missing create function".to_string()))?; - let mut handle = std::ptr::null_mut(); - // SAFETY: create comes from the validated descriptor and receives ABI v1 data. - let result = unsafe { create(&create_info, &mut handle) }; - if let Err(error) = result.into_result() { - if !handle.is_null() { - self.handle = handle; - self.token = token; - self.created = true; - } - return Err(PluginError::ExecutionError(format!( - "plugin '{}' create failed: {error}", - self.metadata.id - ))); - } - if handle.is_null() { - return Err(PluginError::ExecutionError(format!( - "plugin '{}' returned a null handle", - self.metadata.id - ))); - } - self.handle = handle; - self.token = token; - self.created = true; - Ok(()) - } - - pub fn shutdown(&mut self) -> Result<(), PluginError> { - if !self.created || self.shutdown { - return Ok(()); - } - if let Some(shutdown) = self.descriptor.shutdown { - // SAFETY: handle was returned by create and remains valid until destroy. - let result = unsafe { shutdown(self.handle) }; - if let Err(error) = result.into_result() { - return Err(PluginError::ExecutionError(format!( - "plugin '{}' shutdown failed: {error}", - self.metadata.id - ))); - } - } - self.shutdown = true; - Ok(()) - } - - pub fn metadata(&self) -> &PluginMetadata { - &self.metadata - } - - pub fn capabilities(&self) -> u64 { - self.descriptor.capabilities - } - - pub fn token(&self) -> PluginToken { - self.token - } - - pub fn path(&self) -> &Path { - &self.path - } -} - -impl Drop for NativePlugin { - fn drop(&mut self) { - if let Err(error) = self.shutdown() { - log::error!("{error}; keeping the plugin DLL loaded"); - return; - } - if self.created - && let Some(destroy) = self.descriptor.destroy - { - // SAFETY: shutdown has completed and destroy owns the plugin handle cleanup. - unsafe { destroy(self.handle) }; - } - // SAFETY: The DLL is unloaded only after all plugin function calls have completed. - unsafe { ManuallyDrop::drop(&mut self.library) }; - } -} diff --git a/src/plugin/manager.rs b/src/plugin/manager.rs deleted file mode 100644 index d561132f..00000000 --- a/src/plugin/manager.rs +++ /dev/null @@ -1,3026 +0,0 @@ -use std::cell::RefCell; -use std::collections::{HashMap, HashSet}; -use std::ffi::c_void; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, MutexGuard, OnceLock, mpsc}; - -use super::loader::NativePlugin; -use super::types::{ - ABI_VERSION_1, ByteSliceV1, CAPABILITY_CONTEXT, CAPABILITY_HOST_STATE, CAPABILITY_I18N, - CAPABILITY_LYRICS_TRANSFORM, CAPABILITY_MEDIA, CAPABILITY_SETTINGS, CAPABILITY_WIDGET, - ContextApiV1, ContextDataV1, DrawApiV1, HostApiV1, HostState, HostStateApiV1, HostStateV1, - I18nApiV1, INTERFACE_CONTEXT, INTERFACE_HOST_STATE, INTERFACE_I18N, INTERFACE_LYRICS_TRANSFORM, - INTERFACE_MEDIA, INTERFACE_SETTINGS, INTERFACE_VERSION_1, INTERFACE_WIDGET, INVALID_ID, - LYRICS_TEXT_FLAG_WORD_SYNCED, LyricsTextV1, LyricsTransformApiV1, LyricsTransformFnV1, - LyricsTransformerDataV1, MediaApiV1, MediaCommandV1, MediaSourceDataV1, PluginError, - PluginResultC, PluginToken, ResourceId, SETTINGS_ITEM_BUTTON, SETTINGS_ITEM_FLAG_DISABLED, - SETTINGS_ITEM_GROUP_END, SETTINGS_ITEM_GROUP_START, SETTINGS_ITEM_LABEL, SETTINGS_ITEM_SECTION, - SETTINGS_ITEM_SELECT, SETTINGS_ITEM_STEPPER, SETTINGS_ITEM_SWITCH, SettingsApiV1, - SettingsChangeV1, SettingsChangedFnV1, SettingsItemV1, SettingsOptionV1, SettingsPageDataV1, - TranslationPairV1, Utf8SliceV1, WidgetApiV1, WidgetDataV1, WidgetDrawContextV1, WidgetDrawFnV1, - context_from_ffi, read_c_str, widget_from_ffi, -}; -use super::zip_loader::{self, PluginManifest}; -use skia_safe::{Canvas, Color, ColorType, ISize, ImageInfo, Paint, Rect}; -use winisland_render::plugin_v1_backend as skia_safe; - -const MAX_COVER_BYTES: u32 = 16 * 1024 * 1024; -const MAX_CONTEXTS_PER_PLUGIN: usize = 64; -const MAX_MEDIA_SOURCES_PER_PLUGIN: usize = 4; -const MAX_MEDIA_BYTES_PER_PLUGIN: usize = 32 * 1024 * 1024; -const MAX_I18N_BUNDLES_PER_PLUGIN: usize = 16; -const MAX_I18N_BYTES_PER_PLUGIN: usize = 4 * 1024 * 1024; -const MAX_WIDGETS_PER_PLUGIN: usize = 8; -const MAX_LYRICS_TRANSFORMERS_PER_PLUGIN: usize = 4; -const MAX_SETTINGS_PAGES_PER_PLUGIN: usize = 1; -const MAX_SETTINGS_ITEMS: u32 = 64; -const MAX_SETTINGS_OPTIONS: u32 = 64; -const MAX_SETTINGS_ICON_BYTES: u32 = 1024 * 1024; -const MAX_SETTINGS_BYTES_PER_PLUGIN: usize = 2 * 1024 * 1024; -const MAX_TRANSFORMED_LYRIC_BYTES: u32 = 256 * 1024; -const MAX_TRANSLATION_PAIRS: u32 = 4096; -const MAX_TRANSLATION_STRING_BYTES: u32 = 64 * 1024; -const MAX_TRANSLATION_BUNDLE_BYTES: usize = 1024 * 1024; -const DISABLED_PLUGINS_FILE: &str = ".disabled-plugins"; - -#[derive(Clone)] -pub struct InstalledPlugin { - pub id: String, - pub name: String, - pub author: String, - pub version: String, - pub description: String, - pub github_link: String, - pub enabled: bool, - pub icon: Option>, - pub readme: Option, -} - -#[derive(Clone)] -pub struct PendingMediaSource { - pub resource_id: ResourceId, - pub title: String, - pub artist: String, - pub album: String, - pub duration_ms: u64, - pub position_ms: u64, - pub is_playing: bool, - pub available_controls: u32, - pub cover_data: Vec, -} - -pub enum MediaSourceEvent { - Set(PendingMediaSource), - Clear, -} - -enum ContextEvent { - Upsert(winisland_core::context::PluginContext), - Remove(ResourceId), -} - -enum WidgetEvent { - Upsert(winisland_core::widgets::PluginWidget), - Remove(ResourceId), -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum ResourceKind { - Context, - Media, - I18n, - Widget, - LyricsTransform, - Settings, -} - -struct ResourceOwner { - plugin: PluginToken, - kind: ResourceKind, - size_bytes: usize, -} - -struct PluginRegistration { - id: String, - capabilities: u64, - stopping: bool, -} - -struct MediaResource { - data: PendingMediaSource, - sequence: u64, - on_command: Option, - callback_data: usize, - in_flight: u32, -} - -struct LyricsTransformerResource { - sequence: u64, - on_transform: LyricsTransformFnV1, - callback_data: usize, - in_flight: u32, -} - -struct WidgetResource { - on_draw: WidgetDrawFnV1, - callback_data: usize, - in_flight: u32, -} - -struct SettingsResource { - page: crate::core::plugin_settings::PluginSettingsPage, - on_change: Option, - callback_data: usize, - in_flight: u32, -} - -#[derive(Default)] -struct RuntimeState { - plugins: HashMap, - resources: HashMap, - context_events: HashMap, - visible_contexts: HashSet, - media: HashMap, - media_sequence: u64, - media_dirty: bool, - widget_events: HashMap, - widget_keys: HashMap<(PluginToken, String), ResourceId>, - widgets: HashMap, - lyrics_transformers: HashMap, - lyrics_transformer_sequence: u64, - settings_keys: HashMap<(PluginToken, String), ResourceId>, - settings: HashMap, - settings_sequence: u64, - settings_dirty: bool, - host_state: HostState, -} - -static RUNTIME: OnceLock> = OnceLock::new(); -static NEXT_PLUGIN_TOKEN: AtomicU64 = AtomicU64::new(1); -static NEXT_RESOURCE_ID: AtomicU64 = AtomicU64::new(1); -static NEXT_BACKUP_ID: AtomicU64 = AtomicU64::new(1); - -static HOST_API: HostApiV1 = HostApiV1 { - struct_size: std::mem::size_of::() as u32, - abi_version: ABI_VERSION_1, - query_interface: Some(query_interface), -}; -static CONTEXT_API: ContextApiV1 = ContextApiV1 { - struct_size: std::mem::size_of::() as u32, - version: INTERFACE_VERSION_1, - create: Some(context_create), - update: Some(context_update), - release: Some(context_release), -}; -static MEDIA_API: MediaApiV1 = MediaApiV1 { - struct_size: std::mem::size_of::() as u32, - version: INTERFACE_VERSION_1, - create: Some(media_create), - update: Some(media_update), - release: Some(media_release), -}; -static I18N_API: I18nApiV1 = I18nApiV1 { - struct_size: std::mem::size_of::() as u32, - version: INTERFACE_VERSION_1, - register_bundle: Some(i18n_register_bundle), - release_bundle: Some(i18n_release_bundle), -}; -static HOST_STATE_API: HostStateApiV1 = HostStateApiV1 { - struct_size: std::mem::size_of::() as u32, - version: INTERFACE_VERSION_1, - get: Some(host_state_get), -}; -static WIDGET_API: WidgetApiV1 = WidgetApiV1 { - struct_size: std::mem::size_of::() as u32, - version: INTERFACE_VERSION_1, - create: Some(widget_create), - update: Some(widget_update), - release: Some(widget_release), -}; -static LYRICS_TRANSFORM_API: LyricsTransformApiV1 = LyricsTransformApiV1 { - struct_size: std::mem::size_of::() as u32, - version: INTERFACE_VERSION_1, - register: Some(lyrics_transform_register), - release: Some(lyrics_transform_release), -}; -static SETTINGS_API: SettingsApiV1 = SettingsApiV1 { - struct_size: std::mem::size_of::() as u32, - version: INTERFACE_VERSION_1, - create: Some(settings_create), - update: Some(settings_update), - release: Some(settings_release), -}; -static DRAW_API: DrawApiV1 = DrawApiV1 { - struct_size: std::mem::size_of::() as u32, - version: INTERFACE_VERSION_1, - draw_text: Some(ffi_draw_text), - measure_text: Some(ffi_measure_text), - draw_rect: Some(ffi_draw_rect), - draw_round_rect: Some(ffi_draw_round_rect), - draw_circle: Some(ffi_draw_circle), - draw_line: Some(ffi_draw_line), - draw_arc: Some(ffi_draw_arc), - draw_image: Some(ffi_draw_image), - save: Some(ffi_save), - restore: Some(ffi_restore), - translate: Some(ffi_translate), -}; - -fn runtime() -> &'static Mutex { - RUNTIME.get_or_init(|| Mutex::new(RuntimeState::default())) -} - -fn lock_runtime() -> Result, &'static str> { - runtime() - .lock() - .map_err(|_| "plugin runtime lock is poisoned") -} - -fn release_runtime(state: MutexGuard<'static, RuntimeState>) { - drop(state); - crate::platform::wake(); -} - -macro_rules! lock_runtime_or_return { - () => { - match lock_runtime() { - Ok(state) => state, - Err(error) => return PluginResultC::err(error), - } - }; -} - -macro_rules! require_output { - ($ptr:expr, $message:literal) => { - if $ptr.is_null() { - return PluginResultC::err($message); - } - }; -} - -pub fn host_api() -> *const HostApiV1 { - &HOST_API -} - -pub fn draw_api() -> &'static DrawApiV1 { - &DRAW_API -} - -unsafe extern "C" fn query_interface(interface_id: u32, version: u32) -> *const c_void { - if version != INTERFACE_VERSION_1 { - return std::ptr::null(); - } - match interface_id { - INTERFACE_CONTEXT => std::ptr::from_ref(&CONTEXT_API).cast(), - INTERFACE_MEDIA => std::ptr::from_ref(&MEDIA_API).cast(), - INTERFACE_I18N => std::ptr::from_ref(&I18N_API).cast(), - INTERFACE_HOST_STATE => std::ptr::from_ref(&HOST_STATE_API).cast(), - INTERFACE_WIDGET => std::ptr::from_ref(&WIDGET_API).cast(), - INTERFACE_LYRICS_TRANSFORM => std::ptr::from_ref(&LYRICS_TRANSFORM_API).cast(), - INTERFACE_SETTINGS => std::ptr::from_ref(&SETTINGS_API).cast(), - _ => std::ptr::null(), - } -} - -fn next_id(counter: &AtomicU64) -> u64 { - loop { - let id = counter.fetch_add(1, Ordering::Relaxed); - if id != INVALID_ID { - return id; - } - } -} - -fn require_capability( - state: &RuntimeState, - token: PluginToken, - capability: u64, -) -> Result<(), &'static str> { - match state.plugins.get(&token) { - Some(plugin) if plugin.capabilities & capability != 0 => Ok(()), - Some(_) => Err("capability was not declared"), - None => Err("invalid plugin token"), - } -} - -fn require_resource( - state: &RuntimeState, - token: PluginToken, - id: ResourceId, - kind: ResourceKind, -) -> Result<(), &'static str> { - match state.resources.get(&id) { - Some(owner) if owner.plugin == token && owner.kind == kind => Ok(()), - Some(_) => Err("resource is owned by another plugin"), - None => Err("resource was not found"), - } -} - -fn resource_count(state: &RuntimeState, token: PluginToken, kind: ResourceKind) -> usize { - state - .resources - .values() - .filter(|owner| owner.plugin == token && owner.kind == kind) - .count() -} - -fn resource_bytes( - state: &RuntimeState, - token: PluginToken, - kind: ResourceKind, - except: Option, -) -> usize { - state - .resources - .iter() - .filter(|(id, owner)| Some(**id) != except && owner.plugin == token && owner.kind == kind) - .map(|(_, owner)| owner.size_bytes) - .sum() -} - -unsafe fn read_struct(value: *const T) -> Result { - if value.is_null() { - return Err("input pointer is null"); - } - // SAFETY: Plugin inputs are trusted ABI values and every v1 struct starts with struct_size. - let struct_size = unsafe { std::ptr::read_unaligned(value.cast::()) }; - if struct_size < std::mem::size_of::() as u32 { - return Err("input struct is truncated"); - } - // SAFETY: The size check proves the complete ABI v1 prefix is available. - Ok(unsafe { std::ptr::read_unaligned(value) }) -} - -unsafe fn read_widget_data(value: *const WidgetDataV1) -> Result { - if value.is_null() { - return Err("input pointer is null"); - } - // SAFETY: Plugin widget inputs start with a readable struct_size field. - let struct_size = unsafe { std::ptr::read_unaligned(value.cast::()) } as usize; - let legacy_size = std::mem::offset_of!(WidgetDataV1, key); - if struct_size < legacy_size { - return Err("input struct is truncated"); - } - let mut data = std::mem::MaybeUninit::::zeroed(); - // SAFETY: The trusted plugin reports at least the legacy prefix length. Copying only the - // smaller of its version and the host version preserves compatibility with both layouts. - unsafe { - std::ptr::copy_nonoverlapping( - value.cast::(), - data.as_mut_ptr().cast::(), - struct_size.min(std::mem::size_of::()), - ); - Ok(data.assume_init()) - } -} - -fn validate_widget_key(key: &[u8; 64]) -> Result, &'static str> { - let end = key.iter().position(|byte| *byte == 0).unwrap_or(key.len()); - if end == 0 { - return Ok(None); - } - if end == key.len() - || !key[..end] - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-' || *byte == b'_') - { - return Err("widget key must match [a-zA-Z0-9_-]{1,63}"); - } - Ok(Some(String::from_utf8_lossy(&key[..end]).into_owned())) -} - -fn validate_widget_data(data: &WidgetDataV1) -> Result<(), &'static str> { - if data.span_cols == 0 - || data.span_cols > winisland_core::config::WIDGET_GRID_COLS as u32 - || data.span_rows == 0 - || data.span_rows > winisland_core::config::WIDGET_GRID_ROWS as u32 - { - return Err("widget span is out of range"); - } - if data.flags & !super::types::WIDGET_FLAG_SHOW_COMPACT != 0 { - return Err("widget contains unknown flags"); - } - data.on_draw - .is_some() - .then_some(()) - .ok_or("widget render callback is required") -} - -fn read_settings_key(value: &[u8; 64]) -> Result { - let end = value - .iter() - .position(|byte| *byte == 0) - .unwrap_or(value.len()); - if end == 0 - || end == value.len() - || !value[..end] - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-' || *byte == b'_') - { - return Err("settings key must match [a-zA-Z0-9_-]{1,63}"); - } - Ok(String::from_utf8_lossy(&value[..end]).into_owned()) -} - -unsafe fn read_bytes(value: ByteSliceV1, max_len: u32) -> Result, &'static str> { - if value.len > max_len { - return Err("byte value exceeds the size limit"); - } - if value.len == 0 { - return Ok(Vec::new()); - } - if value.ptr.is_null() { - return Err("byte pointer is null"); - } - // SAFETY: The plugin guarantees this borrowed range is valid for the call. - Ok(unsafe { std::slice::from_raw_parts(value.ptr, value.len as usize) }.to_vec()) -} - -fn validate_settings_icon(icon: &[u8]) -> Result<(), &'static str> { - if icon.is_empty() { - return Ok(()); - } - let image = skia_safe::Image::from_encoded(skia_safe::Data::new_copy(icon)) - .ok_or("settings icon is not a supported image")?; - if image.width() <= 0 || image.height() <= 0 || image.width() > 1024 || image.height() > 1024 { - return Err("settings icon dimensions are out of range"); - } - Ok(()) -} - -unsafe fn copy_settings_options( - item: &SettingsItemV1, -) -> Result, &'static str> { - if item.option_count == 0 || item.option_count > MAX_SETTINGS_OPTIONS || item.options.is_null() - { - return Err("settings select options are empty or too large"); - } - // SAFETY: The option count is bounded and the plugin keeps the borrowed array valid. - let options = unsafe { std::slice::from_raw_parts(item.options, item.option_count as usize) }; - let mut copied = Vec::with_capacity(options.len()); - let mut values = HashSet::new(); - for option in options { - if option.struct_size < std::mem::size_of::() as u32 { - return Err("settings option struct is truncated"); - } - let value = read_c_str(&option.value); - let label = read_c_str(&option.label); - if value.is_empty() || label.trim().is_empty() { - return Err("settings option value and label are required"); - } - if !values.insert(value.clone()) { - return Err("settings option values must be unique"); - } - copied.push(crate::core::plugin_settings::PluginSettingsOption { value, label }); - } - Ok(copied) -} - -unsafe fn copy_settings_page( - id: ResourceId, - sequence: u64, - data: *const SettingsPageDataV1, -) -> Result<(SettingsResource, usize), &'static str> { - // SAFETY: The page pointer is validated and copied before borrowed fields are read. - let data = unsafe { read_struct(data) }?; - let key = read_settings_key(&data.key)?; - let title = read_c_str(&data.title); - if title.trim().is_empty() { - return Err("settings page title is empty"); - } - if data.item_count == 0 || data.item_count > MAX_SETTINGS_ITEMS || data.items.is_null() { - return Err("settings page items are empty or too large"); - } - // SAFETY: The icon is borrowed only for this call and copied immediately. - let icon = unsafe { read_bytes(data.icon, MAX_SETTINGS_ICON_BYTES) }?; - validate_settings_icon(&icon)?; - // SAFETY: The item count is bounded and the plugin keeps the borrowed array valid. - let items = unsafe { std::slice::from_raw_parts(data.items, data.item_count as usize) }; - let mut copied = Vec::with_capacity(items.len()); - let mut keys = HashSet::new(); - let mut group_open = false; - let mut has_actions = false; - let mut size_bytes = key.len() + title.len() + icon.len(); - - for item in items { - if item.struct_size < std::mem::size_of::() as u32 { - return Err("settings item struct is truncated"); - } - if item.flags & !SETTINGS_ITEM_FLAG_DISABLED != 0 { - return Err("settings item contains unknown flags"); - } - let enabled = item.flags & SETTINGS_ITEM_FLAG_DISABLED == 0; - let label = read_c_str(&item.label); - let copied_item = match item.kind { - SETTINGS_ITEM_SECTION => { - if group_open || label.trim().is_empty() { - return Err("settings section is empty or inside a group"); - } - crate::core::plugin_settings::PluginSettingsItem::Section(label) - } - SETTINGS_ITEM_GROUP_START => { - if group_open { - return Err("settings groups cannot be nested"); - } - group_open = true; - crate::core::plugin_settings::PluginSettingsItem::GroupStart - } - SETTINGS_ITEM_GROUP_END => { - if !group_open { - return Err("settings group end has no matching start"); - } - group_open = false; - crate::core::plugin_settings::PluginSettingsItem::GroupEnd - } - SETTINGS_ITEM_LABEL => { - if !group_open || label.trim().is_empty() { - return Err("settings label is empty or outside a group"); - } - crate::core::plugin_settings::PluginSettingsItem::Label(label) - } - SETTINGS_ITEM_SWITCH => { - let key = read_settings_key(&item.key)?; - let value = match read_c_str(&item.value).as_str() { - "true" => true, - "false" => false, - _ => return Err("settings switch value must be true or false"), - }; - validate_settings_action(group_open, &label, &key, &mut keys)?; - has_actions = true; - crate::core::plugin_settings::PluginSettingsItem::Switch { - key, - label, - value, - enabled, - } - } - SETTINGS_ITEM_SELECT => { - let key = read_settings_key(&item.key)?; - let value = read_c_str(&item.value); - // SAFETY: Option data is copied during this host call. - let options = unsafe { copy_settings_options(item) }?; - if !options.iter().any(|option| option.value == value) { - return Err("settings select value is not present in its options"); - } - validate_settings_action(group_open, &label, &key, &mut keys)?; - has_actions = true; - crate::core::plugin_settings::PluginSettingsItem::Select { - key, - label, - value, - options, - enabled, - } - } - SETTINGS_ITEM_STEPPER => { - let key = read_settings_key(&item.key)?; - let value = read_c_str(&item.value) - .parse::() - .map_err(|_| "settings stepper value is not a number")?; - if !value.is_finite() - || !item.minimum.is_finite() - || !item.maximum.is_finite() - || !item.step.is_finite() - || item.minimum > item.maximum - || item.step <= 0.0 - || !(item.minimum..=item.maximum).contains(&value) - { - return Err("settings stepper range is invalid"); - } - validate_settings_action(group_open, &label, &key, &mut keys)?; - has_actions = true; - crate::core::plugin_settings::PluginSettingsItem::Stepper { - key, - label, - value, - minimum: item.minimum, - maximum: item.maximum, - step: item.step, - enabled, - } - } - SETTINGS_ITEM_BUTTON => { - let key = read_settings_key(&item.key)?; - let button_label = read_c_str(&item.value); - if button_label.trim().is_empty() { - return Err("settings button label is empty"); - } - validate_settings_action(group_open, &label, &key, &mut keys)?; - has_actions = true; - crate::core::plugin_settings::PluginSettingsItem::Button { - key, - label, - button_label, - enabled, - } - } - _ => return Err("settings item kind is unknown"), - }; - size_bytes = size_bytes.saturating_add(settings_item_size(&copied_item)); - copied.push(copied_item); - } - if group_open { - return Err("settings group is not closed"); - } - if has_actions && data.on_change.is_none() { - return Err("interactive settings require an on_change callback"); - } - - Ok(( - SettingsResource { - page: crate::core::plugin_settings::PluginSettingsPage { - resource_id: id, - key, - title, - icon, - items: copied, - sequence, - }, - on_change: data.on_change, - callback_data: data.callback_data as usize, - in_flight: 0, - }, - size_bytes, - )) -} - -fn validate_settings_action( - group_open: bool, - label: &str, - key: &str, - keys: &mut HashSet, -) -> Result<(), &'static str> { - if !group_open || label.trim().is_empty() { - return Err("interactive setting is empty or outside a group"); - } - if !keys.insert(key.to_string()) { - return Err("settings item keys must be unique"); - } - Ok(()) -} - -fn settings_item_size(item: &crate::core::plugin_settings::PluginSettingsItem) -> usize { - use crate::core::plugin_settings::PluginSettingsItem; - match item { - PluginSettingsItem::Section(label) | PluginSettingsItem::Label(label) => label.len(), - PluginSettingsItem::GroupStart | PluginSettingsItem::GroupEnd => 0, - PluginSettingsItem::Switch { key, label, .. } - | PluginSettingsItem::Stepper { key, label, .. } => key.len() + label.len(), - PluginSettingsItem::Select { - key, - label, - value, - options, - .. - } => { - key.len() - + label.len() - + value.len() - + options - .iter() - .map(|option| option.value.len() + option.label.len()) - .sum::() - } - PluginSettingsItem::Button { - key, - label, - button_label, - .. - } => key.len() + label.len() + button_label.len(), - } -} - -fn validate_context_data(data: &ContextDataV1) -> Result<(), &'static str> { - if read_c_str(&data.title).is_empty() { - return Err("context title is empty"); - } - (data.priority <= super::types::PRIORITY_HIGH - && data.flags & !super::types::CONTEXT_FLAG_SHOW_COMPACT == 0) - .then_some(()) - .ok_or("context contains unknown priority or flags") -} - -unsafe fn read_utf8(value: Utf8SliceV1, max_len: u32) -> Result { - if value.len > max_len { - return Err("UTF-8 value exceeds the size limit"); - } - if value.len == 0 { - return Ok(String::new()); - } - if value.ptr.is_null() { - return Err("UTF-8 pointer is null"); - } - // SAFETY: The plugin guarantees this borrowed range is valid for the call. - let bytes = unsafe { std::slice::from_raw_parts(value.ptr, value.len as usize) }; - std::str::from_utf8(bytes) - .map(str::to_owned) - .map_err(|_| "value is not valid UTF-8") -} - -unsafe extern "C" fn context_create( - token: PluginToken, - data: *const ContextDataV1, - out_id: *mut ResourceId, -) -> PluginResultC { - require_output!(out_id, "resource output pointer is null"); - // SAFETY: read_widget_data validates and copies the versioned widget structure. - let data = match unsafe { read_struct(data) } { - Ok(data) => data, - Err(error) => return PluginResultC::err(error), - }; - if let Err(error) = validate_context_data(&data) { - return PluginResultC::err(error); - } - let mut state = lock_runtime_or_return!(); - if let Err(error) = require_capability(&state, token, CAPABILITY_CONTEXT) { - return PluginResultC::err(error); - } - if resource_count(&state, token, ResourceKind::Context) >= MAX_CONTEXTS_PER_PLUGIN { - return PluginResultC::err("context resource limit reached"); - } - let id = next_id(&NEXT_RESOURCE_ID); - let context = context_from_ffi(id, &data); - let size_bytes = context.title.len() + context.body.len() + context.compact_text.len(); - state.resources.insert( - id, - ResourceOwner { - plugin: token, - kind: ResourceKind::Context, - size_bytes, - }, - ); - state - .context_events - .insert(id, ContextEvent::Upsert(context)); - // SAFETY: out_id was checked non-null and belongs to the caller. - unsafe { out_id.write(id) }; - release_runtime(state); - PluginResultC::ok() -} - -unsafe extern "C" fn context_update( - token: PluginToken, - id: ResourceId, - data: *const ContextDataV1, -) -> PluginResultC { - // SAFETY: read_widget_data validates and copies the versioned widget structure. - let data = match unsafe { read_struct(data) } { - Ok(data) => data, - Err(error) => return PluginResultC::err(error), - }; - if let Err(error) = validate_context_data(&data) { - return PluginResultC::err(error); - } - let context = context_from_ffi(id, &data); - let size_bytes = context.title.len() + context.body.len() + context.compact_text.len(); - let mut state = lock_runtime_or_return!(); - if let Err(error) = require_resource(&state, token, id, ResourceKind::Context) { - return PluginResultC::err(error); - } - if let Some(owner) = state.resources.get_mut(&id) { - owner.size_bytes = size_bytes; - } - state - .context_events - .insert(id, ContextEvent::Upsert(context)); - release_runtime(state); - PluginResultC::ok() -} - -unsafe extern "C" fn context_release(token: PluginToken, id: ResourceId) -> PluginResultC { - let mut state = lock_runtime_or_return!(); - if let Err(error) = require_resource(&state, token, id, ResourceKind::Context) { - return PluginResultC::err(error); - } - state.resources.remove(&id); - state.context_events.remove(&id); - if state.visible_contexts.remove(&id) { - state.context_events.insert(id, ContextEvent::Remove(id)); - } - release_runtime(state); - PluginResultC::ok() -} - -fn copy_media(data: &MediaSourceDataV1, id: ResourceId) -> Result { - let title = read_c_str(&data.title); - if title.is_empty() { - return Err("media title is empty"); - } - if data.cover.len > MAX_COVER_BYTES { - return Err("cover exceeds 16 MiB"); - } - let known_controls = super::types::MEDIA_CONTROL_TOGGLE_PLAY - | super::types::MEDIA_CONTROL_PREVIOUS - | super::types::MEDIA_CONTROL_NEXT - | super::types::MEDIA_CONTROL_SEEK; - if data.flags & !super::types::MEDIA_FLAG_PLAYING != 0 - || data.available_controls & !known_controls != 0 - { - return Err("media source contains unknown flags or controls"); - } - if data.available_controls != 0 && data.on_command.is_none() { - return Err("media controls require an on_command callback"); - } - let cover_data = if data.cover.len == 0 { - Vec::new() - } else { - if data.cover.ptr.is_null() { - return Err("cover pointer is null"); - } - // SAFETY: The plugin guarantees the cover range is valid for this call. - unsafe { std::slice::from_raw_parts(data.cover.ptr, data.cover.len as usize) }.to_vec() - }; - Ok(MediaResource { - data: PendingMediaSource { - resource_id: id, - title, - artist: read_c_str(&data.artist), - album: read_c_str(&data.album), - duration_ms: data.duration_ms, - position_ms: data.position_ms, - is_playing: data.flags & super::types::MEDIA_FLAG_PLAYING != 0, - available_controls: data.available_controls, - cover_data, - }, - sequence: 0, - on_command: data.on_command, - callback_data: data.callback_data as usize, - in_flight: 0, - }) -} - -unsafe extern "C" fn media_create( - token: PluginToken, - data: *const MediaSourceDataV1, - out_id: *mut ResourceId, -) -> PluginResultC { - require_output!(out_id, "resource output pointer is null"); - // SAFETY: Validation is performed by read_struct before the value is used. - let data = match unsafe { read_struct(data) } { - Ok(data) => data, - Err(error) => return PluginResultC::err(error), - }; - let id = next_id(&NEXT_RESOURCE_ID); - let mut media = match copy_media(&data, id) { - Ok(media) => media, - Err(error) => return PluginResultC::err(error), - }; - let mut state = lock_runtime_or_return!(); - if let Err(error) = require_capability(&state, token, CAPABILITY_MEDIA) { - return PluginResultC::err(error); - } - if resource_count(&state, token, ResourceKind::Media) >= MAX_MEDIA_SOURCES_PER_PLUGIN { - return PluginResultC::err("media resource limit reached"); - } - if resource_bytes(&state, token, ResourceKind::Media, None) - .saturating_add(media.data.cover_data.len()) - > MAX_MEDIA_BYTES_PER_PLUGIN - { - return PluginResultC::err("media resources exceed the 32 MiB limit"); - } - state.media_sequence = state.media_sequence.wrapping_add(1); - media.sequence = state.media_sequence; - state.resources.insert( - id, - ResourceOwner { - plugin: token, - kind: ResourceKind::Media, - size_bytes: media.data.cover_data.len(), - }, - ); - state.media.insert(id, media); - state.media_dirty = true; - // SAFETY: out_id was checked non-null and belongs to the caller. - unsafe { out_id.write(id) }; - release_runtime(state); - PluginResultC::ok() -} - -unsafe extern "C" fn media_update( - token: PluginToken, - id: ResourceId, - data: *const MediaSourceDataV1, -) -> PluginResultC { - // SAFETY: Validation is performed by read_struct before the value is used. - let data = match unsafe { read_struct(data) } { - Ok(data) => data, - Err(error) => return PluginResultC::err(error), - }; - let mut media = match copy_media(&data, id) { - Ok(media) => media, - Err(error) => return PluginResultC::err(error), - }; - let mut state = lock_runtime_or_return!(); - if let Err(error) = require_resource(&state, token, id, ResourceKind::Media) { - return PluginResultC::err(error); - } - if state - .media - .get(&id) - .is_some_and(|media| media.in_flight != 0) - { - return PluginResultC::err("media callback is in progress"); - } - if resource_bytes(&state, token, ResourceKind::Media, Some(id)) - .saturating_add(media.data.cover_data.len()) - > MAX_MEDIA_BYTES_PER_PLUGIN - { - return PluginResultC::err("media resources exceed the 32 MiB limit"); - } - state.media_sequence = state.media_sequence.wrapping_add(1); - media.sequence = state.media_sequence; - if let Some(owner) = state.resources.get_mut(&id) { - owner.size_bytes = media.data.cover_data.len(); - } - state.media.insert(id, media); - state.media_dirty = true; - release_runtime(state); - PluginResultC::ok() -} - -unsafe extern "C" fn media_release(token: PluginToken, id: ResourceId) -> PluginResultC { - let mut state = lock_runtime_or_return!(); - if let Err(error) = require_resource(&state, token, id, ResourceKind::Media) { - return PluginResultC::err(error); - } - if state - .media - .get(&id) - .is_some_and(|media| media.in_flight != 0) - { - return PluginResultC::err("media callback is in progress"); - } - state.resources.remove(&id); - state.media.remove(&id); - state.media_dirty = true; - release_runtime(state); - PluginResultC::ok() -} - -unsafe extern "C" fn i18n_register_bundle( - token: PluginToken, - language: Utf8SliceV1, - pairs: *const TranslationPairV1, - count: u32, - out_id: *mut ResourceId, -) -> PluginResultC { - require_output!(out_id, "resource output pointer is null"); - if count == 0 || count > MAX_TRANSLATION_PAIRS || pairs.is_null() { - return PluginResultC::err("translation bundle is empty or too large"); - } - // SAFETY: The plugin owns the borrowed language bytes for this call. - let language = match unsafe { read_utf8(language, 64) } { - Ok(language) if !language.is_empty() => language, - Ok(_) => return PluginResultC::err("language is empty"), - Err(error) => return PluginResultC::err(error), - }; - // SAFETY: count is bounded and the plugin guarantees this borrowed array is valid. - let pairs = unsafe { std::slice::from_raw_parts(pairs, count as usize) }; - let mut copied = Vec::with_capacity(pairs.len()); - let mut total_bytes = 0usize; - for pair in pairs { - // SAFETY: Translation strings are borrowed for this call and copied immediately. - let key = match unsafe { read_utf8(pair.key, MAX_TRANSLATION_STRING_BYTES) } { - Ok(key) if !key.is_empty() => key, - Ok(_) => return PluginResultC::err("translation key is empty"), - Err(error) => return PluginResultC::err(error), - }; - // SAFETY: Translation strings are borrowed for this call and copied immediately. - let value = match unsafe { read_utf8(pair.value, MAX_TRANSLATION_STRING_BYTES) } { - Ok(value) => value, - Err(error) => return PluginResultC::err(error), - }; - total_bytes = match total_bytes.checked_add(key.len() + value.len()) { - Some(total) if total <= MAX_TRANSLATION_BUNDLE_BYTES => total, - _ => return PluginResultC::err("translation bundle exceeds 1 MiB"), - }; - copied.push((key, value)); - } - - let mut state = lock_runtime_or_return!(); - if let Err(error) = require_capability(&state, token, CAPABILITY_I18N) { - return PluginResultC::err(error); - } - if resource_count(&state, token, ResourceKind::I18n) >= MAX_I18N_BUNDLES_PER_PLUGIN { - return PluginResultC::err("translation bundle limit reached"); - } - if resource_bytes(&state, token, ResourceKind::I18n, None).saturating_add(total_bytes) - > MAX_I18N_BYTES_PER_PLUGIN - { - return PluginResultC::err("translation bundles exceed the 4 MiB limit"); - } - let id = next_id(&NEXT_RESOURCE_ID); - if let Err(error) = - winisland_core::i18n::register_plugin_translation_bundle(id, language, copied) - { - return PluginResultC::err(error); - } - state.resources.insert( - id, - ResourceOwner { - plugin: token, - kind: ResourceKind::I18n, - size_bytes: total_bytes, - }, - ); - // SAFETY: out_id was checked non-null and belongs to the caller. - unsafe { out_id.write(id) }; - release_runtime(state); - PluginResultC::ok() -} - -unsafe extern "C" fn i18n_release_bundle(token: PluginToken, id: ResourceId) -> PluginResultC { - let mut state = lock_runtime_or_return!(); - if let Err(error) = require_resource(&state, token, id, ResourceKind::I18n) { - return PluginResultC::err(error); - } - if let Err(error) = winisland_core::i18n::release_plugin_translation_bundle(id) { - return PluginResultC::err(error); - } - state.resources.remove(&id); - release_runtime(state); - PluginResultC::ok() -} - -unsafe extern "C" fn host_state_get( - token: PluginToken, - out_state: *mut HostStateV1, -) -> PluginResultC { - if out_state.is_null() { - return PluginResultC::err("host state output pointer is null"); - } - // SAFETY: HostStateV1 begins with struct_size, which is readable by contract. - let struct_size = unsafe { std::ptr::read_unaligned(out_state.cast::()) }; - if struct_size < std::mem::size_of::() as u32 { - return PluginResultC::err("host state output struct is truncated"); - } - let state = lock_runtime_or_return!(); - if let Err(error) = require_capability(&state, token, CAPABILITY_HOST_STATE) { - return PluginResultC::err(error); - } - let snapshot = HostStateV1::from(&state.host_state); - // SAFETY: The size check proves the caller provided a complete v1 output struct. - unsafe { out_state.write(snapshot) }; - PluginResultC::ok() -} - -unsafe extern "C" fn lyrics_transform_register( - token: PluginToken, - data: *const LyricsTransformerDataV1, - out_id: *mut ResourceId, -) -> PluginResultC { - require_output!(out_id, "resource output pointer is null"); - // SAFETY: Validation is performed by read_struct before the value is used. - let data = match unsafe { read_struct(data) } { - Ok(data) => data, - Err(error) => return PluginResultC::err(error), - }; - if data.flags != 0 { - return PluginResultC::err("lyrics transformer contains unknown flags"); - } - let Some(on_transform) = data.on_transform else { - return PluginResultC::err("lyrics transform callback is required"); - }; - let mut state = lock_runtime_or_return!(); - if let Err(error) = require_capability(&state, token, CAPABILITY_LYRICS_TRANSFORM) { - return PluginResultC::err(error); - } - if resource_count(&state, token, ResourceKind::LyricsTransform) - >= MAX_LYRICS_TRANSFORMERS_PER_PLUGIN - { - return PluginResultC::err("lyrics transformer resource limit reached"); - } - let id = next_id(&NEXT_RESOURCE_ID); - state.lyrics_transformer_sequence = state.lyrics_transformer_sequence.wrapping_add(1); - let sequence = state.lyrics_transformer_sequence; - state.resources.insert( - id, - ResourceOwner { - plugin: token, - kind: ResourceKind::LyricsTransform, - size_bytes: 0, - }, - ); - state.lyrics_transformers.insert( - id, - LyricsTransformerResource { - sequence, - on_transform, - callback_data: data.callback_data as usize, - in_flight: 0, - }, - ); - // SAFETY: out_id was checked non-null and belongs to the caller. - unsafe { out_id.write(id) }; - PluginResultC::ok() -} - -unsafe extern "C" fn lyrics_transform_release(token: PluginToken, id: ResourceId) -> PluginResultC { - let mut state = lock_runtime_or_return!(); - if let Err(error) = require_resource(&state, token, id, ResourceKind::LyricsTransform) { - return PluginResultC::err(error); - } - if state - .lyrics_transformers - .get(&id) - .is_some_and(|transformer| transformer.in_flight != 0) - { - return PluginResultC::err("lyrics transform callback is in progress"); - } - state.resources.remove(&id); - state.lyrics_transformers.remove(&id); - PluginResultC::ok() -} - -fn ctx_ref<'a>(ctx: *const WidgetDrawContextV1) -> Option<&'a WidgetDrawContextV1> { - // SAFETY: The context is host-provided and valid for the whole on_draw call. - let ctx = unsafe { ctx.as_ref() }?; - (ctx.struct_size >= std::mem::size_of::() as u32 - && ctx.version == INTERFACE_VERSION_1) - .then_some(ctx) -} - -fn ctx_canvas(ctx: &WidgetDrawContextV1) -> Option<&Canvas> { - if ctx.canvas_handle.is_null() { - return None; - } - // SAFETY: The host sets canvas_handle before invoking on_draw and the - // canvas outlives the whole synchronous callback. Skia canvas operations - // take &self, so a shared reference is sufficient. - Some(unsafe { &*(ctx.canvas_handle.cast::()) }) -} - -fn with_canvas(ctx: *const WidgetDrawContextV1, draw: impl FnOnce(&WidgetDrawContextV1, &Canvas)) { - let Some(ctx) = ctx_ref(ctx) else { - return; - }; - let Some(canvas) = ctx_canvas(ctx) else { - return; - }; - draw(ctx, canvas); -} - -const MAX_DRAW_TEXT_BYTES: u32 = 64 * 1024; -const MAX_DRAW_IMAGE_BYTES: usize = 16 * 1024 * 1024; - -thread_local! { - static DRAW_TRANSFORMS: RefCell> = const { RefCell::new(Vec::new()) }; -} - -pub fn reset_draw_transform() { - DRAW_TRANSFORMS.with(|transforms| { - let mut transforms = transforms.borrow_mut(); - transforms.clear(); - transforms.push((0.0, 0.0)); - }); -} - -fn draw_transform() -> (f32, f32) { - DRAW_TRANSFORMS.with(|transforms| transforms.borrow().last().copied().unwrap_or((0.0, 0.0))) -} - -fn ctx_text<'a>(text: Utf8SliceV1) -> Option<&'a str> { - if text.len == 0 { - return Some(""); - } - if text.ptr.is_null() || text.len > MAX_DRAW_TEXT_BYTES { - return None; - } - // SAFETY: The plugin guarantees this borrowed range is valid for the call. - let bytes = unsafe { std::slice::from_raw_parts(text.ptr, text.len as usize) }; - std::str::from_utf8(bytes).ok() -} - -fn argb_paint(color: u32, alpha: u8) -> Paint { - let a = ((color >> 24) & 0xFF) as u8; - let r = ((color >> 16) & 0xFF) as u8; - let g = ((color >> 8) & 0xFF) as u8; - let b = (color & 0xFF) as u8; - let mut paint = Paint::default(); - paint.set_anti_alias(true); - paint.set_color(Color::from_argb( - (a as u32 * alpha as u32 / 255) as u8, - r, - g, - b, - )); - paint -} - -fn stroke_paint(color: u32, alpha: u8, width: f32) -> Paint { - let mut paint = argb_paint(color, alpha); - paint.set_style(skia_safe::paint::Style::Stroke); - paint.set_stroke_width(width); - paint.set_stroke_cap(skia_safe::paint::Cap::Round); - paint -} - -unsafe extern "C" fn ffi_draw_text( - ctx: *const WidgetDrawContextV1, - x: f32, - y: f32, - text: Utf8SliceV1, - size: f32, - bold: u8, - color: u32, -) { - with_canvas(ctx, |ctx, canvas| { - let Some(text) = ctx_text(text) else { - return; - }; - let (tx, ty) = draw_transform(); - let size = size.clamp(1.0, 512.0); - let font_manager = winisland_render::text::FontManager::global(); - let scaled_size = size * ctx.scale; - let baseline = (y + ty) * ctx.scale - font_manager.ascent(scaled_size, bold != 0); - font_manager.draw_plugin_str_v1( - canvas, - text, - winisland_render::Point::new((x + tx) * ctx.scale, baseline), - scaled_size, - bold != 0, - winisland_render::Rgba::from_argb( - (((color >> 24) & 0xff) as u8 as u32 * ctx.alpha as u32 / 255) as u8, - (color >> 16) as u8, - (color >> 8) as u8, - color as u8, - ), - ); - }); -} - -unsafe extern "C" fn ffi_measure_text( - ctx: *const WidgetDrawContextV1, - text: Utf8SliceV1, - size: f32, - bold: u8, -) -> f32 { - let Some(ctx) = ctx_ref(ctx) else { - return 0.0; - }; - let Some(text) = ctx_text(text) else { - return 0.0; - }; - let size = size.clamp(1.0, 512.0); - let advance = winisland_render::text::FontManager::global().measure_str( - text, - size * ctx.scale, - bold != 0, - ); - if ctx.scale > 0.0 { - advance.width() / ctx.scale - } else { - 0.0 - } -} - -unsafe extern "C" fn ffi_draw_rect( - ctx: *const WidgetDrawContextV1, - x: f32, - y: f32, - w: f32, - h: f32, - color: u32, -) { - with_canvas(ctx, |ctx, canvas| { - let (tx, ty) = draw_transform(); - canvas.draw_rect( - Rect::from_xywh( - (x + tx) * ctx.scale, - (y + ty) * ctx.scale, - w * ctx.scale, - h * ctx.scale, - ), - &argb_paint(color, ctx.alpha), - ); - }); -} - -unsafe extern "C" fn ffi_draw_round_rect( - ctx: *const WidgetDrawContextV1, - x: f32, - y: f32, - w: f32, - h: f32, - radius: f32, - color: u32, -) { - with_canvas(ctx, |ctx, canvas| { - let (tx, ty) = draw_transform(); - let radius = radius * ctx.scale; - canvas.draw_round_rect( - Rect::from_xywh( - (x + tx) * ctx.scale, - (y + ty) * ctx.scale, - w * ctx.scale, - h * ctx.scale, - ), - radius, - radius, - &argb_paint(color, ctx.alpha), - ); - }); -} - -unsafe extern "C" fn ffi_draw_circle( - ctx: *const WidgetDrawContextV1, - cx: f32, - cy: f32, - r: f32, - color: u32, -) { - with_canvas(ctx, |ctx, canvas| { - let (tx, ty) = draw_transform(); - canvas.draw_circle( - ((cx + tx) * ctx.scale, (cy + ty) * ctx.scale), - r * ctx.scale, - &argb_paint(color, ctx.alpha), - ); - }); -} - -unsafe extern "C" fn ffi_draw_line( - ctx: *const WidgetDrawContextV1, - x1: f32, - y1: f32, - x2: f32, - y2: f32, - stroke_width: f32, - color: u32, -) { - with_canvas(ctx, |ctx, canvas| { - let (tx, ty) = draw_transform(); - canvas.draw_line( - ((x1 + tx) * ctx.scale, (y1 + ty) * ctx.scale), - ((x2 + tx) * ctx.scale, (y2 + ty) * ctx.scale), - &stroke_paint(color, ctx.alpha, stroke_width * ctx.scale), - ); - }); -} - -unsafe extern "C" fn ffi_draw_arc( - ctx: *const WidgetDrawContextV1, - x: f32, - y: f32, - w: f32, - h: f32, - start_angle: f32, - sweep_angle: f32, - stroke_width: f32, - color: u32, -) { - with_canvas(ctx, |ctx, canvas| { - let (tx, ty) = draw_transform(); - canvas.draw_arc( - Rect::from_xywh( - (x + tx) * ctx.scale, - (y + ty) * ctx.scale, - w * ctx.scale, - h * ctx.scale, - ), - start_angle, - sweep_angle, - false, - &stroke_paint(color, ctx.alpha, stroke_width * ctx.scale), - ); - }); -} - -unsafe extern "C" fn ffi_draw_image( - ctx: *const WidgetDrawContextV1, - x: f32, - y: f32, - w: f32, - h: f32, - bitmap: ByteSliceV1, - bitmap_width: u32, - bitmap_height: u32, -) { - with_canvas(ctx, |ctx, canvas| { - if bitmap.ptr.is_null() || bitmap.len == 0 || bitmap_width == 0 || bitmap_height == 0 { - return; - } - let Some(pixel_bytes) = (bitmap_width as usize) - .checked_mul(bitmap_height as usize) - .and_then(|pixels| pixels.checked_mul(4)) - .filter(|bytes| *bytes <= bitmap.len as usize && *bytes <= MAX_DRAW_IMAGE_BYTES) - else { - return; - }; - let info = ImageInfo::new( - ISize::new(bitmap_width as i32, bitmap_height as i32), - ColorType::RGBA8888, - skia_safe::AlphaType::Unpremul, - None, - ); - // SAFETY: The plugin guarantees this borrowed range is valid for the call. - let pixels = unsafe { std::slice::from_raw_parts(bitmap.ptr, pixel_bytes) }; - let Some(image) = skia_safe::images::raster_from_data( - &info, - skia_safe::Data::new_copy(pixels), - bitmap_width as usize * 4, - ) else { - return; - }; - let (tx, ty) = draw_transform(); - canvas.draw_image_rect( - &image, - None, - Rect::from_xywh( - (x + tx) * ctx.scale, - (y + ty) * ctx.scale, - w * ctx.scale, - h * ctx.scale, - ), - &argb_paint(0xFFFF_FFFF, ctx.alpha), - ); - }); -} - -unsafe extern "C" fn ffi_save(ctx: *const WidgetDrawContextV1) { - if ctx_ref(ctx).is_none() { - return; - } - DRAW_TRANSFORMS.with(|transforms| { - let mut transforms = transforms.borrow_mut(); - if transforms.len() >= 64 { - return; - } - let top = transforms.last().copied().unwrap_or((0.0, 0.0)); - transforms.push(top); - }); -} - -unsafe extern "C" fn ffi_restore(ctx: *const WidgetDrawContextV1) { - if ctx_ref(ctx).is_none() { - return; - } - DRAW_TRANSFORMS.with(|transforms| { - let mut transforms = transforms.borrow_mut(); - if transforms.len() > 1 { - transforms.pop(); - } - }); -} - -unsafe extern "C" fn ffi_translate(ctx: *const WidgetDrawContextV1, dx: f32, dy: f32) { - if ctx_ref(ctx).is_none() { - return; - } - DRAW_TRANSFORMS.with(|transforms| { - let mut transforms = transforms.borrow_mut(); - if let Some(top) = transforms.last_mut() { - *top = (top.0 + dx, top.1 + dy); - } else { - transforms.push((dx, dy)); - } - }); -} - -unsafe extern "C" fn widget_create( - token: PluginToken, - data: *const WidgetDataV1, - out_id: *mut ResourceId, -) -> PluginResultC { - require_output!(out_id, "widget output pointer is null"); - // SAFETY: Validation is performed by read_struct before the value is used. - let data = match unsafe { read_widget_data(data) } { - Ok(data) => data, - Err(error) => return PluginResultC::err(error), - }; - if let Err(error) = validate_widget_data(&data) { - return PluginResultC::err(error); - } - let mut state = lock_runtime_or_return!(); - if let Err(error) = require_capability(&state, token, CAPABILITY_WIDGET) { - return PluginResultC::err(error); - } - if resource_count(&state, token, ResourceKind::Widget) >= MAX_WIDGETS_PER_PLUGIN { - return PluginResultC::err("widget resource limit reached"); - } - let plugin_id = match state.plugins.get(&token) { - Some(plugin) => plugin.id.clone(), - None => return PluginResultC::err("invalid plugin token"), - }; - let key = match validate_widget_key(&data.key) { - Ok(key) => key, - Err(error) => return PluginResultC::err(error), - }; - if key - .as_ref() - .is_some_and(|key| state.widget_keys.contains_key(&(token, key.to_string()))) - { - return PluginResultC::err("widget key is already registered by this plugin"); - } - let id = next_id(&NEXT_RESOURCE_ID); - let widget = widget_from_ffi(&plugin_id, id, &data); - let Some(on_draw) = data.on_draw else { - return PluginResultC::err("widget render callback is required"); - }; - let size_bytes = widget.title.len() + widget.body.len(); - state.resources.insert( - id, - ResourceOwner { - plugin: token, - kind: ResourceKind::Widget, - size_bytes, - }, - ); - if let Some(key) = key { - state.widget_keys.insert((token, key), id); - } - state.widgets.insert( - id, - WidgetResource { - on_draw, - callback_data: data.callback_data as usize, - in_flight: 0, - }, - ); - state.widget_events.insert(id, WidgetEvent::Upsert(widget)); - // SAFETY: out_id was checked non-null and belongs to the caller. - unsafe { out_id.write(id) }; - release_runtime(state); - PluginResultC::ok() -} - -unsafe extern "C" fn widget_update( - token: PluginToken, - id: ResourceId, - data: *const WidgetDataV1, -) -> PluginResultC { - // SAFETY: Validation is performed by read_struct before the value is used. - let data = match unsafe { read_widget_data(data) } { - Ok(data) => data, - Err(error) => return PluginResultC::err(error), - }; - if let Err(error) = validate_widget_data(&data) { - return PluginResultC::err(error); - } - let mut state = lock_runtime_or_return!(); - if let Err(error) = require_resource(&state, token, id, ResourceKind::Widget) { - return PluginResultC::err(error); - } - let key = match validate_widget_key(&data.key) { - Ok(key) => key, - Err(error) => return PluginResultC::err(error), - }; - let existing_key = state - .widget_keys - .iter() - .find_map(|((owner, key), resource)| { - (*owner == token && *resource == id).then_some(key.as_str()) - }); - if existing_key != key.as_deref() { - return PluginResultC::err("widget key cannot change after creation"); - } - if state - .widgets - .get(&id) - .is_some_and(|widget| widget.in_flight != 0) - { - return PluginResultC::err("widget render callback is in progress"); - } - let plugin_id = match state.plugins.get(&token) { - Some(plugin) => plugin.id.clone(), - None => return PluginResultC::err("invalid plugin token"), - }; - let widget = widget_from_ffi(&plugin_id, id, &data); - let size_bytes = widget.title.len() + widget.body.len(); - if let Some(owner) = state.resources.get_mut(&id) { - owner.size_bytes = size_bytes; - } - let Some(on_draw) = data.on_draw else { - return PluginResultC::err("widget render callback is required"); - }; - state.widgets.insert( - id, - WidgetResource { - on_draw, - callback_data: data.callback_data as usize, - in_flight: 0, - }, - ); - state.widget_events.insert(id, WidgetEvent::Upsert(widget)); - release_runtime(state); - PluginResultC::ok() -} - -unsafe extern "C" fn widget_release(token: PluginToken, id: ResourceId) -> PluginResultC { - let mut state = lock_runtime_or_return!(); - if let Err(error) = require_resource(&state, token, id, ResourceKind::Widget) { - return PluginResultC::err(error); - } - if state - .widgets - .get(&id) - .is_some_and(|widget| widget.in_flight != 0) - { - return PluginResultC::err("widget render callback is in progress"); - } - state.resources.remove(&id); - state.widgets.remove(&id); - state - .widget_keys - .retain(|_, resource_id| *resource_id != id); - state.widget_events.remove(&id); - state.widget_events.insert(id, WidgetEvent::Remove(id)); - release_runtime(state); - PluginResultC::ok() -} - -unsafe extern "C" fn settings_create( - token: PluginToken, - data: *const SettingsPageDataV1, - out_id: *mut ResourceId, -) -> PluginResultC { - require_output!(out_id, "settings page output pointer is null"); - let mut state = lock_runtime_or_return!(); - if let Err(error) = require_capability(&state, token, CAPABILITY_SETTINGS) { - return PluginResultC::err(error); - } - if resource_count(&state, token, ResourceKind::Settings) >= MAX_SETTINGS_PAGES_PER_PLUGIN { - return PluginResultC::err("settings page limit reached"); - } - let id = next_id(&NEXT_RESOURCE_ID); - let sequence = state.settings_sequence.wrapping_add(1); - // SAFETY: The plugin keeps the page and nested borrowed data valid for this call. - let (resource, size_bytes) = match unsafe { copy_settings_page(id, sequence, data) } { - Ok(resource) => resource, - Err(error) => return PluginResultC::err(error), - }; - if state - .settings_keys - .contains_key(&(token, resource.page.key.clone())) - { - return PluginResultC::err("settings page key is already registered by this plugin"); - } - if resource_bytes(&state, token, ResourceKind::Settings, None).saturating_add(size_bytes) - > MAX_SETTINGS_BYTES_PER_PLUGIN - { - return PluginResultC::err("settings page exceeds the 2 MiB limit"); - } - state.settings_sequence = sequence; - state.resources.insert( - id, - ResourceOwner { - plugin: token, - kind: ResourceKind::Settings, - size_bytes, - }, - ); - state - .settings_keys - .insert((token, resource.page.key.clone()), id); - state.settings.insert(id, resource); - state.settings_dirty = true; - // SAFETY: out_id was checked non-null and belongs to the caller. - unsafe { out_id.write(id) }; - release_runtime(state); - PluginResultC::ok() -} - -unsafe extern "C" fn settings_update( - token: PluginToken, - id: ResourceId, - data: *const SettingsPageDataV1, -) -> PluginResultC { - let mut state = lock_runtime_or_return!(); - if let Err(error) = require_resource(&state, token, id, ResourceKind::Settings) { - return PluginResultC::err(error); - } - let Some(existing) = state.settings.get(&id) else { - return PluginResultC::err("settings page was not found"); - }; - let existing_key = existing.page.key.clone(); - let sequence = existing.page.sequence; - let in_flight = existing.in_flight; - // SAFETY: The plugin keeps the page and nested borrowed data valid for this call. - let (mut resource, size_bytes) = match unsafe { copy_settings_page(id, sequence, data) } { - Ok(resource) => resource, - Err(error) => return PluginResultC::err(error), - }; - if resource.page.key != existing_key { - return PluginResultC::err("settings page key cannot change after creation"); - } - if resource_bytes(&state, token, ResourceKind::Settings, Some(id)).saturating_add(size_bytes) - > MAX_SETTINGS_BYTES_PER_PLUGIN - { - return PluginResultC::err("settings page exceeds the 2 MiB limit"); - } - resource.in_flight = in_flight; - if let Some(owner) = state.resources.get_mut(&id) { - owner.size_bytes = size_bytes; - } - state.settings.insert(id, resource); - state.settings_dirty = true; - release_runtime(state); - PluginResultC::ok() -} - -unsafe extern "C" fn settings_release(token: PluginToken, id: ResourceId) -> PluginResultC { - let mut state = lock_runtime_or_return!(); - if let Err(error) = require_resource(&state, token, id, ResourceKind::Settings) { - return PluginResultC::err(error); - } - if state - .settings - .get(&id) - .is_some_and(|settings| settings.in_flight != 0) - { - return PluginResultC::err("settings callback is in progress"); - } - state.resources.remove(&id); - state.settings.remove(&id); - state - .settings_keys - .retain(|_, resource_id| *resource_id != id); - state.settings_dirty = true; - release_runtime(state); - PluginResultC::ok() -} - -pub fn update_host_state(state: HostState) { - if let Ok(mut runtime) = runtime().lock() { - runtime.host_state = state; - } -} - -pub(crate) struct WidgetDrawLease { - resource_id: ResourceId, - on_draw: WidgetDrawFnV1, - callback_data: usize, -} - -impl WidgetDrawLease { - pub(crate) fn draw(&self, context: &WidgetDrawContextV1) { - // SAFETY: The lease keeps the plugin registered and prevents its widget resource from - // being released or the DLL from being unloaded until this synchronous call returns. - unsafe { (self.on_draw)(self.callback_data as *mut c_void, context) }; - } -} - -impl Drop for WidgetDrawLease { - fn drop(&mut self) { - if let Ok(mut state) = runtime().lock() - && let Some(widget) = state.widgets.get_mut(&self.resource_id) - { - widget.in_flight = widget.in_flight.saturating_sub(1); - } - } -} - -pub(crate) fn acquire_widget_draw(resource_id: ResourceId) -> Option { - let mut state = runtime().lock().ok()?; - let owner = state.resources.get(&resource_id)?; - if owner.kind != ResourceKind::Widget - || state - .plugins - .get(&owner.plugin) - .is_none_or(|plugin| plugin.stopping) - { - return None; - } - let widget = state.widgets.get_mut(&resource_id)?; - widget.in_flight = widget.in_flight.saturating_add(1); - Some(WidgetDrawLease { - resource_id, - on_draw: widget.on_draw, - callback_data: widget.callback_data, - }) -} - -struct ActiveLyricsTransformer { - resource_id: ResourceId, - plugin_id: String, - on_transform: LyricsTransformFnV1, - callback_data: usize, -} - -struct LyricsTransformLease { - transformers: Vec, -} - -impl LyricsTransformLease { - fn acquire() -> Self { - let Ok(mut state) = runtime().lock() else { - return Self { - transformers: Vec::new(), - }; - }; - let mut available = state - .lyrics_transformers - .iter() - .filter_map(|(&resource_id, transformer)| { - let owner = state.resources.get(&resource_id)?; - let plugin = state.plugins.get(&owner.plugin)?; - (!plugin.stopping) - .then(|| (resource_id, transformer.sequence, plugin.id.to_string())) - }) - .collect::>(); - available.sort_by_key(|(_, sequence, _)| *sequence); - let transformers = available - .into_iter() - .filter_map(|(resource_id, _, plugin_id)| { - let transformer = state.lyrics_transformers.get_mut(&resource_id)?; - transformer.in_flight = transformer.in_flight.saturating_add(1); - Some(ActiveLyricsTransformer { - resource_id, - plugin_id, - on_transform: transformer.on_transform, - callback_data: transformer.callback_data, - }) - }) - .collect(); - Self { transformers } - } -} - -impl Drop for LyricsTransformLease { - fn drop(&mut self) { - if let Ok(mut state) = runtime().lock() { - for transformer in &self.transformers { - if let Some(resource) = state.lyrics_transformers.get_mut(&transformer.resource_id) - { - resource.in_flight = resource.in_flight.saturating_sub(1); - } - } - } - } -} - -fn transform_lyric_text( - transformer: &ActiveLyricsTransformer, - input: &LyricsTextV1, -) -> Result { - let mut required = 0u32; - // SAFETY: The callback belongs to a leased, loaded plugin. Input and out_len - // remain valid for this synchronous size-query call. - unsafe { - (transformer.on_transform)( - transformer.callback_data as *mut c_void, - transformer.resource_id, - input, - std::ptr::null_mut(), - 0, - &mut required, - ) - } - .into_result()?; - if required > MAX_TRANSFORMED_LYRIC_BYTES { - return Err("transformed lyric line exceeds 256 KiB".to_string()); - } - if required == 0 { - return Ok(String::new()); - } - - let mut output = vec![0u8; required as usize]; - let mut written = required; - // SAFETY: The callback belongs to a leased, loaded plugin. The output buffer - // and out_len remain writable for this synchronous transform call. - unsafe { - (transformer.on_transform)( - transformer.callback_data as *mut c_void, - transformer.resource_id, - input, - output.as_mut_ptr(), - required, - &mut written, - ) - } - .into_result()?; - if written > required { - return Err("lyrics transform wrote beyond the advertised length".to_string()); - } - output.truncate(written as usize); - String::from_utf8(output).map_err(|_| "lyrics transform returned invalid UTF-8".to_string()) -} - -pub fn apply_lyrics_transforms( - mut lyrics: Arc>, -) -> Arc> { - let lease = LyricsTransformLease::acquire(); - if lease.transformers.is_empty() { - return lyrics; - } - - let mut reported_errors = HashSet::new(); - for line in Arc::make_mut(&mut lyrics) { - for transformer in &lease.transformers { - let input = LyricsTextV1 { - flags: if line.is_word_synced() { - LYRICS_TEXT_FLAG_WORD_SYNCED - } else { - 0 - }, - line_time_ms: line.time_ms, - text: Utf8SliceV1::borrowed(&line.text), - ..Default::default() - }; - let transformed = match transform_lyric_text(transformer, &input) { - Ok(transformed) => transformed, - Err(error) => { - if reported_errors.insert(transformer.resource_id) { - log::warn!( - "Lyrics transformer '{}' failed: {error}", - transformer.plugin_id - ); - } - continue; - } - }; - if !line.replace_text_preserving_timings(transformed) - && reported_errors.insert(transformer.resource_id) - { - log::warn!( - "Lyrics transformer '{}' changed the character count of a word-synced line", - transformer.plugin_id - ); - } - } - } - lyrics -} - -pub fn update_host_media(title: &str, artist: &str, is_playing: bool) { - if let Ok(mut runtime) = runtime().lock() { - if runtime.host_state.media_title != title { - title.clone_into(&mut runtime.host_state.media_title); - } - if runtime.host_state.media_artist != artist { - artist.clone_into(&mut runtime.host_state.media_artist); - } - runtime.host_state.is_playing = is_playing; - } -} - -pub fn update_host_theme(is_light: bool) { - if let Ok(mut runtime) = runtime().lock() { - runtime.host_state.theme = if is_light { - "light".to_string() - } else { - "dark".to_string() - }; - } -} - -pub fn drain_pending_contexts(manager: &mut winisland_core::context::ContextManager) { - let events = match runtime().lock() { - Ok(mut runtime) => { - let events = runtime - .context_events - .drain() - .map(|(_, event)| event) - .collect::>(); - for event in &events { - match event { - ContextEvent::Upsert(context) => { - runtime.visible_contexts.insert(context.id); - } - ContextEvent::Remove(id) => { - runtime.visible_contexts.remove(id); - } - } - } - events - } - Err(_) => return, - }; - for event in events { - match event { - ContextEvent::Upsert(context) => manager.upsert_context(context), - ContextEvent::Remove(id) => { - manager.remove_context(id); - } - } - } -} - -pub fn drain_widget_events(manager: &mut winisland_core::widgets::WidgetManager) -> bool { - let events = match runtime().try_lock() { - Ok(mut runtime) => runtime - .widget_events - .drain() - .map(|(_, event)| event) - .collect::>(), - Err(_) => return false, - }; - let changed = !events.is_empty(); - for event in events { - match event { - WidgetEvent::Upsert(widget) => manager.upsert_widget(widget), - WidgetEvent::Remove(id) => { - manager.remove_widget(id); - } - } - } - changed -} - -fn settings_page_snapshot( - runtime: &RuntimeState, -) -> Vec { - let mut pages = runtime - .settings - .values() - .map(|settings| settings.page.clone()) - .collect::>(); - pages.sort_by_key(|page| page.sequence); - pages -} - -pub fn plugin_settings_pages() -> Vec { - runtime() - .lock() - .map(|runtime| settings_page_snapshot(&runtime)) - .unwrap_or_default() -} - -pub fn drain_settings_page_changes() -> Option> -{ - let mut runtime = runtime().try_lock().ok()?; - if !runtime.settings_dirty { - return None; - } - runtime.settings_dirty = false; - Some(settings_page_snapshot(&runtime)) -} - -pub fn dispatch_settings_change( - resource_id: ResourceId, - key: &str, - value: &str, -) -> Result<(), String> { - let (callback, callback_data) = { - let mut runtime = runtime() - .lock() - .map_err(|_| "plugin runtime lock is poisoned".to_string())?; - let owner = runtime - .resources - .get(&resource_id) - .filter(|owner| owner.kind == ResourceKind::Settings) - .ok_or_else(|| "settings page was not found".to_string())?; - if runtime - .plugins - .get(&owner.plugin) - .is_none_or(|plugin| plugin.stopping) - { - return Err("plugin is shutting down".to_string()); - } - let settings = runtime - .settings - .get_mut(&resource_id) - .ok_or_else(|| "settings page was not found".to_string())?; - if !settings - .page - .items - .iter() - .any(|item| item.key() == Some(key)) - { - return Err("settings item was not found".to_string()); - } - let callback = settings - .on_change - .ok_or_else(|| "settings page has no change callback".to_string())?; - settings.in_flight = settings.in_flight.saturating_add(1); - (callback, settings.callback_data) - }; - let change = SettingsChangeV1 { - struct_size: std::mem::size_of::() as u32, - key: super::types::str_to_fixed(key), - value: super::types::str_to_fixed(value), - }; - // SAFETY: The callback belongs to a leased, loaded plugin and the event lives for the call. - let result = unsafe { callback(callback_data as *mut c_void, resource_id, &change) }; - if let Ok(mut runtime) = runtime().lock() - && let Some(settings) = runtime.settings.get_mut(&resource_id) - { - settings.in_flight = settings.in_flight.saturating_sub(1); - } - result.into_result() -} - -pub fn drain_media_source_event() -> Option { - let mut runtime = runtime().lock().ok()?; - if !runtime.media_dirty { - return None; - } - runtime.media_dirty = false; - Some( - runtime - .media - .values() - .max_by_key(|media| media.sequence) - .map_or(MediaSourceEvent::Clear, |media| { - MediaSourceEvent::Set(media.data.clone()) - }), - ) -} - -pub fn dispatch_media_command( - resource_id: ResourceId, - command: u32, - position_ms: u64, -) -> Result<(), String> { - let required_control = match command { - super::types::MEDIA_COMMAND_TOGGLE_PLAY => super::types::MEDIA_CONTROL_TOGGLE_PLAY, - super::types::MEDIA_COMMAND_PREVIOUS => super::types::MEDIA_CONTROL_PREVIOUS, - super::types::MEDIA_COMMAND_NEXT => super::types::MEDIA_CONTROL_NEXT, - super::types::MEDIA_COMMAND_SEEK => super::types::MEDIA_CONTROL_SEEK, - _ => return Err("unknown media command".to_string()), - }; - let (callback, callback_data) = { - let mut runtime = runtime() - .lock() - .map_err(|_| "plugin runtime lock is poisoned".to_string())?; - let plugin_token = runtime - .resources - .get(&resource_id) - .filter(|owner| owner.kind == ResourceKind::Media) - .map(|owner| owner.plugin) - .ok_or_else(|| "media resource was not found".to_string())?; - if runtime - .plugins - .get(&plugin_token) - .is_none_or(|plugin| plugin.stopping) - { - return Err("plugin is shutting down".to_string()); - } - let media = runtime - .media - .get_mut(&resource_id) - .ok_or_else(|| "media resource was not found".to_string())?; - if media.data.available_controls & required_control == 0 { - return Err("media control is not supported".to_string()); - } - let callback = media - .on_command - .ok_or_else(|| "media command callback is missing".to_string())?; - media.in_flight = media.in_flight.saturating_add(1); - (callback, media.callback_data) - }; - let command = MediaCommandV1 { - struct_size: std::mem::size_of::() as u32, - command, - position_ms, - }; - // SAFETY: Media callbacks are invoked on the event loop thread while the plugin is loaded. - unsafe { callback(callback_data as *mut c_void, resource_id, &command) }; - if let Ok(mut runtime) = runtime().lock() - && let Some(media) = runtime.media.get_mut(&resource_id) - { - media.in_flight = media.in_flight.saturating_sub(1); - } - Ok(()) -} - -fn register_plugin( - token: PluginToken, - plugin_id: &str, - capabilities: u64, -) -> Result<(), PluginError> { - let mut runtime = runtime() - .lock() - .map_err(|_| PluginError::ExecutionError("plugin runtime lock is poisoned".to_string()))?; - runtime.plugins.insert( - token, - PluginRegistration { - id: plugin_id.to_string(), - capabilities, - stopping: false, - }, - ); - Ok(()) -} - -fn begin_plugin_shutdown(token: PluginToken) -> Result { - let mut runtime = runtime() - .lock() - .map_err(|_| PluginError::ExecutionError("plugin runtime lock is poisoned".to_string()))?; - let was_stopping = runtime - .plugins - .get(&token) - .ok_or_else(|| PluginError::ExecutionError("plugin token is not registered".to_string()))? - .stopping; - let callback_in_progress = runtime.resources.iter().any(|(&id, owner)| { - if owner.plugin != token { - return false; - } - match owner.kind { - ResourceKind::Media => runtime - .media - .get(&id) - .is_some_and(|media| media.in_flight != 0), - ResourceKind::LyricsTransform => runtime - .lyrics_transformers - .get(&id) - .is_some_and(|transformer| transformer.in_flight != 0), - ResourceKind::Widget => runtime - .widgets - .get(&id) - .is_some_and(|widget| widget.in_flight != 0), - ResourceKind::Settings => runtime - .settings - .get(&id) - .is_some_and(|settings| settings.in_flight != 0), - _ => false, - } - }); - if callback_in_progress { - return Err(PluginError::ExecutionError( - "plugin callback is in progress".to_string(), - )); - } - if let Some(plugin) = runtime.plugins.get_mut(&token) { - plugin.stopping = true; - } - Ok(was_stopping) -} - -fn restore_plugin_shutdown_state(token: PluginToken, stopping: bool) { - if let Ok(mut runtime) = runtime().lock() - && let Some(plugin) = runtime.plugins.get_mut(&token) - { - plugin.stopping = stopping; - } -} - -fn revoke_plugin(token: PluginToken) { - let i18n_resources = { - let Ok(mut runtime) = runtime().lock() else { - return; - }; - runtime.plugins.remove(&token); - runtime.widget_keys.retain(|(owner, _), _| *owner != token); - runtime - .settings_keys - .retain(|(owner, _), _| *owner != token); - let resources = runtime - .resources - .iter() - .filter_map(|(&id, owner)| (owner.plugin == token).then_some((id, owner.kind))) - .collect::>(); - let mut i18n_resources = Vec::new(); - let mut media_removed = false; - for (id, kind) in resources { - runtime.resources.remove(&id); - match kind { - ResourceKind::Context => { - runtime.context_events.remove(&id); - if runtime.visible_contexts.remove(&id) { - runtime.context_events.insert(id, ContextEvent::Remove(id)); - } - } - ResourceKind::Media => { - runtime.media.remove(&id); - media_removed = true; - } - ResourceKind::I18n => i18n_resources.push(id), - ResourceKind::Widget => { - runtime.widgets.remove(&id); - runtime.widget_events.remove(&id); - runtime.widget_events.insert(id, WidgetEvent::Remove(id)); - } - ResourceKind::LyricsTransform => { - runtime.lyrics_transformers.remove(&id); - } - ResourceKind::Settings => { - runtime.settings.remove(&id); - runtime.settings_dirty = true; - } - } - } - runtime.media_dirty |= media_removed; - i18n_resources - }; - for id in i18n_resources { - if let Err(error) = winisland_core::i18n::release_plugin_translation_bundle(id) { - log::error!("Failed to release plugin translation bundle {id}: {error}"); - } - } - crate::platform::wake(); -} - -pub struct PluginManager { - entries: RefCell>, - pub(crate) plugin_dir: PathBuf, -} - -impl PluginManager { - pub fn new>(plugin_dir: P) -> Self { - let plugin_dir = plugin_dir.as_ref().to_path_buf(); - let _ = std::fs::create_dir_all(&plugin_dir); - Self { - entries: RefCell::new(Vec::new()), - plugin_dir, - } - } - - pub fn load_all(&self) { - let dlls = discover_plugins(&self.plugin_dir); - let disabled = disabled_plugin_ids(&self.plugin_dir); - log::info!( - "Discovering ABI v1 plugins in {}: {} DLL(s) found", - self.plugin_dir.display(), - dlls.len() - ); - for (path, manifest) in dlls { - if let Some(manifest) = manifest.as_ref() - && disabled.contains(&manifest.id) - { - log::info!("Plugin '{}' is disabled", manifest.id); - continue; - } - if let Err(error) = self.load_dll_checked(&path, manifest.as_ref()) { - log::warn!("Failed to load plugin '{}': {error}", path.display()); - } - } - } - - fn load_dll_checked( - &self, - path: &Path, - manifest: Option<&PluginManifest>, - ) -> Result<(), PluginError> { - let mut plugin = NativePlugin::load(path)?; - let plugin_id = plugin.metadata().id.clone(); - if let Some(manifest) = manifest { - validate_manifest_metadata(manifest, plugin.metadata())?; - } - if disabled_plugin_ids(&self.plugin_dir).contains(&plugin_id) { - log::info!("Plugin '{plugin_id}' is disabled"); - return Ok(()); - } - let mut entries = self.entries.try_borrow_mut().map_err(|_| { - PluginError::ExecutionError("plugin list is already borrowed".to_string()) - })?; - if entries.iter().any(|entry| entry.metadata().id == plugin_id) { - return Err(PluginError::InvalidPlugin(format!( - "plugin '{plugin_id}' is already loaded" - ))); - } - - let token = next_id(&NEXT_PLUGIN_TOKEN); - register_plugin(token, &plugin_id, plugin.capabilities())?; - if let Err(error) = plugin.initialize(token, host_api()) { - if let Err(shutdown_error) = begin_plugin_shutdown(token) { - entries.push(plugin); - return Err(PluginError::ExecutionError(format!( - "{error}; cleanup could not start: {shutdown_error}" - ))); - } - match plugin.shutdown() { - Ok(()) => { - revoke_plugin(token); - return Err(error); - } - Err(shutdown_error) => { - entries.push(plugin); - return Err(PluginError::ExecutionError(format!( - "{error}; cleanup also failed: {shutdown_error}" - ))); - } - } - } - log::info!( - "Loaded ABI v1 plugin: {} v{} by {} ({})", - plugin.metadata().name, - plugin.metadata().version, - plugin.metadata().author, - plugin_id - ); - log::debug!("Plugin description: {}", plugin.metadata().description); - entries.push(plugin); - Ok(()) - } - - pub fn unload_if_loaded(&self, plugin_id: &str) -> Result { - let mut entries = self.entries.try_borrow_mut().map_err(|_| { - PluginError::ExecutionError("plugin list is already borrowed".to_string()) - })?; - let Some(index) = entries - .iter() - .position(|plugin| plugin.metadata().id == plugin_id) - else { - return Ok(false); - }; - let token = entries[index].token(); - let was_stopping = begin_plugin_shutdown(token)?; - if let Err(error) = entries[index].shutdown() { - restore_plugin_shutdown_state(token, was_stopping); - return Err(error); - } - let plugin = entries.remove(index); - revoke_plugin(token); - drop(plugin); - Ok(true) - } - - pub fn len(&self) -> usize { - self.entries.try_borrow().map_or(0, |entries| entries.len()) - } - - pub fn installed_plugins(&self) -> Vec { - let disabled = disabled_plugin_ids(&self.plugin_dir); - collect_installed_plugins(&self.plugin_dir, &disabled, self.loaded_plugin_snapshot()) - } - - pub fn installed_plugins_async(&self) -> mpsc::Receiver> { - let plugin_dir = self.plugin_dir.clone(); - let loaded_plugins = self.loaded_plugin_snapshot(); - let (tx, rx) = mpsc::channel(); - let spawn_result = std::thread::Builder::new() - .name("winisland-plugin-scan".to_string()) - .spawn(move || { - let disabled = disabled_plugin_ids(&plugin_dir); - let plugins = collect_installed_plugins(&plugin_dir, &disabled, loaded_plugins); - let _ = tx.send(plugins); - crate::platform::wake(); - }); - if let Err(error) = spawn_result { - log::warn!("Failed to start plugin scan: {error}"); - } - rx - } - - fn loaded_plugin_snapshot(&self) -> Vec { - let Ok(entries) = self.entries.try_borrow() else { - return Vec::new(); - }; - entries - .iter() - .map(|plugin| { - let metadata = plugin.metadata(); - InstalledPlugin { - id: metadata.id.clone(), - name: metadata.name.clone(), - author: metadata.author.clone(), - version: metadata.version.clone(), - description: metadata.description.clone(), - github_link: String::new(), - enabled: true, - icon: None, - readme: None, - } - }) - .collect() - } - - pub fn set_plugin_enabled(&self, plugin_id: &str, enabled: bool) -> Result<(), String> { - validate_plugin_id(plugin_id)?; - if !self - .installed_plugins() - .iter() - .any(|plugin| plugin.id == plugin_id) - { - return Err(format!("Plugin '{plugin_id}' is not installed")); - } - let mut disabled = disabled_plugin_ids(&self.plugin_dir); - if enabled { - disabled.remove(plugin_id); - } else { - disabled.insert(plugin_id.to_string()); - } - write_disabled_plugin_ids(&self.plugin_dir, &disabled) - } - - pub fn uninstall_plugin(&self, plugin_id: &str) -> Result<(), String> { - validate_plugin_id(plugin_id)?; - let targets = uninstall_targets(&self.plugin_dir, plugin_id); - if targets.is_empty() { - return Err(format!("Plugin '{plugin_id}' is not installed")); - } - - let loaded_source = self.entries.try_borrow().ok().and_then(|entries| { - entries - .iter() - .find(|plugin| plugin.metadata().id == plugin_id) - .map(|plugin| plugin.path().to_path_buf()) - }); - let was_loaded = self - .unload_if_loaded(plugin_id) - .map_err(|error| error.to_string())?; - let uninstall_id = NEXT_BACKUP_ID.fetch_add(1, Ordering::Relaxed); - let mut moved = Vec::with_capacity(targets.len()); - for (index, source) in targets.into_iter().enumerate() { - let backup = self.plugin_dir.join(format!( - ".{plugin_id}.uninstall-{}-{uninstall_id}-{index}", - std::process::id() - )); - if let Err(error) = std::fs::rename(&source, &backup) { - let rollback = restore_uninstall_targets(&moved); - let reload = if was_loaded { - reload_plugin_source(self, loaded_source.as_deref()) - } else { - Ok(()) - }; - return Err(rollback_message( - format!("Cannot remove plugin files: {error}"), - rollback.and(reload), - )); - } - moved.push((source, backup)); - } - - let mut disabled = disabled_plugin_ids(&self.plugin_dir); - disabled.remove(plugin_id); - if let Err(error) = write_disabled_plugin_ids(&self.plugin_dir, &disabled) { - let rollback = restore_uninstall_targets(&moved); - let reload = if was_loaded { - reload_plugin_source(self, loaded_source.as_deref()) - } else { - Ok(()) - }; - return Err(rollback_message(error, rollback.and(reload))); - } - - for (_, backup) in moved { - let result = if backup.is_dir() { - std::fs::remove_dir_all(&backup) - } else { - std::fs::remove_file(&backup) - }; - if let Err(error) = result { - log::warn!( - "Plugin '{}' was uninstalled, but temporary files '{}' could not be removed: {error}", - plugin_id, - backup.display() - ); - } - } - Ok(()) - } - - pub fn activate_staged_plugin( - &self, - manifest: &PluginManifest, - staging: &Path, - ) -> Result<(), String> { - let staged_entry = staging.join(&manifest.entry); - let validation = NativePlugin::load(&staged_entry).map_err(|error| error.to_string())?; - validate_manifest_metadata(manifest, validation.metadata()) - .map_err(|error| error.to_string())?; - drop(validation); - - let destination = self.plugin_dir.join(manifest.safe_dir_name()); - let old_source = self.entries.try_borrow().ok().and_then(|entries| { - entries - .iter() - .find(|plugin| plugin.metadata().id == manifest.id) - .map(|plugin| plugin.path().to_path_buf()) - }); - let old_relative = old_source - .as_ref() - .and_then(|path| path.strip_prefix(&destination).ok().map(Path::to_path_buf)); - if old_source.is_some() && old_relative.is_none() { - return Err( - "Cannot replace a manually installed root DLL with a packaged plugin".to_string(), - ); - } - self.unload_if_loaded(&manifest.id) - .map_err(|error| error.to_string())?; - - let backup = self.plugin_dir.join(format!( - ".{}.backup-{}-{}", - manifest.safe_dir_name(), - std::process::id(), - NEXT_BACKUP_ID.fetch_add(1, Ordering::Relaxed) - )); - let had_previous = destination.exists(); - if had_previous && let Err(error) = std::fs::rename(&destination, &backup) { - let reload = reload_previous( - self, - old_source.as_deref(), - old_relative.as_deref(), - &destination, - ); - return Err(rollback_message( - format!("Cannot back up the existing plugin: {error}"), - reload, - )); - } - if let Err(error) = std::fs::rename(staging, &destination) { - let mut message = format!("Cannot activate the staged plugin: {error}"); - if had_previous && let Err(restore_error) = std::fs::rename(&backup, &destination) { - message.push_str(&format!( - "; cannot restore the previous plugin directory: {restore_error}" - )); - } - let reload = reload_previous( - self, - old_source.as_deref(), - old_relative.as_deref(), - &destination, - ); - return Err(rollback_message(message, reload)); - } - - let new_entry = destination.join(&manifest.entry); - if let Err(error) = self.load_dll_checked(&new_entry, Some(manifest)) { - if self.is_loaded(&manifest.id) { - return Err(format!( - "New plugin failed after creating a non-stoppable instance: {error}" - )); - } - let mut message = format!("Cannot initialize the new plugin: {error}"); - if let Err(move_error) = std::fs::rename(&destination, staging) - && let Err(remove_error) = std::fs::remove_dir_all(&destination) - { - message.push_str(&format!( - "; cannot remove the failed plugin directory ({move_error}; {remove_error})" - )); - } - if had_previous && let Err(restore_error) = std::fs::rename(&backup, &destination) { - message.push_str(&format!( - "; cannot restore the previous plugin directory: {restore_error}" - )); - } - let reload = reload_previous( - self, - old_source.as_deref(), - old_relative.as_deref(), - &destination, - ); - return Err(rollback_message(message, reload)); - } - if had_previous && let Err(error) = std::fs::remove_dir_all(&backup) { - log::warn!( - "Cannot remove plugin backup '{}': {error}", - backup.display() - ); - } - Ok(()) - } - - fn is_loaded(&self, plugin_id: &str) -> bool { - self.entries.try_borrow().is_ok_and(|entries| { - entries - .iter() - .any(|plugin| plugin.metadata().id == plugin_id) - }) - } -} - -impl Drop for PluginManager { - fn drop(&mut self) { - for mut plugin in self.entries.get_mut().drain(..) { - let token = plugin.token(); - if let Err(error) = begin_plugin_shutdown(token) { - log::error!("{error}; keeping the plugin DLL loaded"); - std::mem::forget(plugin); - continue; - } - match plugin.shutdown() { - Ok(()) => revoke_plugin(token), - Err(error) => { - log::error!("{error}; keeping the plugin DLL loaded"); - std::mem::forget(plugin); - } - } - } - } -} - -impl Default for PluginManager { - fn default() -> Self { - let directory = dirs::config_dir() - .unwrap_or_default() - .join("WinIsland") - .join("plugins"); - Self::new(directory) - } -} - -fn discover_plugins(directory: &Path) -> Vec<(PathBuf, Option)> { - let Ok(entries) = std::fs::read_dir(directory) else { - return Vec::new(); - }; - let mut plugins = Vec::new(); - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - if path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.starts_with('.')) - { - continue; - } - let manifest_path = path.join("plugin.yml"); - match zip_loader::read_manifest_file(&manifest_path) { - Ok(manifest) => { - let entry = path.join(&manifest.entry); - if entry.is_file() { - plugins.push((entry, Some(manifest))); - } else { - log::warn!("Plugin entry '{}' is missing", entry.display()); - } - } - Err(error) if manifest_path.exists() => { - log::warn!("Skipping '{}': {error}", path.display()); - } - Err(_) => (), - } - } else if path.extension().is_some_and(|ext| ext == "dll") { - plugins.push((path, None)); - } - } - plugins -} - -fn discover_packaged_plugins(directory: &Path, disabled: &HashSet) -> Vec { - let Ok(entries) = std::fs::read_dir(directory) else { - return Vec::new(); - }; - entries - .flatten() - .filter_map(|entry| { - let directory = entry.path(); - if !directory.is_dir() - || directory - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.starts_with('.')) - { - return None; - } - let manifest = zip_loader::read_manifest_file(&directory.join("plugin.yml")).ok()?; - let icon = plugin_asset_path( - &directory, - manifest.icon.as_deref(), - &["icon.png", "icon.jpg", "icon.jpeg", "icon.webp"], - ) - .and_then(|path| read_plugin_icon(&path)); - let readme = plugin_asset_path( - &directory, - manifest.readme.as_deref(), - &["README.md", "README.markdown", "README.txt"], - ) - .and_then(|path| { - zip_loader::read_bounded_file(&path, zip_loader::MAX_PLUGIN_README_BYTES).ok() - }) - .and_then(|bytes| String::from_utf8(bytes).ok()); - Some(InstalledPlugin { - enabled: !disabled.contains(&manifest.id), - id: manifest.id, - name: manifest.name, - author: manifest.author, - version: manifest.version, - description: manifest.description, - github_link: manifest.github_link, - icon, - readme, - }) - }) - .collect() -} - -fn collect_installed_plugins( - directory: &Path, - disabled: &HashSet, - loaded_plugins: Vec, -) -> Vec { - let mut plugins = discover_packaged_plugins(directory, disabled); - for mut plugin in loaded_plugins { - if plugins.iter().any(|entry| entry.id == plugin.id) { - continue; - } - plugin.enabled = !disabled.contains(&plugin.id); - plugins.push(plugin); - } - for path in discover_manual_plugin_dlls(directory) { - let Ok(plugin) = NativePlugin::load(&path) else { - continue; - }; - let metadata = plugin.metadata(); - if plugins.iter().any(|entry| entry.id == metadata.id) { - continue; - } - plugins.push(InstalledPlugin { - id: metadata.id.clone(), - name: metadata.name.clone(), - author: metadata.author.clone(), - version: metadata.version.clone(), - description: metadata.description.clone(), - github_link: String::new(), - enabled: !disabled.contains(&metadata.id), - icon: None, - readme: None, - }); - } - plugins.sort_by_key(|plugin| plugin.name.to_lowercase()); - plugins -} - -fn discover_manual_plugin_dlls(directory: &Path) -> Vec { - let Ok(entries) = std::fs::read_dir(directory) else { - return Vec::new(); - }; - entries - .flatten() - .map(|entry| entry.path()) - .filter(|path| { - path.is_file() - && path - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("dll")) - }) - .collect() -} - -fn uninstall_targets(directory: &Path, plugin_id: &str) -> Vec { - let Ok(entries) = std::fs::read_dir(directory) else { - return Vec::new(); - }; - entries - .flatten() - .filter_map(|entry| { - let path = entry.path(); - if path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.starts_with('.')) - { - return None; - } - if path.is_dir() { - return zip_loader::read_manifest_file(&path.join("plugin.yml")) - .ok() - .filter(|manifest| manifest.id == plugin_id) - .map(|_| path); - } - if !path - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("dll")) - { - return None; - } - NativePlugin::load(&path) - .ok() - .filter(|plugin| plugin.metadata().id == plugin_id) - .map(|_| path) - }) - .collect() -} - -fn restore_uninstall_targets(moved: &[(PathBuf, PathBuf)]) -> Result<(), String> { - let mut errors = Vec::new(); - for (source, backup) in moved.iter().rev() { - if let Err(error) = std::fs::rename(backup, source) { - errors.push(format!("cannot restore '{}': {error}", source.display())); - } - } - if errors.is_empty() { - Ok(()) - } else { - Err(errors.join("; ")) - } -} - -fn reload_plugin_source(manager: &PluginManager, source: Option<&Path>) -> Result<(), String> { - let Some(source) = source else { - return Ok(()); - }; - let manifest = source - .parent() - .and_then(|directory| zip_loader::read_manifest_file(&directory.join("plugin.yml")).ok()); - manager - .load_dll_checked(source, manifest.as_ref()) - .map_err(|error| format!("cannot reload previous plugin: {error}")) -} - -fn read_plugin_icon(path: &Path) -> Option> { - let bytes = zip_loader::read_bounded_file(path, zip_loader::MAX_PLUGIN_ICON_BYTES).ok()?; - let reader = image::ImageReader::new(std::io::Cursor::new(&bytes)) - .with_guessed_format() - .ok()?; - let (width, height) = reader.into_dimensions().ok()?; - (width > 0 && height > 0 && width <= 2048 && height <= 2048).then_some(bytes) -} - -fn plugin_asset_path( - directory: &Path, - declared: Option<&str>, - fallbacks: &[&str], -) -> Option { - declared - .map(|path| directory.join(path)) - .filter(|path| path.is_file()) - .or_else(|| { - fallbacks - .iter() - .map(|path| directory.join(path)) - .find(|path| path.is_file()) - }) -} - -fn disabled_plugin_ids(directory: &Path) -> HashSet { - std::fs::read_to_string(directory.join(DISABLED_PLUGINS_FILE)) - .ok() - .map(|contents| { - contents - .lines() - .map(str::trim) - .filter(|line| { - !line.is_empty() - && line.bytes().all(|byte| { - byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_' - }) - }) - .map(str::to_string) - .collect() - }) - .unwrap_or_default() -} - -fn validate_plugin_id(plugin_id: &str) -> Result<(), String> { - if plugin_id.is_empty() - || !plugin_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') - { - return Err("Invalid plugin ID".to_string()); - } - Ok(()) -} - -fn write_disabled_plugin_ids(directory: &Path, ids: &HashSet) -> Result<(), String> { - let path = directory.join(DISABLED_PLUGINS_FILE); - let mut ids = ids.iter().collect::>(); - ids.sort_unstable(); - if ids.is_empty() { - if path.exists() { - std::fs::remove_file(&path) - .map_err(|error| format!("Cannot remove '{}': {error}", path.display()))?; - } - return Ok(()); - } - let contents = ids - .into_iter() - .map(String::as_str) - .collect::>() - .join("\n") - + "\n"; - std::fs::write(&path, contents) - .map_err(|error| format!("Cannot write '{}': {error}", path.display())) -} - -fn validate_manifest_metadata( - manifest: &PluginManifest, - metadata: &super::types::PluginMetadata, -) -> Result<(), PluginError> { - for (field, packaged, declared) in [ - ("id", manifest.id.as_str(), metadata.id.as_str()), - ("name", manifest.name.as_str(), metadata.name.as_str()), - ( - "version", - manifest.version.as_str(), - metadata.version.as_str(), - ), - ("author", manifest.author.as_str(), metadata.author.as_str()), - ] { - if packaged != declared { - return Err(PluginError::InvalidPlugin(format!( - "plugin.yml {field} '{packaged}' does not match DLL descriptor '{declared}'" - ))); - } - } - Ok(()) -} - -fn reload_previous( - manager: &PluginManager, - old_source: Option<&Path>, - old_relative: Option<&Path>, - destination: &Path, -) -> Result<(), String> { - let Some(path) = old_relative - .map(|relative| destination.join(relative)) - .or_else(|| old_source.map(Path::to_path_buf)) - else { - return Ok(()); - }; - if !path.is_file() { - return Err(format!( - "previous plugin entry '{}' is missing", - path.display() - )); - } - let manifest = zip_loader::read_manifest_file(&destination.join("plugin.yml")).ok(); - manager - .load_dll_checked(&path, manifest.as_ref()) - .map_err(|error| { - format!( - "cannot reload previous plugin '{}': {error}", - path.display() - ) - }) -} - -fn rollback_message(mut message: String, rollback: Result<(), String>) -> String { - if let Err(error) = rollback { - message.push_str(&format!("; rollback also failed: {error}")); - } - message -} diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 548675ea..af924573 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -1,7 +1 @@ -pub mod loader; -pub mod manager; -pub mod marketplace; -pub mod types; -pub mod zip_loader; - -pub use manager::PluginManager; +pub mod inventory; diff --git a/src/plugin/types.rs b/src/plugin/types.rs deleted file mode 100644 index 694d47ee..00000000 --- a/src/plugin/types.rs +++ /dev/null @@ -1,101 +0,0 @@ -pub use winisland_plugin_api::*; - -pub fn read_c_str(buf: &[u8]) -> String { - String::from_utf8_lossy(buf.split(|&byte| byte == 0).next().unwrap_or(buf)).into_owned() -} - -#[derive(Debug, Clone)] -pub struct PluginMetadata { - pub id: String, - pub name: String, - pub version: String, - pub author: String, - pub description: String, -} - -impl From<&PluginMetadataC> for PluginMetadata { - fn from(value: &PluginMetadataC) -> Self { - Self { - id: read_c_str(&value.id), - name: read_c_str(&value.name), - version: read_c_str(&value.version), - author: read_c_str(&value.author), - description: read_c_str(&value.description), - } - } -} - -#[derive(Debug, thiserror::Error)] -pub enum PluginError { - #[error("Failed to load plugin: {0}")] - LoadFailed(String), - #[error("Invalid plugin: {0}")] - InvalidPlugin(String), - #[error("Plugin execution error: {0}")] - ExecutionError(String), -} - -#[derive(Debug, Clone, Default)] -pub struct HostState { - pub media_title: String, - pub media_artist: String, - pub is_playing: bool, - pub theme: String, -} - -impl From<&HostState> for HostStateV1 { - fn from(value: &HostState) -> Self { - Self { - struct_size: std::mem::size_of::() as u32, - flags: 0, - media_title: str_to_fixed(&value.media_title), - media_artist: str_to_fixed(&value.media_artist), - is_playing: u8::from(value.is_playing), - reserved: [0; 7], - theme: str_to_fixed(&value.theme), - } - } -} - -pub fn context_from_ffi( - id: ResourceId, - value: &ContextDataV1, -) -> winisland_core::context::PluginContext { - let priority = match value.priority { - PRIORITY_LOW => winisland_core::context::Priority::Low, - PRIORITY_HIGH => winisland_core::context::Priority::High, - _ => winisland_core::context::Priority::Medium, - }; - let timeout = if value.timeout_ms == 0 { - None - } else { - Some(std::time::Instant::now() + std::time::Duration::from_millis(value.timeout_ms as u64)) - }; - winisland_core::context::PluginContext { - id, - priority, - title: read_c_str(&value.title), - body: read_c_str(&value.body), - compact_text: read_c_str(&value.compact_text), - show_compact: value.flags & CONTEXT_FLAG_SHOW_COMPACT != 0, - expires_at: timeout, - updated_at: std::time::Instant::now(), - } -} - -pub fn widget_from_ffi( - plugin_id: &str, - id: ResourceId, - value: &WidgetDataV1, -) -> winisland_core::widgets::PluginWidget { - let key = read_c_str(&value.key); - winisland_core::widgets::PluginWidget { - id, - plugin_id: plugin_id.to_string(), - key: (!key.is_empty()).then_some(key), - span_cols: value.span_cols, - span_rows: value.span_rows, - title: read_c_str(&value.title), - body: read_c_str(&value.body), - } -} diff --git a/src/ui/expanded/music_view.rs b/src/ui/expanded/music_view.rs index 35b87639..5c5d699e 100644 --- a/src/ui/expanded/music_view.rs +++ b/src/ui/expanded/music_view.rs @@ -459,7 +459,9 @@ pub fn draw_music_page(params: DrawMusicPageParams<'_>) { } }); - if available_controls & crate::plugin::types::MEDIA_CONTROL_PREVIOUS != 0 { + if available_controls & winisland_plugin_api::types::v2::context::MEDIA_CONTROL_PREVIOUS + != 0 + { draw_skip_button( painter, btn_cx - skip_gap, @@ -473,7 +475,9 @@ pub fn draw_music_page(params: DrawMusicPageParams<'_>) { ); } - if available_controls & crate::plugin::types::MEDIA_CONTROL_TOGGLE_PLAY != 0 { + if available_controls & winisland_plugin_api::types::v2::context::MEDIA_CONTROL_TOGGLE_PLAY + != 0 + { draw_pause_control( painter, btn_cx, btn_cy, pause_t, alpha, scale, use_blur, dt, text_color, ); @@ -494,7 +498,7 @@ pub fn draw_music_page(params: DrawMusicPageParams<'_>) { } }); - if available_controls & crate::plugin::types::MEDIA_CONTROL_NEXT != 0 { + if available_controls & winisland_plugin_api::types::v2::context::MEDIA_CONTROL_NEXT != 0 { draw_skip_button( painter, btn_cx + skip_gap, diff --git a/src/ui/expanded/widget_view.rs b/src/ui/expanded/widget_view.rs index d61ecfb7..9f3c1c1a 100644 --- a/src/ui/expanded/widget_view.rs +++ b/src/ui/expanded/widget_view.rs @@ -1,44 +1,38 @@ use crate::icons::arrows::draw_arrow_left; -use crate::plugin::types::{INTERFACE_VERSION_1, WidgetDrawContextV1}; use crate::ui::widget::expanded::{draw_widget, widget_animates, widget_grid_layout}; +use std::collections::HashMap; use winisland_core::config::{ PluginWidgetSlot, WIDGET_GRID_SLOTS, WidgetSlot, first_free_anchor, plugin_widget_slot, span_cells, widget_footprint, }; use winisland_core::widgets::WidgetManager; +use winisland_plugin_host::draw::replay::{PreparedFrame, replay}; +use winisland_plugin_host::host::PluginHost; use winisland_render::{Painter, Rect, Rgba}; #[allow(clippy::too_many_arguments)] -pub fn draw_plugin_widget( +pub fn draw_prepared_widget( painter: Painter<'_>, - widget: &winisland_core::widgets::PluginWidget, + widget_id: u64, + frame: &PreparedFrame, x: f32, y: f32, width: f32, height: f32, - scale: f32, alpha: u8, ) { - let Some(callback) = crate::plugin::manager::acquire_widget_draw(widget.id) else { - return; - }; - let inv_scale = if scale > 0.0 { 1.0 / scale } else { 1.0 }; let save_count = painter.save(); painter.clip_rect_with_anti_alias(Rect::from_xywh(x, y, width, height), false); painter.translate(winisland_render::Vec2::new(x, y)); - crate::plugin::manager::reset_draw_transform(); - let ctx = WidgetDrawContextV1 { - struct_size: std::mem::size_of::() as u32, - version: INTERFACE_VERSION_1, - width: width * inv_scale, - height: height * inv_scale, - scale, - alpha, - canvas_handle: painter.plugin_canvas_handle_v1(), - draw: crate::plugin::manager::draw_api(), - }; - callback.draw(&ctx); + painter.scale(winisland_render::Vec2::new( + width / frame.logical_width, + height / frame.logical_height, + )); + let elapsed = replay(frame, painter, alpha); painter.restore_to(save_count); + if elapsed.as_millis() > 3 { + log::warn!("Plugin widget {widget_id} replay took {elapsed:?}"); + } } #[allow(clippy::too_many_arguments)] @@ -50,9 +44,13 @@ pub fn draw_widget_page( h: f32, alpha: u8, scale: f32, + expanded_width: f32, + expanded_height: f32, widget_layout: &[WidgetSlot], plugin_widget_layout: &[PluginWidgetSlot], plugin_widgets: &WidgetManager, + plugin_frames: &HashMap, + plugin_host: Option<&PluginHost>, text_color: Rgba, show_page_switcher: bool, ) -> bool { @@ -60,6 +58,7 @@ pub fn draw_widget_page( if alpha > 20 { let layout = widget_grid_layout(ox, oy, w, h, scale); + let logical_layout = widget_grid_layout(0.0, 0.0, expanded_width, expanded_height, 1.0); let mut occupied = [false; WIDGET_GRID_SLOTS]; for slot in 0..WIDGET_GRID_SLOTS { @@ -107,9 +106,17 @@ pub fn draw_widget_page( occupied[cell] = true; } let (slot_x, slot_y, tile_w, tile_h) = layout.footprint_rect_span(anchor, span); - draw_plugin_widget( - painter, widget, slot_x, slot_y, tile_w, tile_h, scale, alpha, - ); + if let Some(host) = plugin_host + && plugin_frames.contains_key(&widget.id) + { + let (_, _, logical_w, logical_h) = logical_layout.footprint_rect_span(anchor, span); + let _ = host.set_widget_logical_size(widget.id, logical_w, logical_h); + } + if let Some(frame) = plugin_frames.get(&widget.id) { + draw_prepared_widget( + painter, widget.id, frame, slot_x, slot_y, tile_w, tile_h, alpha, + ); + } } } diff --git a/src/ui/island.rs b/src/ui/island.rs index d961d692..3acd493c 100644 --- a/src/ui/island.rs +++ b/src/ui/island.rs @@ -3,6 +3,7 @@ mod expanded; mod mini; use std::cell::RefCell; +use std::collections::HashMap; pub(crate) use mini::{ lyric_font_size as mini_lyric_font_size, lyric_insets as mini_lyric_insets, @@ -20,6 +21,8 @@ use winisland_core::config::{ CompactWidgetSlot, LyricTransitionAnimation, PluginWidgetSlot, WidgetSlot, }; use winisland_core::lyrics::LyricHighlight; +use winisland_plugin_host::draw::replay::PreparedFrame; +use winisland_plugin_host::host::PluginHost; use winisland_render::DrawingContext; use winisland_render::{BlurSpec, Image, Painter, Path, Point, RasterSurface, Rect, Rgba, Vec2}; @@ -65,9 +68,13 @@ pub struct StyleParams<'a> { pub use_blur: bool, pub font_size: f32, pub dt: f32, + pub expanded_width: f32, + pub expanded_height: f32, pub widget_layout: &'a [WidgetSlot], pub plugin_widget_layout: &'a [PluginWidgetSlot], pub plugin_widgets: &'a winisland_core::widgets::WidgetManager, + pub plugin_frames: &'a HashMap, + pub plugin_host: Option<&'a PluginHost>, pub compact_widget_layout: &'a [CompactWidgetSlot], } @@ -324,12 +331,16 @@ fn draw_expanded_layer( use_blur: style.use_blur, font_size: style.font_size, dt: style.dt, + expanded_width: style.expanded_width, + expanded_height: style.expanded_height, text_color: Rgba::WHITE, text_color_sec: Rgba::WHITE, palette, widget_layout: style.widget_layout, plugin_widget_layout: style.plugin_widget_layout, plugin_widgets: style.plugin_widgets, + plugin_frames: style.plugin_frames, + plugin_host: style.plugin_host, }) } diff --git a/src/ui/island/expanded.rs b/src/ui/island/expanded.rs index 1c788dd3..aef4d4fc 100644 --- a/src/ui/island/expanded.rs +++ b/src/ui/island/expanded.rs @@ -1,7 +1,10 @@ use crate::core::smtc::MediaInfo; use crate::ui::expanded::music_view::{DrawMusicPageParams, draw_music_page}; use crate::ui::expanded::widget_view::draw_widget_page; +use std::collections::HashMap; use winisland_core::config::{PluginWidgetSlot, WidgetSlot}; +use winisland_plugin_host::draw::replay::PreparedFrame; +use winisland_plugin_host::host::PluginHost; use winisland_render::{BlurSpec, LayerSpec, Painter, Rgba, Vec2}; pub(super) struct ExpandedContentParams<'a> { @@ -22,12 +25,16 @@ pub(super) struct ExpandedContentParams<'a> { pub(super) use_blur: bool, pub(super) font_size: f32, pub(super) dt: f32, + pub(super) expanded_width: f32, + pub(super) expanded_height: f32, pub(super) text_color: Rgba, pub(super) text_color_sec: Rgba, pub(super) palette: &'a [Rgba], pub(super) widget_layout: &'a [WidgetSlot], pub(super) plugin_widget_layout: &'a [PluginWidgetSlot], pub(super) plugin_widgets: &'a winisland_core::widgets::WidgetManager, + pub(super) plugin_frames: &'a HashMap, + pub(super) plugin_host: Option<&'a PluginHost>, } pub(super) fn draw_expanded_content(params: ExpandedContentParams<'_>) -> bool { @@ -49,12 +56,16 @@ pub(super) fn draw_expanded_content(params: ExpandedContentParams<'_>) -> bool { use_blur, font_size, dt, + expanded_width, + expanded_height, text_color, text_color_sec, palette, widget_layout, plugin_widget_layout, plugin_widgets, + plugin_frames, + plugin_host, } = params; let mut widget_animating = false; if expanded_alpha_f > 0.01 { @@ -110,9 +121,13 @@ pub(super) fn draw_expanded_content(params: ExpandedContentParams<'_>) -> bool { current_h, alpha, global_scale, + expanded_width, + expanded_height, widget_layout, plugin_widget_layout, plugin_widgets, + plugin_frames, + plugin_host, text_color, music_page_available, ); diff --git a/src/utils/logger.rs b/src/utils/logger.rs index 9f0a734f..3842cd90 100644 --- a/src/utils/logger.rs +++ b/src/utils/logger.rs @@ -172,7 +172,35 @@ fn crash_flag_path() -> PathBuf { pub fn check_crash_flag() { let flag = crash_flag_path(); - if flag.exists() { + let plugin_dir = dirs::config_dir() + .unwrap_or_default() + .join("WinIsland") + .join("plugins"); + if flag.exists() || winisland_plugin_host::fault::has_active_plugin_markers(&plugin_dir) { + let config_dir = crate::platform::shell().config_dir(); + match winisland_plugin_host::fault::recover_crashed_plugin(&config_dir, &plugin_dir) { + Ok(ids) if !ids.is_empty() => { + let names = ids.join(", "); + let report_path = + log_dir().join(format!("plugin-crash-recovery-{}.txt", file_timestamp())); + let report = format!( + "---- WinIsland Plugin Crash Recovery ----\nTime: {}\nPlugins active at termination: {names}\nThe callback marker survived process termination. These plugins were disabled on startup.\n", + timestamp() + ); + if let Err(error) = write_report_to(&report_path, &report) { + log::error!("Could not write plugin recovery report: {error}"); + } + log::warn!("Plugin callback(s) active during previous crash: {names}; disabled"); + let message = if ids.len() == 1 { + format!("上次崩溃由插件 {names} 引起,已自动禁用。") + } else { + format!("上次崩溃时插件 {names} 正在执行,已自动禁用。") + }; + crate::platform::shell().information_dialog("WinIsland plugin disabled", &message); + } + Ok(_) => {} + Err(error) => log::error!("Could not disable the crashed plugin: {error}"), + } log::warn!("Previous session crashed; delaying startup by 1s for GPU recovery"); let _ = fs::remove_file(&flag); std::thread::sleep(std::time::Duration::from_secs(1)); @@ -183,7 +211,10 @@ pub fn flush() { log::logger().flush(); } -fn write_crash_report(panic_info: &PanicHookInfo) { +fn write_crash_report( + panic_info: &PanicHookInfo, + plugin: Option<&winisland_plugin_host::fault::PluginIdentity>, +) { let ts = timestamp(); let file_ts = file_timestamp(); @@ -199,11 +230,15 @@ fn write_crash_report(panic_info: &PanicHookInfo) { .map(|l| format!("{}:{}", l.file(), l.line())) .unwrap_or_else(|| "unknown".into()); + let plugin_line = plugin + .map(|plugin| format!("Plugin: {} v{}\n", plugin.id, plugin.version)) + .unwrap_or_default(); let report = format!( r#"---- WinIsland Crash Report ---- Time: {ts} Version: {} Thread: main +{plugin_line} // The crash happened at Location: {location} @@ -273,7 +308,10 @@ fn get_desktop_path() -> Option { fn panic_hook(info: &PanicHookInfo) { log::logger().flush(); let _ = fs::write(crash_flag_path(), ""); - write_crash_report(info); + let plugin = winisland_plugin_host::fault::record_crash(&crate::platform::shell().config_dir()) + .ok() + .flatten(); + write_crash_report(info, plugin.as_deref()); } pub fn init() -> Result<(), SetLoggerError> { diff --git a/src/utils/settings_ui/renderer.rs b/src/utils/settings_ui/renderer.rs index 17a56348..26586829 100644 --- a/src/utils/settings_ui/renderer.rs +++ b/src/utils/settings_ui/renderer.rs @@ -8,8 +8,10 @@ use crate::utils::color::SettingsTheme; use crate::utils::settings_ui::input::{ WidgetDropAnimation, WidgetEditorHover, WidgetEditorMode, WidgetEditorSlot, WidgetSource, }; +use std::collections::HashMap; use winisland_core::config::{CompactWidgetKind, CompactWidgetSlot, PluginWidgetSlot, WidgetSlot}; use winisland_core::widgets::PluginWidget; +use winisland_plugin_host::draw::replay::PreparedFrame; use super::anim::SwitchAnimator; use super::items::SettingsItem; @@ -42,6 +44,7 @@ pub struct DrawItemsParams<'a> { pub widget_layout: &'a [WidgetSlot], pub plugin_widget_layout: &'a [PluginWidgetSlot], pub plugin_widgets: &'a [PluginWidget], + pub plugin_frames: &'a HashMap, pub widget_dragging: Option<&'a WidgetSource>, pub widget_drag_hover_slot: Option, pub widget_preview_hover_slot: Option, diff --git a/src/utils/settings_ui/renderer/items.rs b/src/utils/settings_ui/renderer/items.rs index 3648facd..d9b51da4 100644 --- a/src/utils/settings_ui/renderer/items.rs +++ b/src/utils/settings_ui/renderer/items.rs @@ -669,6 +669,7 @@ pub fn draw_items(params: DrawItemsParams<'_>) { let widget_layout = params.widget_layout; let plugin_widget_layout = params.plugin_widget_layout; let plugin_widgets = params.plugin_widgets; + let plugin_frames = params.plugin_frames; let widget_dragging = params.widget_dragging; let widget_drag_hover_slot = params.widget_drag_hover_slot; let widget_preview_hover_slot = params.widget_preview_hover_slot; @@ -859,6 +860,7 @@ pub fn draw_items(params: DrawItemsParams<'_>) { widget_layout, plugin_widget_layout, plugin_widgets, + plugin_frames, widget_dragging, widget_drag_hover_slot, widget_preview_hover_slot, diff --git a/src/utils/settings_ui/renderer/widget_preview.rs b/src/utils/settings_ui/renderer/widget_preview.rs index b7008034..4c5c038c 100644 --- a/src/utils/settings_ui/renderer/widget_preview.rs +++ b/src/utils/settings_ui/renderer/widget_preview.rs @@ -1,4 +1,5 @@ use std::cell::RefCell; +use std::collections::HashMap; use std::collections::VecDeque; use winisland_render::{ @@ -6,6 +7,7 @@ use winisland_render::{ Sampling, StrokeCap, StrokeJoin, TileMode, Vec2, }; +use crate::ui::expanded::widget_view::draw_prepared_widget; use crate::ui::widget::expanded::{ draw_mini_card, draw_widget_preview as draw_widget_card_preview, }; @@ -20,6 +22,7 @@ use winisland_core::config::{ }; use winisland_core::i18n::tr; use winisland_core::widgets::PluginWidget; +use winisland_plugin_host::draw::replay::PreparedFrame; use super::super::input::{ COMPACT_WIDGET_ISLAND_PANEL_H, CompactWidgetGridGeom, WIDGET_ISLAND_PANEL_H, @@ -48,6 +51,7 @@ pub(super) struct WidgetPreviewParams<'a> { pub(super) widget_layout: &'a [WidgetSlot], pub(super) plugin_widget_layout: &'a [PluginWidgetSlot], pub(super) plugin_widgets: &'a [PluginWidget], + pub(super) plugin_frames: &'a HashMap, pub(super) widget_dragging: Option<&'a WidgetSource>, pub(super) widget_drag_hover_slot: Option, pub(super) widget_preview_hover_slot: Option, @@ -419,6 +423,7 @@ fn draw_library_tile( painter: Painter<'_>, source: &WidgetSource, plugin_widgets: &[PluginWidget], + plugin_frames: &HashMap, rect: Rect, hover: f32, theme: &SettingsTheme, @@ -461,16 +466,18 @@ fn draw_library_tile( .min(1.0); let width = natural_width * scale; let height = natural_height * scale; - crate::ui::expanded::widget_view::draw_plugin_widget( - painter, - widget, - preview_rect.center_x() - width / 2.0, - preview_rect.center_y() - height / 2.0, - width, - height, - scale, - 255, - ); + if let Some(frame) = plugin_frames.get(&widget.id) { + draw_prepared_widget( + painter, + widget.id, + frame, + preview_rect.center_x() - width / 2.0, + preview_rect.center_y() - height / 2.0, + width, + height, + 255, + ); + } } } } @@ -606,6 +613,7 @@ fn draw_expanded_widget_preview(params: WidgetPreviewParams<'_>) { widget_layout, plugin_widget_layout, plugin_widgets, + plugin_frames, widget_dragging, widget_drag_hover_slot, widget_preview_hover_slot, @@ -740,16 +748,9 @@ fn draw_expanded_widget_preview(params: WidgetPreviewParams<'_>) { let rect = Rect::from_xywh(x, y, width, height); begin_card_transform(painter, rect, hover, drop); draw_card_feedback(painter, rect, 12.0 * geometry.cap_scale, hover, drop, theme); - crate::ui::expanded::widget_view::draw_plugin_widget( - painter, - widget, - x, - y, - width, - height, - geometry.cap_scale, - 255, - ); + if let Some(frame) = plugin_frames.get(&widget.id) { + draw_prepared_widget(painter, widget.id, frame, x, y, width, height, 255); + } painter.restore(); let hovered = widget_preview_hover_slot.is_some_and(|slot| cells.contains(&slot)); if dragging || hovered { @@ -783,7 +784,15 @@ fn draw_expanded_widget_preview(params: WidgetPreviewParams<'_>) { } else { 0.0 }; - draw_library_tile(painter, source, plugin_widgets, rect, hover, theme); + draw_library_tile( + painter, + source, + plugin_widgets, + plugin_frames, + rect, + hover, + theme, + ); } } } diff --git a/src/window/app.rs b/src/window/app.rs index 963c602b..a8c2366b 100644 --- a/src/window/app.rs +++ b/src/window/app.rs @@ -2,12 +2,13 @@ use crate::core::audio::AudioProcessor; use crate::core::persistence::{get_config_path, load_config}; use crate::core::smtc::{MediaInfo, SmtcListener}; use crate::platform::WindowRef; -use crate::plugin::PluginManager; -use crate::plugin::marketplace::MarketplaceCatalog; -use crate::plugin::zip_loader::PluginManifest; +use crate::plugin::inventory::PluginManager; use crate::ui::compact::CompactOverlay; use crate::window::settings::SettingsApp; +use std::cell::Cell; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; +use std::rc::Rc; use std::sync::mpsc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use winisland_core::config::{AppConfig, LyricTransitionAnimation, LyricTransitionMode}; @@ -16,6 +17,10 @@ use winisland_core::lyrics::LyricHighlight; use winisland_core::physics::Spring; use winisland_core::widgets::WidgetManager; use winisland_platform::WindowPoint; +use winisland_plugin_host::draw::replay::PreparedFrame; +use winisland_plugin_host::host::PluginHost; +use winisland_plugin_package::manifest::PluginManifest; +use winisland_plugin_package::marketplace::MarketplaceCatalog; use winisland_render::Renderer; mod events; @@ -24,6 +29,7 @@ mod input; mod layout; mod startup; mod system; +mod v2; type InstallResult = Result<(PluginManifest, PathBuf), String>; type MarketplaceCatalogResult = Result; @@ -108,6 +114,14 @@ pub struct App { ctx_mgr: ContextManager, widget_mgr: WidgetManager, plugin_mgr: PluginManager, + plugin_host: Option>, + plugin_frames: HashMap, + v2_widget_ids: HashSet, + v2_context_ids: HashSet, + v2_context_revision: u64, + v2_media_revision: u64, + v2_album_art_hash: Cell>, + v2_settings_revision: u64, plugin_media_source: Option, is_light_theme: bool, pending_install: Option>, @@ -145,6 +159,14 @@ impl Default for App { .ok(); winisland_render::text::FontManager::global() .set_custom_font_path(config.custom_font_path.as_deref()); + let plugin_mgr = PluginManager::default(); + let plugin_host = match PluginHost::new(plugin_mgr.plugin_dir.clone(), 1) { + Ok(host) => Some(Rc::new(host)), + Err(error) => { + log::error!("Cannot initialize ABI v2 plugin host: {error}"); + None + } + }; Self { window: None, host_backdrop: false, @@ -168,6 +190,7 @@ impl Default for App { config.lyrics_local_dir.clone(), config.smtc_apps.clone(), config.smtc_known_apps.clone(), + plugin_host.as_ref().map(|host| host.lyrics_bridge()), ), audio: AudioProcessor::new(), compact_overlay: CompactOverlay::new( @@ -213,7 +236,15 @@ impl Default for App { last_touch_at: None, ctx_mgr: ContextManager::new(), widget_mgr: WidgetManager::new(), - plugin_mgr: PluginManager::default(), + plugin_mgr, + plugin_host, + plugin_frames: HashMap::new(), + v2_widget_ids: HashSet::new(), + v2_context_ids: HashSet::new(), + v2_context_revision: 0, + v2_media_revision: 0, + v2_album_art_hash: Cell::new(None), + v2_settings_revision: 0, plugin_media_source: None, is_light_theme: false, pending_install: None, @@ -299,7 +330,7 @@ struct SeekDrag { bar_right: f32, duration_ms: u64, preview_ms: u64, - media_resource_id: Option, + media_resource_id: Option, } impl SeekDrag { @@ -309,7 +340,7 @@ impl SeekDrag { bar_right: f32, duration_ms: u64, preview_ms: u64, - media_resource_id: Option, + media_resource_id: Option, ) { self.active = true; self.bar_left = bar_left; @@ -519,20 +550,32 @@ impl App { fn dispatch_media_command(&self, command: u32, position_ms: u64) { if let Some(source) = &self.plugin_media_source { - if let Err(error) = crate::plugin::manager::dispatch_media_command( - source.resource_id, - command, - position_ms, - ) { + let result = self + .plugin_host + .as_ref() + .ok_or("ABI v2 host unavailable".to_string()) + .and_then(|host| { + host.dispatch_media_command(source.resource_id, command, position_ms) + .map_err(|error| error.to_string()) + }); + if let Err(error) = result { log::warn!("Plugin media command failed: {error}"); } return; } match command { - crate::plugin::types::MEDIA_COMMAND_TOGGLE_PLAY => self.smtc.request_toggle_play(), - crate::plugin::types::MEDIA_COMMAND_PREVIOUS => self.smtc.request_prev(), - crate::plugin::types::MEDIA_COMMAND_NEXT => self.smtc.request_next(), - crate::plugin::types::MEDIA_COMMAND_SEEK => self.smtc.request_seek(position_ms), + winisland_plugin_api::types::v2::context::MEDIA_COMMAND_TOGGLE_PLAY => { + self.smtc.request_toggle_play() + } + winisland_plugin_api::types::v2::context::MEDIA_COMMAND_PREVIOUS => { + self.smtc.request_prev() + } + winisland_plugin_api::types::v2::context::MEDIA_COMMAND_NEXT => { + self.smtc.request_next() + } + winisland_plugin_api::types::v2::context::MEDIA_COMMAND_SEEK => { + self.smtc.request_seek(position_ms) + } _ => (), } } @@ -540,11 +583,19 @@ impl App { fn dispatch_seek_command(&mut self) { let position_ms = self.seek.preview_ms.min(self.seek.duration_ms); if let Some(resource_id) = self.seek.media_resource_id { - if let Err(error) = crate::plugin::manager::dispatch_media_command( - resource_id, - crate::plugin::types::MEDIA_COMMAND_SEEK, - position_ms, - ) { + let result = self + .plugin_host + .as_ref() + .ok_or("ABI v2 host unavailable".to_string()) + .and_then(|host| { + host.dispatch_media_command( + resource_id, + winisland_plugin_api::types::v2::context::MEDIA_COMMAND_SEEK, + position_ms, + ) + .map_err(|error| error.to_string()) + }); + if let Err(error) = result { log::warn!("Plugin media seek failed: {error}"); } else if let Some(source) = self .plugin_media_source diff --git a/src/window/app/events.rs b/src/window/app/events.rs index c738bba1..e620fae7 100644 --- a/src/window/app/events.rs +++ b/src/window/app/events.rs @@ -15,7 +15,7 @@ use super::input::InputSource; impl App { pub(super) fn on_window_event(&mut self, id: WindowId, event: PlatformEvent) { - if let Some(win) = &self.window + if let Some(win) = self.window && win.id() == id { match event { @@ -27,7 +27,6 @@ impl App { PlatformEvent::ThemeChanged { theme, .. } => { let is_light = theme == Theme::Light; self.is_light_theme = is_light; - crate::plugin::manager::update_host_theme(is_light); win.request_redraw(); log::info!("Window theme changed to {theme:?}"); if self.tray_installed { @@ -59,7 +58,7 @@ impl App { PlatformEvent::ScaleFactorChanged { .. } => { let expected = self.required_window_size(); let _ = win.request_inner_size(expected); - if let Some(monitor) = Self::get_target_monitor(win, self.config.monitor_index) + if let Some(monitor) = Self::get_target_monitor(&win, self.config.monitor_index) { let (x, y) = self.compute_window_position(monitor.position(), monitor.size()); @@ -179,18 +178,15 @@ impl App { .max(1.0); let dist_h = (self.springs.h.value - compact_target_h).abs(); let progress = (dist_h / total_h).clamp(0.0, 1.0); - if let Some(event) = crate::plugin::manager::drain_media_source_event() { + if let Some(event) = self.next_v2_media_event() { match event { - crate::plugin::manager::MediaSourceEvent::Set(source) => { - let (cover, hash) = if !source.cover_data.is_empty() { + Some(source) => { + let (cover, hash) = if !source.cover.is_empty() { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut hasher = DefaultHasher::new(); - source.cover_data.hash(&mut hasher); - ( - Some(std::sync::Arc::from(source.cover_data)), - hasher.finish(), - ) + source.cover.hash(&mut hasher); + (Some(std::sync::Arc::from(source.cover)), hasher.finish()) } else { (None, 0) }; @@ -212,7 +208,7 @@ impl App { }, }); } - crate::plugin::manager::MediaSourceEvent::Clear => { + None => { self.plugin_media_source = None; } } @@ -234,13 +230,16 @@ impl App { source.available_controls } else if self.config.smtc_enabled { self.smtc_media_info.spectrum = spectrum; - crate::plugin::types::MEDIA_CONTROL_TOGGLE_PLAY - | crate::plugin::types::MEDIA_CONTROL_PREVIOUS - | crate::plugin::types::MEDIA_CONTROL_NEXT - | crate::plugin::types::MEDIA_CONTROL_SEEK + winisland_plugin_api::types::v2::context::MEDIA_CONTROL_TOGGLE_PLAY + | winisland_plugin_api::types::v2::context::MEDIA_CONTROL_PREVIOUS + | winisland_plugin_api::types::v2::context::MEDIA_CONTROL_NEXT + | winisland_plugin_api::types::v2::context::MEDIA_CONTROL_SEEK } else { 0 }; + let v2_widgets_changed = self.refresh_v2_widgets(); + self.prepare_v2_frames(); + self.refresh_v2_contexts(); let media_info = if let Some(source) = self.plugin_media_source.as_ref() { &source.info } else if self.config.smtc_enabled { @@ -257,18 +256,21 @@ impl App { None }; let media_info = seeking_media_info.as_ref().unwrap_or(media_info); + self.update_v2_album_art( + media_info.thumbnail.as_deref(), + media_info.thumbnail_hash, + ); let music_active = !media_info.title.is_empty() && (plugin_media_active || self.config.smtc_enabled); - crate::plugin::manager::update_host_media( + self.update_v2_host_state( &media_info.title, &media_info.artist, media_info.is_playing, ); self.audio.set_gate_override(music_active && !is_hidden); self.ctx_mgr.set_smtc_active(music_active); - crate::plugin::manager::drain_pending_contexts(&mut self.ctx_mgr); let _ = self.ctx_mgr.tick(); - if crate::plugin::manager::drain_widget_events(&mut self.widget_mgr) { + if v2_widgets_changed { let widgets = self.widget_mgr.configurable_widgets(); let mut layout_config = crate::core::persistence::load_config(); if winisland_core::config::normalize_active_plugin_widget_layout( @@ -395,6 +397,8 @@ impl App { use_blur: self.config.motion_blur, font_size: self.config.font_size, dt, + expanded_width: self.config.expanded_width, + expanded_height: self.config.expanded_height, widget_layout: if compact_components_hidden { &[] } else { @@ -406,6 +410,8 @@ impl App { &self.config.plugin_widget_layout }, plugin_widgets: &self.widget_mgr, + plugin_frames: &self.plugin_frames, + plugin_host: self.plugin_host.as_deref(), compact_widget_layout: if compact_components_hidden { &[] } else { diff --git a/src/window/app/frame.rs b/src/window/app/frame.rs index 22baf12d..425c1ffe 100644 --- a/src/window/app/frame.rs +++ b/src/window/app/frame.rs @@ -72,7 +72,7 @@ impl App { self.poll_pending_plugin_install(); self.poll_pending_plugin_marketplace(); - if let Some(pages) = crate::plugin::manager::drain_settings_page_changes() + if let Some(pages) = self.next_v2_settings_pages() && let Some(settings) = self.settings.as_mut() { settings.set_plugin_settings_pages(pages); @@ -224,7 +224,15 @@ impl App { }; match rx.try_recv() { Ok(Ok((manifest, staging))) => { - if let Err(error) = self.plugin_mgr.activate_staged_plugin(&manifest, &staging) { + let activation = self + .plugin_host + .as_ref() + .ok_or_else(|| "ABI v2 plugin host is unavailable".to_string()) + .and_then(|host| { + host.activate_staged_plugin(&manifest, &staging) + .map_err(|error| error.to_string()) + }); + if let Err(error) = activation { let _ = std::fs::remove_dir_all(staging); Self::show_toast("Plugin Error", &error); log::error!("Failed to activate installed plugin: {error}"); @@ -627,7 +635,9 @@ impl App { true } else if self.expanded && (self.springs.view.value as f64) < 0.5 - && self.media_control_available(crate::plugin::types::MEDIA_CONTROL_SEEK) + && self.media_control_available( + winisland_plugin_api::types::v2::context::MEDIA_CONTROL_SEEK, + ) { if let Some((bar_left, bar_right, bar_top, bar_hit_h)) = get_progress_bar_rect( offset_x as f32, diff --git a/src/window/app/input.rs b/src/window/app/input.rs index f3be03cd..6bf16e95 100644 --- a/src/window/app/input.rs +++ b/src/window/app/input.rs @@ -227,14 +227,19 @@ impl App { self.config.expanded_scale, ); if music_on - && self.media_control_available(crate::plugin::types::MEDIA_CONTROL_TOGGLE_PLAY) + && self.media_control_available( + winisland_plugin_api::types::v2::context::MEDIA_CONTROL_TOGGLE_PLAY, + ) && cx >= bx && cx <= bx + bw && cy >= by && cy <= by + bh { trigger_pause_click(media.is_playing); - self.dispatch_media_command(crate::plugin::types::MEDIA_COMMAND_TOGGLE_PLAY, 0); + self.dispatch_media_command( + winisland_plugin_api::types::v2::context::MEDIA_COMMAND_TOGGLE_PLAY, + 0, + ); return; } @@ -245,7 +250,9 @@ impl App { self.config.expanded_scale, ); if music_on - && self.media_control_available(crate::plugin::types::MEDIA_CONTROL_PREVIOUS) + && self.media_control_available( + winisland_plugin_api::types::v2::context::MEDIA_CONTROL_PREVIOUS, + ) && cx >= px && cx <= px + pw && cy >= py @@ -253,7 +260,10 @@ impl App { { trigger_cover_flip(); trigger_prev_click(); - self.dispatch_media_command(crate::plugin::types::MEDIA_COMMAND_PREVIOUS, 0); + self.dispatch_media_command( + winisland_plugin_api::types::v2::context::MEDIA_COMMAND_PREVIOUS, + 0, + ); return; } @@ -264,7 +274,9 @@ impl App { self.config.expanded_scale, ); if music_on - && self.media_control_available(crate::plugin::types::MEDIA_CONTROL_NEXT) + && self.media_control_available( + winisland_plugin_api::types::v2::context::MEDIA_CONTROL_NEXT, + ) && cx >= nx && cx <= nx + nw && cy >= ny @@ -272,7 +284,10 @@ impl App { { trigger_cover_flip(); trigger_next_click(); - self.dispatch_media_command(crate::plugin::types::MEDIA_COMMAND_NEXT, 0); + self.dispatch_media_command( + winisland_plugin_api::types::v2::context::MEDIA_COMMAND_NEXT, + 0, + ); return; } @@ -282,8 +297,9 @@ impl App { w as f32, music_on, self.config.expanded_scale, - ) && self.media_control_available(crate::plugin::types::MEDIA_CONTROL_SEEK) - && cx >= bar_left + ) && self.media_control_available( + winisland_plugin_api::types::v2::context::MEDIA_CONTROL_SEEK, + ) && cx >= bar_left && cx <= bar_right && cy >= bar_top && cy <= bar_top + bar_hit_h diff --git a/src/window/app/startup.rs b/src/window/app/startup.rs index 22f92d10..c80216b9 100644 --- a/src/window/app/startup.rs +++ b/src/window/app/startup.rs @@ -95,16 +95,13 @@ impl App { self.create_host_backdrop(&window_ref); let is_light = window().theme(id) == Some(Theme::Light); self.is_light_theme = is_light; - crate::plugin::manager::update_host_state(crate::plugin::types::HostState { - theme: if is_light { - "light".to_string() - } else { - "dark".to_string() - }, - ..Default::default() - }); - self.plugin_mgr.load_all(); - log::info!("{} plugin(s) loaded", self.plugin_mgr.len()); + self.update_v2_host_state("", "", false); + if let Some(host) = &self.plugin_host { + for error in host.load_all() { + log::warn!("Plugin load failed: {error}"); + } + log::info!("{} ABI v2 plugin(s) loaded", host.len()); + } match crate::platform::shell().tray_install( crate::platform::tray_theme(is_light), crate::platform::tray_labels(true), diff --git a/src/window/app/system.rs b/src/window/app/system.rs index 021e4d93..8859ade8 100644 --- a/src/window/app/system.rs +++ b/src/window/app/system.rs @@ -6,8 +6,8 @@ use winisland_platform::{HostBackdropParams, TrayAction, WindowSize}; use crate::core::persistence::{get_config_path, load_config}; use crate::platform::{WindowRef, window}; -use crate::plugin::marketplace::{self, MarketplacePlugin}; -use crate::plugin::zip_loader; +use winisland_plugin_package::activate as zip_loader; +use winisland_plugin_package::marketplace::{self, MarketplacePlugin}; use winisland_render::RendererOptions; use super::App; @@ -83,9 +83,23 @@ impl App { } } Some(crate::window::settings::PluginSettingsRequest::Uninstall { id }) => { + if let Some(host) = &self.plugin_host + && let Err(error) = host.unload_if_loaded(&id) + { + if let Some(settings) = self.settings.as_mut() { + settings.set_plugin_status( + winisland_core::i18n::tr_args( + "plugin_uninstall_failed", + &[&error.to_string()], + ), + false, + ); + } + return; + } let result = self.plugin_mgr.uninstall_plugin(&id); if result.is_ok() { - crate::plugin::manager::drain_widget_events(&mut self.widget_mgr); + self.refresh_v2_widgets(); } let plugin_inventory = result .is_ok() @@ -238,7 +252,20 @@ impl App { let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { - let result = zip_loader::extract_plugin(&zip_path, &plugin_dir); + let result = zip_loader::extract_plugin( + &zip_path, + &plugin_dir, + winisland_plugin_api::abi::ABI_VERSION_2, + ) + .map_err(|error| { + if error.contains("Unsupported plugin ABI version 1") { + format!( + "该插件使用已废弃的 ABI v1,请升级或联系作者;建议先禁用该插件。 {error}" + ) + } else { + error + } + }); let _ = tx.send(result); }); @@ -255,7 +282,7 @@ impl App { } let (tx, rx) = mpsc::channel(); tokio::spawn(async move { - let result = marketplace::load_catalog().await; + let result = marketplace::load_catalog(winisland_plugin_api::abi::ABI_VERSION_2).await; let _ = tx.send(result); crate::platform::wake(); }); @@ -263,7 +290,9 @@ impl App { } fn install_marketplace_plugin(&mut self, plugin: MarketplacePlugin) { - if plugin.revoked_reason.is_some() || !plugin.is_compatible() { + if plugin.revoked_reason.is_some() + || !plugin.is_compatible(winisland_core::config::APP_VERSION) + { if let Some(settings) = self.settings.as_mut() { settings.finish_marketplace_install(); settings.set_plugin_status( @@ -294,7 +323,9 @@ impl App { } let (tx, rx) = mpsc::channel(); tokio::spawn(async move { - let result = marketplace::download_plugin(&plugin).await; + let result = + marketplace::download_plugin(&plugin, winisland_plugin_api::abi::ABI_VERSION_2) + .await; let _ = tx.send(result); crate::platform::wake(); }); @@ -307,7 +338,7 @@ impl App { return; } - crate::plugin::manager::drain_widget_events(&mut self.widget_mgr); + self.refresh_v2_widgets(); let mut config = load_config(); let plugin_widgets = self.widget_mgr.configurable_widgets(); if winisland_core::config::normalize_active_plugin_widget_layout( @@ -317,7 +348,12 @@ impl App { ) { crate::core::persistence::save_config(&config); } - let plugin_settings_pages = crate::plugin::manager::plugin_settings_pages(); + let plugin_settings_pages = self + .plugin_host + .as_ref() + .and_then(|host| host.settings_pages_snapshot()) + .map(|(_, pages)| pages) + .unwrap_or_default(); let target_monitor = self .window .as_ref() @@ -328,6 +364,7 @@ impl App { plugin_widgets, plugin_settings_pages, ); + settings.set_plugin_host(self.plugin_host.clone()); let Some(renderer) = self.renderer.as_mut() else { log::error!("Cannot open settings without the shared D3D12 renderer"); return; diff --git a/src/window/app/v2.rs b/src/window/app/v2.rs new file mode 100644 index 00000000..219eb5a5 --- /dev/null +++ b/src/window/app/v2.rs @@ -0,0 +1,176 @@ +use std::collections::HashSet; +use winisland_plugin_api::types::v2::context::HostStateV2; +use winisland_plugin_host::host::MediaSnapshot; + +use super::App; + +impl App { + pub(super) fn update_v2_album_art(&self, cover: Option<&[u8]>, hash: u64) { + let Some(host) = &self.plugin_host else { + return; + }; + if self.v2_album_art_hash.get() == Some(hash) { + return; + } + let image = cover.and_then(winisland_render::Image::decode); + if let Err(status) = host.runtime().set_album_art(image) { + log::warn!("Cannot update ABI v2 album art: {status:?}"); + return; + } + self.v2_album_art_hash.set(Some(hash)); + } + + pub(super) fn refresh_v2_contexts(&mut self) { + let Some((revision, contexts)) = self + .plugin_host + .as_ref() + .and_then(|host| host.contexts_snapshot()) + else { + return; + }; + if revision == self.v2_context_revision { + return; + } + let current_ids = contexts + .iter() + .map(|context| context.id) + .collect::>(); + for id in self.v2_context_ids.difference(¤t_ids) { + self.ctx_mgr.remove_context(*id); + } + for context in contexts { + self.ctx_mgr.upsert_context(context); + } + self.v2_context_ids = current_ids; + self.v2_context_revision = revision; + } + + pub(super) fn next_v2_settings_pages( + &mut self, + ) -> Option> { + let (revision, pages) = self.plugin_host.as_ref()?.settings_pages_snapshot()?; + if revision == self.v2_settings_revision { + return None; + } + self.v2_settings_revision = revision; + Some(pages) + } + + pub(super) fn next_v2_media_event(&mut self) -> Option> { + let (revision, source) = self.plugin_host.as_ref()?.media_snapshot()?; + if revision == self.v2_media_revision { + return None; + } + self.v2_media_revision = revision; + Some(source) + } + + pub(super) fn update_v2_host_state(&self, title: &str, artist: &str, playing: bool) { + let Some(host) = &self.plugin_host else { + return; + }; + let mut state = HostStateV2::default(); + copy_text(&mut state.media_title, title); + copy_text(&mut state.media_artist, artist); + state.is_playing = u8::from(playing); + copy_text( + &mut state.theme, + if self.is_light_theme { "light" } else { "dark" }, + ); + if let Err(error) = host.set_host_state(state) { + log::warn!("Cannot update ABI v2 host state: {error}"); + } + } + + pub(super) fn refresh_v2_widgets(&mut self) -> bool { + let Some(host) = &self.plugin_host else { + return false; + }; + for error in host.drain_failed_plugins() { + log::warn!("{error}"); + Self::show_toast("Plugin disabled", &error); + } + let widgets = host.widgets_snapshot(); + let current_ids = widgets + .iter() + .map(|widget| widget.id) + .collect::>(); + let mut changed = false; + for id in self.v2_widget_ids.difference(¤t_ids) { + changed |= self.widget_mgr.remove_widget(*id); + self.plugin_frames.remove(id); + } + for widget in widgets { + let previous = self + .widget_mgr + .widgets() + .iter() + .find(|old| old.id == widget.id); + let needs_update = previous.is_none_or(|old| { + old.plugin_id != widget.plugin_id + || old.key != widget.key + || old.span_cols != widget.span_cols + || old.span_rows != widget.span_rows + || old.title != widget.title + || old.body != widget.body + }); + if needs_update { + self.widget_mgr.upsert_widget(widget); + changed = true; + } + } + self.v2_widget_ids = current_ids; + if changed { + for error in host.refresh_tick_widgets() { + log::warn!("{error}"); + } + } + changed + } + + pub(super) fn prepare_v2_frames(&mut self) { + let Some(host) = &self.plugin_host else { + return; + }; + for id in &self.v2_widget_ids { + match host.prepare_widget_frame(*id) { + Ok(Some(frame)) => { + self.plugin_frames.insert(*id, frame); + } + Ok(None) => {} + Err(error) if error.consecutive_failures == 1 => { + log::warn!("Plugin widget {id} draw list rejected: {}", error.reason); + } + Err(error) if error.disabled => { + self.plugin_frames.remove(id); + } + Err(_) => {} + } + } + match host.runtime().drain_disabled_widgets() { + Ok(disabled) if !disabled.is_empty() => { + let widgets = self.widget_mgr.widgets(); + for id in disabled { + let plugin = widgets + .iter() + .find(|widget| widget.id == id.get()) + .map_or("Unknown plugin", |widget| widget.plugin_id.as_str()); + let message = + format!("{plugin}: widget disabled after repeated invalid frames"); + log::warn!("{message}"); + Self::show_toast("Plugin widget disabled", &message); + } + } + Ok(_) => {} + Err(status) => log::warn!("Cannot read disabled plugin widgets: {status:?}"), + } + } +} + +fn copy_text(target: &mut [u8], value: &str) { + let mut len = value.len().min(target.len().saturating_sub(1)); + while !value.is_char_boundary(len) { + len -= 1; + } + target[..len].copy_from_slice(&value.as_bytes()[..len]); +} diff --git a/src/window/settings/mod.rs b/src/window/settings/mod.rs index 23191c53..ba494fc2 100644 --- a/src/window/settings/mod.rs +++ b/src/window/settings/mod.rs @@ -1,22 +1,24 @@ -use crate::core::plugin_settings::PluginSettingsPage; use crate::platform::{MonitorRef, WindowRef, window}; -use crate::plugin::manager::InstalledPlugin; -use crate::plugin::marketplace::{MarketplaceCatalog, MarketplacePlugin}; +use crate::plugin::inventory::InstalledPlugin; use crate::utils::color::{SettingsTheme, dark_settings_theme, light_settings_theme}; use crate::utils::settings_ui::items::{POPUP_MENU_R, SIDEBAR_PAD, SettingsItem}; use crate::utils::settings_ui::{ SwitchAnimator, WidgetDropAnimation, WidgetEditorHover, WidgetEditorMode, WidgetEditorSlot, WidgetSource, }; +use std::rc::Rc; use std::sync::mpsc; use std::time::{Duration, Instant}; use winisland_core::anim::AnimPool; use winisland_core::config::AppConfig; +use winisland_core::plugin_settings::PluginSettingsPage; use winisland_core::widgets::PluginWidget; use winisland_platform::{ CursorKind, InputState, Key, LogicalWindowSize, MouseButton, MouseWheelDelta, PlatformEvent, SettingsSpec, Theme, TouchPhase, WindowId, WindowPoint, WindowPosition, WindowSize, }; +use winisland_plugin_host::host::PluginHost; +use winisland_plugin_package::marketplace::{MarketplaceCatalog, MarketplacePlugin}; use winisland_render::{Renderer, RendererTargetId}; pub mod input; @@ -234,6 +236,7 @@ pub struct SettingsApp { pub(crate) widget_drop_animation: Option, pub(crate) resource_editor_open: bool, pub(crate) plugin_widgets: Vec, + pub(crate) plugin_host: Option>, pub(crate) plugins: Vec, plugin_inventory_rx: Option>>, pub(crate) plugin_page_tab: PluginPageTab, @@ -365,6 +368,7 @@ impl SettingsApp { widget_drop_animation: None, resource_editor_open: false, plugin_widgets, + plugin_host: None, plugins, plugin_inventory_rx: None, plugin_page_tab: PluginPageTab::Installed, @@ -1366,6 +1370,10 @@ impl SettingsApp { self.request_redraw(); } + pub(crate) fn set_plugin_host(&mut self, host: Option>) { + self.plugin_host = host; + } + pub(crate) fn set_plugin_settings_pages(&mut self, pages: Vec) { let previous_active_page = self.active_page; let icons_changed = self.plugin_settings_pages.len() != pages.len() diff --git a/src/window/settings/pages/plugin_settings.rs b/src/window/settings/pages/plugin_settings.rs index c284069c..ba543be6 100644 --- a/src/window/settings/pages/plugin_settings.rs +++ b/src/window/settings/pages/plugin_settings.rs @@ -1,6 +1,6 @@ -use crate::core::plugin_settings::PluginSettingsItem; use crate::utils::settings_ui::{ClickResult, StepDirection}; use crate::window::settings::{PendingPluginSetting, PopupState, SettingsApp}; +use winisland_core::plugin_settings::PluginSettingsItem; use super::{PageInput, SettingsPage}; @@ -223,7 +223,15 @@ impl SettingsApp { } fn dispatch_plugin_setting(&mut self, resource_id: u64, key: &str, value: &str) { - match crate::plugin::manager::dispatch_settings_change(resource_id, key, value) { + let result = self + .plugin_host + .as_ref() + .ok_or_else(|| "ABI v2 plugin host is unavailable".to_string()) + .and_then(|host| { + host.dispatch_settings_change(resource_id, key, value) + .map_err(|error| error.to_string()) + }); + match result { Ok(()) => { self.plugin_settings_error = None; self.apply_plugin_setting_value(resource_id, key, value); diff --git a/src/window/settings/pages/plugins.rs b/src/window/settings/pages/plugins.rs index 54250f5a..bbca12a3 100644 --- a/src/window/settings/pages/plugins.rs +++ b/src/window/settings/pages/plugins.rs @@ -3,13 +3,13 @@ use std::collections::HashMap; use winisland_render::FontStyle; use winisland_render::{Image, ImageOptions, Mipmapped, Painter, Point, Radius, Rect, Rgba, Vec2}; -use crate::plugin::manager::InstalledPlugin; -use crate::plugin::marketplace::MarketplacePlugin; +use crate::plugin::inventory::InstalledPlugin; use crate::utils::color::SettingsTheme; use crate::utils::color::settings_color; use crate::utils::settings_ui::items::{CONTENT_PADDING, SettingsItem}; use crate::utils::settings_ui::{SettingsPainter, ellipsize_text}; use winisland_core::i18n::tr; +use winisland_plugin_package::marketplace::MarketplacePlugin; use winisland_render::DrawingContext; use winisland_render::text::FontManager; @@ -425,7 +425,7 @@ impl SettingsApp { if plugin.revoked_reason.is_some() { return MarketplaceAction::Revoked; } - if !plugin.is_compatible() { + if !plugin.is_compatible(winisland_core::config::APP_VERSION) { return MarketplaceAction::Incompatible; } match self diff --git a/src/window/settings/pages/plugins/detail.rs b/src/window/settings/pages/plugins/detail.rs index 098de029..92017c6d 100644 --- a/src/window/settings/pages/plugins/detail.rs +++ b/src/window/settings/pages/plugins/detail.rs @@ -1,11 +1,11 @@ use winisland_render::{Painter, Point, Radius, Rect, Rgba, Vec2}; -use crate::plugin::manager::InstalledPlugin; -use crate::plugin::marketplace::MarketplacePlugin; +use crate::plugin::inventory::InstalledPlugin; use crate::utils::color::SettingsTheme; use crate::utils::color::settings_color; use crate::utils::settings_ui::{SettingsPainter, ellipsize_text}; use winisland_core::i18n::tr; +use winisland_plugin_package::marketplace::MarketplacePlugin; use winisland_render::text::FontManager; use super::super::super::{ diff --git a/src/window/settings/renderer.rs b/src/window/settings/renderer.rs index 1d227bd9..bc6fd9b5 100644 --- a/src/window/settings/renderer.rs +++ b/src/window/settings/renderer.rs @@ -1,4 +1,4 @@ -use crate::ui::expanded::widget_view::draw_plugin_widget; +use crate::ui::expanded::widget_view::draw_prepared_widget; use crate::ui::widget::expanded::draw_mini_card; use crate::utils::color::SettingsTheme; use crate::utils::settings_ui::items::{POPUP_ITEM_H, SettingsItem}; @@ -6,7 +6,9 @@ use crate::utils::settings_ui::{ ActiveStepperValue, DrawItemsParams, SettingsPainter, WidgetSource, draw_items, ellipsize_text, settings_color, widget_grid_geom, widget_source_span, }; +use std::collections::HashMap; use winisland_core::i18n::tr; +use winisland_plugin_host::draw::replay::PreparedFrame; use winisland_render::Renderer; use winisland_render::text::{DrawTextCachedParams, FontManager}; use winisland_render::{Painter, Path, Point, Radius, Rect, Rgba, StrokeCap, StrokeJoin, Vec2}; @@ -115,6 +117,21 @@ impl SettingsApp { } self.ensure_items_cache(); + let plugin_frames = self + .plugin_host + .as_ref() + .map(|host| { + self.plugin_widgets + .iter() + .filter_map(|widget| { + host.prepare_widget_frame(widget.id) + .ok() + .flatten() + .map(|frame| (widget.id, frame)) + }) + .collect::>() + }) + .unwrap_or_default(); let theme = self.theme(); let win_w = self.win_w / scale; let win_h = self.win_h / scale; @@ -187,6 +204,7 @@ impl SettingsApp { widget_layout: &self.config.widget_layout, plugin_widget_layout: &self.config.plugin_widget_layout, plugin_widgets: &self.plugin_widgets, + plugin_frames: &plugin_frames, widget_dragging: self.widget_dragging.as_ref(), widget_drag_hover_slot: self.widget_drag_hover_slot, widget_preview_hover_slot: self.widget_preview_hover_slot, @@ -219,7 +237,7 @@ impl SettingsApp { self.draw_plugins_page(drawing_context, painter, &theme, win_w, win_h); } - self.draw_widget_drag_overlay(painter, win_w, win_h); + self.draw_widget_drag_overlay(painter, win_w, win_h, &plugin_frames); self.draw_resource_editor(painter, &theme, win_w, win_h); self.draw_popup(painter, &theme); painter.restore(); @@ -252,7 +270,13 @@ impl SettingsApp { None } - fn draw_widget_drag_overlay(&self, painter: Painter<'_>, win_w: f32, win_h: f32) { + fn draw_widget_drag_overlay( + &self, + painter: Painter<'_>, + win_w: f32, + win_h: f32, + plugin_frames: &HashMap, + ) { let lift = self.widget_drag_lift_progress.clamp(0.0, 1.0); let lift_scale = 0.94 + 0.06 * (1.0 - (1.0 - lift).powi(3)); if self.widget_editor_mode == WidgetEditorMode::Compact { @@ -326,12 +350,9 @@ impl SettingsApp { .plugin_widgets .iter() .find(|widget| widget.layout_id().as_ref() == Some(id)) + && let Some(frame) = plugin_frames.get(&widget.id) { - let span = widget.span(); - let logical_width = (span.0 as f32 * 60.0).max(1.0); - let logical_height = (span.1 as f32 * 48.0).max(1.0); - let scale = (w / logical_width).min(h / logical_height).min(1.0); - draw_plugin_widget(painter, widget, x, y, w, h, scale, 255); + draw_prepared_widget(painter, widget.id, frame, x, y, w, h, 255); } } } diff --git a/src/window/settings/sidebar.rs b/src/window/settings/sidebar.rs index 5f166a58..1518d645 100644 --- a/src/window/settings/sidebar.rs +++ b/src/window/settings/sidebar.rs @@ -73,7 +73,7 @@ pub(super) fn clear_plugin_settings_icon_cache() { fn draw_plugin_settings_icon( drawing_context: &mut DrawingContext<'_>, painter: Painter<'_>, - page: &crate::core::plugin_settings::PluginSettingsPage, + page: &winisland_core::plugin_settings::PluginSettingsPage, rect: Rect, ) { if page.icon.is_empty() {