Skip to content

Sill Viewer: Dock badges, bounces and minimised windows in the remote apps' Dock icons; full speed in the background (builds on #66) - #72

Open
ashleigh-hopkins wants to merge 103 commits into
Saffsanity:mainfrom
ashleigh-hopkins:notifications-spike
Open

ashleigh-hopkins wants to merge 103 commits into
Saffsanity:mainfrom
ashleigh-hopkins:notifications-spike

Conversation

@ashleigh-hopkins

@ashleigh-hopkins ashleigh-hopkins commented Oct 13, 2026 •

Copy link
Copy Markdown

Builds on #66 (Sill Viewer). Until #66 merges, GitHub shows its commits here too; this PR's own are the ones after it:

  • c354167 Docs: notifications spike, what the host can read of banners and Dock bounces and what the viewer can post
  • 1f799ff Viewer: each app's Dock badge in the menu bar menu, and a rising badge bounces its Dock icon
  • 3fd389a Dock bounces forwarded: the host reads which shown apps ask for attention
  • 157d397 Viewer: windows minimise into their app's Dock icon
  • 92cd03c Viewer: a bolt in a window's pill keeps it at full speed in the background
  • 72da1cf Viewer: each app's Dock icon holds the other Mac's menus for it and takes the menu bar while one of its windows is in front, handing the keyboard straight back
  • e430015 Viewer: the menu bar goes to the Dock icon once the window is key, the app active and no button down
  • 4ccc738 Viewer: menus that reach an app's Dock icon after it took the menu bar
  • 3d88c01 Viewer: the Dock icon takes the menu bar whenever Sill Viewer comes forward or the front app changes, whoever macOS names as its owner
  • 53c87e3 Review cleanup: the viewer's menu-bar hand-over in one place
  • 6e65587 Viewer: the menu bar's hand-over compares only the front window's own app's menus

Why

Whether Sill Viewer could forward the host's notifications and Dock bounces, measured before building the forwarding: docs/notifications-spike.md. Every probe was a throwaway and has been removed. Five pieces are built on it.

Built

  • Badges in Sill Viewer's menu bar menu. Each app carries its Dock badge from the other Mac, as a native menu item badge. Seen live with real badges and with a test app counting up.
  • Dock bounces forwarded. Sill.app reads which apps with a window listed are bouncing their Dock icon (LaunchServices' LSWantsAttention, every 250 ms while a Mac viewer is connected; no permission) and sends them as WindowList.attention, a new optional field an older viewer ignores. The app's Dock icon on the viewing Mac bounces while the host's does. Seen between two Macs: a test app's one-time and repeating bounces both reached the viewer's Dock icon.
  • Windows minimise into their app's Dock icon, and Sill Viewer has no Dock icon. macOS minimises a window only into its own app's icon, and every viewer window is Sill Viewer's, so with "Minimise windows into application icon" on they all went into Sill Viewer. Now the pill's minimise puts the window away and the app's Dock icon helper minimises a stand-in showing the window's last picture (its own window captured, no permission; a 0600 PNG, deleted when the window comes back). Restoring the stand-in, picking the window in the menu, or clicking the Dock icon with every window minimised brings the real window back. While the per-app Dock icons are on (the default), Sill Viewer runs as an accessory app: no Dock icon or ⌘Tab entry of its own, its menu bar held by the remote apps' Dock icons (below). Desktop windows minimise as macOS does. Seen on both Macs: each restore path, the stand-in and picture gone after, the menu bar with the remote app's menus.
  • The remote app's menus in its Dock icon's menu bar. An accessory app has no menu bar: the bar stays with the regular app active before it. So each app's Dock icon holds the other Mac's menus for that app. While one of its windows is in front here, Sill Viewer has the icon take the menu bar (it is activated and hands activation straight back, so the keyboard stays with the window), when Sill Viewer comes forward, when the front app changes, and when that app's menus change. Each menu is asked for as it opens ("Loading…" until the other Mac answers, through Sill Viewer), and a choice is pressed there. The top level and the menu opened last are in 0600 files beside the icon, and requests are signed as the icon's others are. Seen between two Macs: the menus following clicks between windows, the Dock, ⌘Tab and a window restored from its Dock icon.
  • Full speed in the background. A bolt in a window's pill (lit in the accent colour, grey off) keeps that window at the main stream's rate when another is in front; the others get 15 fps. Two at most, since each costs the host's encoder and the link as much as the main stream; a third mark takes the oldest's and the pill says which went back. A new optional SideStreamRequest.fullRate, sent only to a host whose window list says fullRate. The pill's lights and bolt are now Accessibility elements. Seen between two Macs: a marked window in the background at 120 fps and 30 Mbps, unmarked at 15 fps and 3 Mbps.
  • A rising badge bounces the app's Dock icon here once (SillDockProxy, an informational request). The first state, and a badge that falls or goes, do not bounce. Seen with the test app: one 1 s bounce for each of three rises.

Findings (macOS 27.0.1; the Dock bounce also on 27.0)

  • Notification banners can be read through Accessibility on Notification Center's process, about 120 ms after the post, with no permission beyond the Accessibility the host already has. Passive and Focus-silenced notifications never appear.
  • Dock bounces can be read through LaunchServices. A bouncing app's information carries LSWantsAttention 1 while it asks for attention: briefly for a one-time bounce, until cancelled or activated for a repeating one. Two private, exported calls read it, with no permission at all. The first pass missed this: it took request ID 0 for a failure, and looked only at Accessibility and notifications.
  • The viewer can post under the remote app's name from the Dock icon copies. Each copy asks for permission once, and a click reaches the copy.

Recommendation

  • Build banner forwarding, opt-in with a per-app allow list and a self-test.

Not yet tried: a third full-speed mark replacing the oldest; a real app's bounce (the test apps go through the same AppKit call), plus the notification cases the doc lists.

…s its own

The Mac client (Sources/MacClient, product SillClient, Scripts/make-client-app.sh): the iOS
session code compiled unchanged from iOSClient through Shared/ symlinks, with ClientPlatform and
HEVCSampleFeed shared by both apps. Each host window opens as a borderless window of its own,
streamed at once (side streams, kinds 29–31), with a handle for this Mac's controls, the remote
app's menus in the menu bar, a Dock icon per remote app, the clipboard both ways (kind 32), sound
(kind 33) and files dropped in and dragged out (kinds 34–37). Input: pointer and all three buttons,
trackpad scrolling with momentum, keys, ⌘Tab and ⌘Space when captured, gestures as the host's
shortcuts.

The host keeps those windows on one shared hidden virtual display (DeskStages), sized to the
viewer's largest screen, taps the shown apps' sound and mutes it there (AppAudio), and takes and
gives files (FileDrops, DragOut). Every addition goes only to a device that asks or a host that
says it takes it; the iOS app is unchanged on the wire. The iPhone build from Xcode 27.0 can leave
the 27.1 hinge API out (SILL_NO_DUO_SDK). New checks: mac-keys and main-router; protocol,
compatibility, menus and pointer-control know kinds 29–37.
…wer window

Two virtual displays with one vendor and product ID are one monitor to macOS, which takes the
older offline when the newer comes; that, not a hardware limit, is why windows on displays of
their own fell back to the host's screen. Each window's display now has its own identity from a
pool (product 0x100 + slot, a serial kept per slot, so macOS remembers how to show each), and the
shared display is gone: a window going full screen takes only its own display, never the other
streamed windows.

While a window is full screen, its display takes the size of the device's window live
(VirtualDisplay.resize keeps the display and its ID), so the full-screen Space fills the viewer
window and nothing more; leaving full screen gives the display back its size.
A display fitted to a full-screen window is smaller than it was made, and the next placement read
that as too small and made it again, which threw the window out of full screen and round again
(black, the sound skipping). A display is reused up to the size it was made at, and a window is
fitted from wherever it streams: to the device's window as the main stream, to the size it had
before full screen beside it.

Each window's display is 2× again unless the window itself is larger than a 2× display can be:
the device's widest screen made every display 1× before, soft on a Retina screen.
…de once, back to 2×

A host file dragged out of one Sill window and dropped on another is dropped on the host from
where it is (FileDrop's new `offer` and `window`): a real drag of the real file, so Finder moves
it as it would there, and nothing goes to this Mac and back. Every Sill window takes drops: files
from this Mac on a window beside the streamed one land in that window.

A window's display was made twice: its first size was the device's screen, the placement then
asked for that plus room for the menu bar and made it again. It is made once, at the placement's
size. A window larger than a 2× display gets a 1× one, and once it is 5 % under the 2× size
again it goes back to 2×.
… windows

A full-screen window is its display's size, and that size was taken as the window's: the display
was made bigger, which threw the window out of full screen and round again. A window full screen
(by Accessibility's word, from the first placement) is sized by the size it had before, and
fitted to the device's window only by a size its display can take.

The viewer opens no app's untitled strips by itself (a browser's full-screen bar). A drag out of a
window counts as leaving it when another window is under the pointer, not only outside its
rectangle: a drop on a Sill window lying over the first one went back into the first.
A window full screen on its desk display cannot be moved home, and when the display went its Space
went to another of Sill's displays, out of sight: a browser looked closed and would not come back
after a quit. Leaving the desk now takes it out of full screen first (1.5 s at most), at Quit too.
Only windows on screen were listed, so a window in another Space (a full-screen one, another
desktop) could not be reached from a device: a browser stuck full screen on a display out of sight
looked gone. Titled windows of regular apps out of sight are listed as well, from the full list the
catalog already fetched for its staged windows (the log line for those is gone with it).
…background

The streamed desk window going into or out of full screen is seen by a 0.25 s look at it, where
the catalog's 2 s poll left the full-screen window at the wrong size for seconds. A full-screen
window shown beside the streamed one keeps the size its display was fitted to: it fell back to its
size before full screen, a smaller picture, as soon as another window was clicked. The viewer logs
each step of a drop on one of its windows.
A right button whose up never reached the host stayed down there: every move was its drag and
left clicks read as right clicks until the next right click. A button press now lets go of any
other button the host still holds first, and says so in the log.
docs/mac-client-plan.md is the design and justification as the branch stands (the wire, Sill's
desk, sound, files, security notes, what was verified, what is open); CLAUDE.md's entry and Layout
and DEVELOPMENT.md's section follow it. The protocol check covers every new payload (round-trips
and refusals, 209 checks, 5 new mutants, 28 of 28 caught).

A drag offer serves only the device it was made to, and a file's parts are taken only from the
device that started it; both are dropped when that device leaves (an open file handle and the drag
watch's timer outlived it). The viewer's menu loses Virtual Display, and a Mac viewer's windows
always go to Sill's desk rather than the iPad's staging. The desk display's functions lose the
"shared" names left from the shared-display phase. One compiler warning fixed.
A folder dropped on a Sill window, or dragged out of one, goes as one zip (FolderZip: ditto, the
archive Finder makes, with resource forks, extended attributes and symbolic links) and is unpacked
where it lands: on the host beside the dropped files, here where the promise is fulfilled. A folder
dragged out onto the Trash goes to the host's Trash whole. The protocol check zips and unpacks a
folder with a nested file and a link (215 checks).
…Sill windows

Between two windows of one size the router tells the new stream apart only by the host's word that
it switched, which reached it on the main thread, after the new stream's first sets could: the
new window's picture went into the old one and stayed while nothing there redrew (two Finder
windows swapping). The window list's source now marks the switch on the network queue, in the
stream's own order (MainRouter.listed; main-router 21 checks, 12 of 12 mutants).

A drag out allowed no operation inside Sill Viewer, so another Sill window was never offered the
drop (no +): it allows copy and move there now, and the other Mac drops its own files.
…o longer takes them)

NSImageView registers for file and image drags and, not editable, refuses them; as the deepest
view under the pointer it took every drag meant for the window's DropContainer, so another Sill
window never offered a drop.
A drop used to reach the other Mac only where it was let go: its app saw no drag, so it could not
show where the files would land (a folder, a web page's drop zone, the address bar), and this Mac's
modifier keys did nothing. Now, to a host whose list says `liveDrag`:

- The host's own files, dragged out of one of its windows: its drag stays under way, the button
  held. Over any Sill window its pointer follows onto that window (each on its own desk display);
  let go there, it lands as a drag of its own (Finder moves, ⌥ copies, ⌥⌘ makes an alias). Let go
  anywhere else (this Mac's Finder, the Trash), the host gives its drag up with Esc.
- This Mac's files over a Sill window: sent at once, the host starts its own drag of them under the
  pointer as they arrive and follows it; dragged away for 0.35 s, given up and the copies removed.

Host: LiveDrags (through the coordinator's deliver: the injector's held buttons and keys, the
modifier keys pressed as the device holds them), FileDrops' panel and received files split out.
Viewer: LiveDrop, the stream view's and DropContainer's drag hooks, the upload split from the drop.
A host without it gets kind 35 at the drop, as before. Checks: protocol (LiveDrag round-trips, the
list's flag), compatibility, menus and pointer-control (38 taken, 39 unknown; their mutants that
renumbered a kind onto 38 use 39).
…n the Dock

- The remote app's icon carries Sill Viewer's in its corner, so it never reads as this Mac's app;
  written again when the icon arrives after the Dock icon started (it used to stay plain).
- The Dock menu marks the windows as macOS marks an app's own: ✓ the app's front window, ◆ one
  minimized; it follows focus and minimizing.
- Kept in the Dock and clicked with nothing open: the copy knows its app (SillRemoteApp in its
  Info.plist), opens Sill Viewer if need be and asks for the app, whose windows on the other Mac
  open here, or which is launched there when it has none.

Docs: the plan and CLAUDE.md name kind 38 and the live drag, its verification and what is open.
Beside the streamed window no fit is asked; the fallback to the size before full screen was taken
before the check that keeps a fitted display, so the display shrank and the video filled a quarter
of its window, pinned to a corner, whenever another Sill window took focus.
…ts windows, the icon kept

- Kind 39 (DockMenu, `dockMenu` in the list): the host reads an app's own Dock menu (Finder's New
  Finder Window…, a chat app's New Chat) through Accessibility, which has it only while the Dock shows
  it, so it shows for about half a second there; read without the app's windows and the Dock's own
  items, and pressed by its place and title. The Dock icon here adds it under its windows, read
  again at most every 20 s as its menu opens.
- Quit from a Dock icon closes that app's windows here (the app runs on there), where it left them
  open.
- The badged icon is also the copy's own (AppIcon.icns, signed again), so one kept in the Dock keeps
  its icon while not running instead of a blank one.
- A full-screen window in the background is captured from its display: an app reports its
  full-screen window far wider than its Space, and captured alone the video was a quarter of the
  picture, in a corner.

Checks: protocol (DockMenu round-trips), compatibility, menus and pointer-control (39 taken, 40
unknown).
… not a count

- A device asks for the sound compressed (SideStreamRequest.audioCodec "aac-eld"): the host encodes
  the tapped apps' sound as AAC-ELD, about 96 kbit/s at 48 kHz stereo instead of plain PCM's
  1.5 Mbit/s, for about 15 ms more delay; a compressed kind 33 carries the decoder's cookie and is
  never read as plain samples (the channels byte's high bit). Sill Viewer asks for it unless
  `SillClient.plainAudio` is set; a host that cannot encode, or a device that asks for none, gets PCM.
- Sill's desk takes a new window while its displays leave room for one more within a quarter of this
  Mac's memory (each display holds its own framebuffer, about 130 MB at the largest Retina size);
  display identities are taken as needed, no longer a pool of 16.

Checks: protocol (the compressed packet, the codec asked for).
… the background; full-screen windows reachable after a restart

- A button let go where the stream view never hears of it (it moved to another window while the
  button was held, as the host switched streams) went nowhere, and the other Mac kept it down: every
  later click read as that button's until it was pressed again (a left click acting as a right
  click). Every up in the app is looked at first, and one the view would miss is sent from there; a
  button the view thinks down that this Mac has let go of is let go of at the next move.
- A press on a window in the background brings it forward and, still held once the other Mac
  streams it, goes on there: a drag out of a window in the background starts with one press.
- A full-screen window leaves full screen before it goes home and only then is its display removed
  (an app reports leaving at once, while its window is still the display's size: removed then, the
  Space outlived the display on the Mac's own, where Accessibility cannot reach it, and after a
  restart it streamed black and took no input). A window found in such a Space is reached by
  bringing its app forward, taken out of full screen, and moved to the desk.
…ached only for a pick, Dock menus only of apps

- The windows the viewer asked the host to keep live were read from AppKit's list while a window
  closed, the closing one still in it: the host kept that window on its desk and captured it, the
  sharing indicator on, and it came back at the next launch. Only windows still open are asked for
  and kept.
- A window in another Space is reached by bringing its app forward only for the window a device
  picked, at most once a minute (it fired for a side stream's window in another Space and brought its
  app forward on the host by itself).
- An app's Dock menu is read from its own Dock item only (AXApplicationDockItem): a folder or stack of
  the same name gave the Dock's own menu (Sort by, Display as).
…n helper windows left out; Dock names

- A minimized window is taken out of the Dock before it moves to the desk (moved while minimized it
  never showed there: "the window vanished while being staged"), and minimized again going home.
- A picked window in another Space (another desktop) has its app brought forward first, which shows
  that Space, so it can be moved from it; full screen there, it leaves full screen first. Once a
  minute a window at most; never for a minimized window or a side stream's.
- A window that could not come to the desk and is not on screen is captured on its own, never cropped
  from its display "with its app": that showed whatever lay there (another app's window) instead.
- Windows above every display (an app's hidden helper at y -360) are no
  longer listed.
- A Dock icon is named after its app: the window's app name, else the installed app's, never its
  bundle ID while either is known.
…t showed the bundle ID, the copy's file name)
…pp with no window gets one

- The Desktop source took the first display ScreenCaptureKit listed, which could be one of Sill's
  hidden desk displays (the Desktop showed a window staged there, not the Mac's screen). It is one of
  the Mac's own displays now (never VirtualDisplay's vendor), the main one unless a device picked
  another: the window list names them (`displays`, `desktopDisplay`) and kind 40 (PickDisplay)
  picks one, the Desktop restarting on it. Sill Viewer lists them under Desktop in its menu bar icon.
  WindowCatalog's preferMainDisplay, which kept the iPad's Virtual Display off the Desktop, is gone:
  the rule covers it.
- Opening an app that is running with no window (Finder with none open) did nothing: its Dock icon is
  clicked now, as a person would, which asks it for a window, and the new window streams.

Checks: protocol (the displays and PickDisplay), compatibility, menus and pointer-control (40 taken,
41 unknown).
… in a Space

- Sill Viewer shows each of the host's own displays as a Desktop window of its own (the menu bar
  icon lists them). The focused one streams as the Desktop on its display (PickDisplay, then the
  Desktop); the others stream beside it as side streams, a display's ID with the top bit set where a
  window ID goes (DesktopWindow), which the host captures whole. Drops and live drags on a Desktop
  window land on its display. A host that lists no displays keeps the one Desktop window.
- A window off screen is listed when it belongs to a Space (another desktop, a full-screen Space,
  minimized from one), any app's, a menu bar app's too (its minimized window): an app's own
  hidden windows belong to none (a chat app's hidden helper, which no position test told apart:
  it moved onto the screen). SkyLight's answer, read-only, looked up at run time.
…haring on the host lets the devices go

- Sill Viewer never sends on input this Mac's own Sill host posted (the event's posting process):
  with a viewer on each Mac showing the other's Desktop, a move went to the other Mac, landed on its
  viewer, came back as that host's posted move, and neither pointer could be moved.
- A Desktop stream leaves out Sill Viewer's windows as it leaves out Sill's own: they show other
  Macs.
- The person at the host stopping the sharing (the menu bar's screen-sharing item) stops every
  stream and lets every device go with a goodbye ("stopped", the Mac's words, no reconnect), where
  the main stream only went to nothing and the side streams were tried again.
…ng it forward there

- Sill's menu on the host lists the windows shared now ("Shared Windows"), each with Stop Sharing,
  and Stop Sharing All: the window goes home, stops streaming, and the devices are told (kind 41,
  TakenBack) and close their windows of it; they stay connected and may open it again.
- An app brought forward by the person at the host (its Dock icon, ⌘Tab, a click) whose windows a
  device shows has them taken back the same way, unless a device acted in the last 2 s (its own
  clicks, picks and menus bring apps forward too); a skipped one is logged with the device's last
  message.
- The system's Stop Sharing takes every window back the same way, the devices staying connected,
  where it let every device go.
- The Virtual Display item says what it is for: iPhone and iPad; a Mac viewer always moves windows.

Checks: protocol (TakenBack), compatibility, menus and pointer-control (41 taken, 42 unknown).
…both sides

ci.yml's mutants list and Tests/checks/README.md both sides' checks (key-strokes 348 and 82 with
the middle button's case); select() sets both `selecting` and main's missingPolls reset; the exit
stops every capture before restoreFits puts sized windows back and the displays go; StreamClient
takes Saffsanity#69's foreground rule through ClientPlatform (a Mac app is never in the background) and the
Mac viewer still never asks for the Desktop by itself; CLAUDE.md keeps both Current step entries.
…ts: JSONEncoder's key order differs from one encoding to the next, so the byte comparison failed at random
@Saffsanity

Copy link
Copy Markdown
Owner

From a pass over all the open pull requests (nothing is decided yet): this builds on #66 at 9f7153f, four commits behind #66's current head, so it conflicts with #66 in CLAUDE.md and Sources/MacClient/WindowPill.swift and can only merge after #66 and a rebase onto it; #73 in turn builds on this. Its other overlaps (main's clipboard from #78, #94's audio kind, #93 and #107 on the decode path, #99 and #117 in the shared StreamClient files) come from #66 and would be settled there.

grub-basket added a commit to grub-basket/sill that referenced this pull request Oct 15, 2026
Review of Saffsanity#111 (the repository owner): Saffsanity#66, and Saffsanity#72 and Saffsanity#73 built on
it, add windows from other Spaces and minimized windows to the catalog,
so a window can leave the list and come back. The first version of this
pull request counted it as new each time: the host dropped a window
from its first-seen order when it left and put it first again when it
came back, and the device compared each list only with the one before.
Every switch of Space or every minimize would have moved windows to the
front, on every device, and on Saffsanity#66's Mac viewer too (which compiles
StreamClient.swift).

- WindowOrder (Sources/StreamProtocol, pure, shared by both ends):
  seen() is the host's first-seen order: windows never listed before
  first, in the order listed, then every window seen before, gone ones
  included, so one that comes back takes its old place; past a cap the
  windows gone the longest are forgotten, never one listed now.
  arranged() is a device's arrangement after a list: windows this
  session has never seen, and the arrangement does not hold, go first;
  nothing on an unarranged bar or on the session's first list.
- WindowCatalog keeps every window it has listed (seenCap 1024) and
  orders by WindowOrder.seen.
- StreamClient keeps every window id this session's lists named
  (seenWindowIDs; cleared at tearDown and when the Mac changes) and
  takes WindowOrder.arranged's result, saving it.

Tests/checks/window-order: 14 checks, 10 of 10 mutants caught; README
and CI's mutants matrix.

Tested: swift build -c release; the check and its mutants; the iOS app
type-checks in a scratch copy with DuoPostureReader's iOS 27.1 calls
stubbed (this Mac has the iOS 27.0 SDK only). Not run on a device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the clipboard's kind 32 negotiation and the display teardown work

Conflicts kept both sides: the pill's comment has the bolt and the refresh that restarts captures; the
protocol mutants list both sides' cases; CLAUDE.md's wire list has the Dock bounces and full-speed
flags beside the clipboard's subscription; the plan keeps this branch's notifications line and
mac-client's merge notes.
@ashleigh-hopkins

Copy link
Copy Markdown
Author

This now has #66's current head merged in (ea16cb5): main through #78 and the display teardown work. The conflicts with #66 in CLAUDE.md and WindowPill.swift are settled, so it no longer lags #66.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants