4d4166dadc
The shell registers as iwd's agent and as BlueZ's default agent (KeyboardDisplay), each only while its daemon runs (GetNameOwner, never a call that would start it), and again after a restart. RequestPassphrase, RequestPinCode, RequestPasskey, RequestConfirmation, RequestAuthorization, DisplayPinCode and DisplayPasskey show a small popup over the control centre that takes the keyboard (Enter answers, Escape cancels); the answer is the reply to the daemon's call. Amane can only reply with success, so Cancel leaves the call unanswered and cancels the pairing or the joining. New Wi-Fi networks with a password are clickable on the map now; IPC control prompt answer|cancel.
199 lines
15 KiB
Markdown
199 lines
15 KiB
Markdown
# Magic Island: an Amane media widget
|
||
|
||
A Dynamic-Island-style media widget for Amane (Rust Wayland shell library), running on Hyprland. It sits as a small pill at the top centre of the screen and opens into a now-playing card.
|
||
|
||
- **Pill:** cover thumbnail, title, 7 live bars from the shown player's own sound (lows left, highs right, growing up and down from the middle), or a pause sign. With no player open, an idle pill: a music note and "Nothing playing".
|
||
- **Card:** a still CD of the cover ringed by 40 cava "candles", title and artist, a seekable time bar, previous / play-pause / next.
|
||
- **Player switcher** ("VIA …" chip): every MPRIS player; picking one pins it, otherwise the island follows whichever plays.
|
||
- **Output switcher:** moves only the shown player's audio to another device.
|
||
- **Per-player volume** (scroll the volume chip, click to mute), set on that player's own PulseAudio/PipeWire streams, so it also works for browsers that ignore MPRIS volume.
|
||
- **Real 10-band equalizer per player** (31 Hz to 16 kHz, ±12 dB): a PipeWire filter-chain per player, with 8 presets (Flat, Bass, Treble, Vocal, Pop, Rock, Jazz, Classic). Curves are saved per player.
|
||
- **Background:** a diagonal gradient in the wallpaper's colours (see Colours); optionally the open card takes the song cover's colours.
|
||
- **Opening animation:** the pill fades out, the empty shape grows, then the card fades in. Closing runs backwards. Moving the pointer off the open card closes it.
|
||
- **Every monitor:** each monitor shows the pill. The card opens only on the monitor you click (a keybind opens it on the focused one), and only the focused monitor's pill shows live bars; the others show still bars, which keeps the cost at about one island's.
|
||
- **Workspace pill:** top left on every monitor, that monitor's Hyprland workspaces as round numbered slots in the island's look. The focused workspace takes the accent colour, the one shown on another monitor a softer accent, empty ones dim. Click one to switch to it.
|
||
- **Control centre:** top right on every monitor, a pill with Wi-Fi (or the hotspot), VPN, Bluetooth and the battery. Click it and it opens, in the island's way, into a panel like macOS's Control Center. See [Control centre](#control-centre).
|
||
|
||
## Control centre
|
||
|
||
The pill shows only what runs: Wi-Fi signal while iwd runs (dim while its radio is off; the hotspot sign instead while it's on), the VPN lock only while a VPN connects or is connected (accent once connected), Bluetooth while bluetoothd runs (dim while powered off, accent with a device connected), and the battery with its percentage (a bolt while charging, red below 15 %). The pill is as wide as what it shows.
|
||
|
||
The panel has:
|
||
|
||
- **Four tiles:** Wi-Fi, Bluetooth, VPN, Hotspot. Accent = on; grey with a grey strike through the icon = its service isn't running (iwd, bluetoothd, the openvpn service); grey without the strike = nothing to work with (no device, hotspot not set up). Wi-Fi, Bluetooth and VPN open their menu when clicked anywhere; Hotspot switches at once.
|
||
- *Wi-Fi* (iwd): iwd stopped → grey; the switch in its menu runs `rc-service iwd start`. iwd running → the switch turns the device (station) on or off. The menu is a map: this desktop as a bubble in the middle (its hostname under it), every network in range floating around it, nearer the stronger its signal, rings rippling out while iwd scans. Each is tied to the bubble by a line: grey and wobbling while not connected; after a click the line runs out from the bubble to it until it's connected; then solid, with a pulse travelling along it. Click a saved or open network to join it, the connected one to disconnect. A new network with a password asks for it in a small popup (see [Passwords and PINs](#passwords-and-pins)); enterprise (802.1x) networks still need `iwctl` once. "Stop the Wi-Fi service" at the bottom runs `rc-service iwd stop`. The shell never wakes iwd by asking it anything: it checks `NameHasOwner` first, because any D-Bus call to `net.connman.iwd` starts it.
|
||
- *Bluetooth* (BlueZ, through Amane's `Bluetooth`): the switch powers the adapter; with bluez installed but stopped it runs `rc-service bluetooth start`. The menu is the same map with the devices bluez knows or finds (it scans while open): connected ones closest, then paired, then the rest. Click to connect, disconnect, or pair; a device that needs a PIN, a passkey or a confirmation asks in the popup.
|
||
- *VPN*: every openrc service `/etc/init.d/openvpn.*` (here `openvpn.swiss`), each with a switch. Connecting while openrc has it *inactive*, connected once its `up.sh` marks it *started*. A switch runs `rc-service openvpn.<name> start|stop`.
|
||
- *Hotspot* (iwd access point): see [Hotspot setup](#hotspot-setup).
|
||
- **Menus:** each opens in a window of its own over the panel and grows out of its tile; the panel takes the menu's size underneath (the Wi-Fi and Bluetooth maps are 820 px wide, more than twice the panel), so the two lie on top of each other. The back button returns to the panel. Moving the pointer off both closes the menu where it is: its content fades out and it shrinks straight into the pill, without the panel growing back first.
|
||
- **Sound:** the default output's volume (drag or scroll the slider); the value next to the title is the mute button. With `extra_output` in `shell.conf` a second slider follows for the output whose name contains it (here `NR515`: the `[TX-NR515]` receiver on HDMI 1). Clicking the block elsewhere opens the Sound menu: every output with its own slider and mute; click a name to make it the default output.
|
||
- **Display:** the laptop screen's brightness and the keyboard light (`tpacpi::kbd_backlight`: Off, Low, High), written straight to their sysfs files (a udev rule makes them writable by everyone on this laptop), else through logind, which refuses a shell without a seat (one started over ssh). For external monitors see [External monitor brightness](#external-monitor-brightness).
|
||
- **Performance:** the firmware profiles in `/sys/firmware/acpi/platform_profile_choices` (Low power, Balanced, Performance), and the battery's state and time left. Picking one sets the platform profile and the CPU governor and energy preference with it: Low power = `powersave` + `power`, Balanced = `powersave` + `balance_performance`, Performance = the `performance` governor.
|
||
- **External GPU:** the GPU the machine didn't boot with (`boot_vga = 0`, here the Radeon RX 6600 in the Thunderbolt enclosure): load, temperature, power draw, video memory, and every program that has it open with its video memory (and its share of the GPU while the driver reports engine time). Found through `/proc/<pid>/fdinfo` (`drm-pdev`), only while the panel is open. Root's processes can't be read and don't show; the shell itself shows up, because Vulkan opens every GPU.
|
||
|
||
Changes that need root (starting services, the performance mode) run as `doas -n <fixed command>`, so they need a doas rule without a password for these commands (here `permit nopass fabian`). Nothing from other programs is passed to a shell.
|
||
|
||
```sh
|
||
amane ipc call control toggle # also: open, close
|
||
amane ipc call control menu wifi # also: bluetooth, vpn, sound, close
|
||
amane ipc call control prompt cancel # or: prompt answer <text>, for the popup's question
|
||
amane ipc call control status # expanded=<bool> menu=<name|none> wifi=<state> vpn=<state> profile=<p> battery=<n%>
|
||
```
|
||
|
||
### Passwords and PINs
|
||
|
||
The shell registers itself as iwd's agent and as BlueZ's default agent (capability `KeyboardDisplay`), each only while its daemon runs. When joining a network or pairing a device needs something from you, the daemon asks the shell, and a small popup shows over the control centre:
|
||
|
||
| Asked for | Popup |
|
||
|---|---|
|
||
| a Wi-Fi password (iwd `RequestPassphrase`) | a password field; Join needs 8 to 63 characters |
|
||
| a Bluetooth PIN (`RequestPinCode`) or passkey (`RequestPasskey`) | a field for it |
|
||
| "does this code match?" (`RequestConfirmation`) | the code, large; Pair or Cancel |
|
||
| "may it pair?" (`RequestAuthorization`) | Pair or Cancel |
|
||
| "type this on the device" (`DisplayPinCode`, `DisplayPasskey`) | the code and a dot per digit typed on the device; it closes once the device is paired |
|
||
|
||
The popup takes the keyboard while it shows: Enter answers, Escape cancels. The answer goes back to the daemon as the reply to its question. Cancel cancels the pairing (`CancelPairing`) or the joining (`Station.Disconnect`, only while iwd is joining that network). A paired device asking to use one of its services (`AuthorizeService`) is allowed without asking: pairing it was the yes.
|
||
|
||
### Hotspot setup
|
||
|
||
iwd turns the Wi-Fi device into an access point, so the laptop isn't a Wi-Fi client while the hotspot is on (the internet then comes from `eth0`). Once, as root:
|
||
|
||
1. Let iwd hand out addresses, in `/etc/iwd/main.conf`. This also makes iwd configure IPv4 for Wi-Fi as a client:
|
||
|
||
```ini
|
||
[General]
|
||
EnableNetworkConfiguration=true
|
||
```
|
||
|
||
2. A profile `/var/lib/iwd/ap/<Name>.ap` (`man iwd.ap`):
|
||
|
||
```ini
|
||
[Security]
|
||
Passphrase=at-least-eight-characters
|
||
|
||
[IPv4]
|
||
Address=192.168.80.1
|
||
DNSList=<your router, or a public resolver>
|
||
```
|
||
|
||
3. `hotspot = <Name>` in `shell.conf` (applies live).
|
||
4. To share `eth0`'s internet with the hotspot's devices, forwarding and NAT (iwd doesn't do this), e.g. with nftables:
|
||
|
||
```sh
|
||
sysctl -w net.ipv4.ip_forward=1
|
||
nft add table ip hotspot
|
||
nft 'add chain ip hotspot post { type nat hook postrouting priority 100 ; }'
|
||
nft add rule ip hotspot post ip saddr 192.168.80.0/24 oifname eth0 masquerade
|
||
```
|
||
|
||
Clicking the tile switches the device to access-point mode and calls `StartProfile(<Name>)`; if that fails (no such profile) it switches back to a client. The tile shows the profile's name, then how many devices are connected.
|
||
|
||
### External monitor brightness
|
||
|
||
External monitors take brightness over DDC/CI (VCP code `10`), which `ddcutil` speaks. Not wired in yet; to try it:
|
||
|
||
1. `emerge --ask app-misc/ddcutil`
|
||
2. Load `i2c-dev`: `modprobe i2c-dev`, and to keep it, `modules="i2c-dev"` in `/etc/conf.d/modules`.
|
||
3. Access without root: ddcutil's udev rule gives the `/dev/i2c-*` buses to group `i2c`; `gpasswd -a fabian i2c`, then log in again.
|
||
4. Turn on DDC/CI in each monitor's on-screen menu, if it has the option.
|
||
5. `ddcutil detect` lists the monitors; `ddcutil --display 1 getvcp 10` reads one's brightness, `ddcutil --display 1 setvcp 10 60` sets it.
|
||
|
||
Each call takes 50–300 ms, so a slider has to send only where the drag ends. Monitors behind docks or MST hubs often don't pass DDC; the eGPU's own DP and HDMI ports usually do. Once `ddcutil detect` works, the Display block can get one slider per external monitor.
|
||
|
||
## Colours
|
||
|
||
The shell takes its colours from the wallpaper through matugen. Skwd-wall v2 runs matugen on every new wallpaper and renders `matugen/amane.colors` into `~/.config/amane/theme`; the shell picks the file up the moment it changes (inotify through `amane::watch_file`) and cross-fades to the new colours. Without the file it keeps its original pink on purple; deleting it takes effect at the next start.
|
||
|
||
Register the template in Skwd-wall as a matugen integration:
|
||
|
||
- template: `~/.config/amane/matugen/amane.colors`
|
||
- output: `~/.config/amane/theme`
|
||
|
||
The Material You scheme type is Skwd-wall's setting (`matugen.schemeType`).
|
||
|
||
`shell.conf` (read live, no restart):
|
||
|
||
```
|
||
cover_colors = false # true: the open media card and equalizer take the song cover's colours as background
|
||
transition_ms = 400 # how long a colour change cross-fades; 0 switches instantly
|
||
hotspot = MyHotspot # the iwd access point profile the control centre's hotspot starts
|
||
extra_output = NR515 # a second Sound slider for the output whose name contains this
|
||
```
|
||
|
||
Keep comments on lines of their own in the real file: a `#` after a value becomes part of it.
|
||
|
||
## Layout
|
||
|
||
```
|
||
src/main.rs app setup: Fira Code font, the `island` IPC handler, palette from the cover
|
||
src/island.rs the view: pill, card, ring, equalizer, dropdowns, animation, IPC commands
|
||
src/sound.rs per-player volume, mute, output and equalizer (pactl, pw-cli, pipewire)
|
||
src/spectrum.rs the shown player's sound as 24 levels (pw-record | cava)
|
||
src/art.rs cover download (curl) and the palette file
|
||
src/workspaces.rs the workspace pill, top left on every monitor
|
||
src/control.rs the control centre: status pill top right, its panel and menus, `control` IPC
|
||
src/map.rs the Wi-Fi and Bluetooth menus' map: this desktop in the middle, what it can connect to around it
|
||
src/agent.rs iwd's and BlueZ's agent: asks for passwords, PINs and passkeys in a popup
|
||
src/system.rs what the control centre shows: iwd, openvpn, power, the external GPU
|
||
src/glyph.rs every drawn icon, as a Canvas or as shapes for a shared one
|
||
src/theme.rs the shell's colours: the matugen theme file, shell.conf, the cross-fade
|
||
src/parse.rs pure text parsing with unit tests, no amane dependency
|
||
matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme
|
||
shell.conf settings: cover_colors, transition_ms, hotspot, extra_output
|
||
island-eq saved equalizer curves (a snapshot of ~/.config/amane/island-eq)
|
||
tools/ remote test driver and fake MPRIS players, see AGENTS.md
|
||
```
|
||
|
||
## Requirements
|
||
|
||
On the desktop that runs the shell:
|
||
|
||
- the `amane` CLI and Rust toolchain (`amane compile` builds `src/`)
|
||
- PipeWire with `pipewire-pulse`: `pactl`, `pw-record`, `pw-cli`, `pipewire` (filter-chain module)
|
||
- `cava`, `curl`
|
||
- the Fira Code font
|
||
- for the control centre: `iwd`, openrc `openvpn.<name>` services, `pciutils` (`lspci`), optionally `bluez`, and a doas rule without a password for `rc-service` and `sh` (see [Control centre](#control-centre))
|
||
|
||
## Install and run
|
||
|
||
The shell lives in `~/.config/amane/`. Clone this repo there (or copy its `src/`, `matugen/` and `shell.conf`), then build and start:
|
||
|
||
```sh
|
||
amane compile
|
||
amane run
|
||
```
|
||
|
||
`amane compile` saves the shell as `~/.cache/amane/amane-shell`, which `amane run` starts.
|
||
|
||
## Control it
|
||
|
||
```sh
|
||
amane ipc call island toggle # also: open, close
|
||
amane ipc call island next # show the next player
|
||
amane ipc call island play-pause # play/pause the shown player; the icon flips at once
|
||
amane ipc call island menu output # open a dropdown: output | player | close
|
||
amane ipc call island preset Rock # apply an equalizer preset to the shown player
|
||
amane ipc call island output <sink> # move the shown player to a sink (pactl sink name)
|
||
amane ipc call island status # player=<identity> expanded=<bool> menu=<bool>
|
||
```
|
||
|
||
Example Hyprland keybind:
|
||
|
||
```
|
||
bind = SUPER, M, exec, amane ipc call island toggle
|
||
```
|
||
|
||
## Runtime files
|
||
|
||
- `~/.config/amane/island-eq`: one line per player identity, `<player>\t<preset or Custom>\t<10 gains in dB>`.
|
||
- `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-<key>.conf`. On tmpfs, gone at logout.
|
||
- Equalizer nodes are named `magic_island_eq.<key>`. The key is the player's MPRIS bus name without `org.mpris.MediaPlayer2.`, non-alphanumerics as `_` (e.g. `chromium_instance123`).
|
||
|
||
## Known limits
|
||
|
||
- The time bar moves in 1 s steps (the MPRIS poll rate).
|
||
- Every slider step starts one `pactl` or `pw-cli` process.
|
||
- An equalizer keeps running, and keeps that player routed through its last curve, if the shell stops while the player is open.
|
||
- The candle ring around the CD is still mirrored left against right; the pill's bars are not.
|
||
- Dropdowns have no keyboard navigation.
|