Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 53 additions & 48 deletions .agents/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
```

Expand Down Expand Up @@ -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.
Expand All @@ -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.

---

Expand Down
78 changes: 73 additions & 5 deletions .github/workflows/publish-crates.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Publish plugin API crate
name: Publish plugin crates

on:
push:
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
37 changes: 33 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" }
Expand Down
Loading
Loading