English · Русский
The MacBook notch as a working tool. A native SwiftUI/AppKit app: invisible at rest, and on hover it unfolds downwards into a panel with a player, a shelf for files, clipboard history and your next meetings.
Download the latest version — macOS 15 or newer. The first launch needs one permission granted by hand, here is how.
0.0 % CPU at rest · ≈40 MB + 14 MB helper · 2.1 MB bundle · one permission, and only on a button
The track in the screenshot is playing in a browser tab — Cyclop reads it from macOS itself, with no permissions and nothing to configure in the browser. How that works is below.
| Tab | What it does |
|---|---|
| Music | Artwork, track, artist, a scrubber that seeks, prev / play-pause / next. The source is anything: a player, a browser tab, any app macOS itself can see |
| Shelf | Drag files into the notch and they stay there until needed; drag a card out and the file goes wherever it is dropped. A click selects a card, ⌘-click selects several, and then the whole group is dragged. A screenshot taken to the clipboard is saved as a file and lands here too — including one taken on an iPhone, if you copy it there |
| Clipboard | The last 40 copies; a click puts an entry back on the clipboard |
| Snippets | A hand-kept list of what you are tired of retyping: an address, a phone number, an email. Added with a button in the panel, removed with the cross on a card; a click puts the text on the clipboard. The same list lives in ~/Library/Application Support/Cyclop/snippets.json and can be edited there instead |
| Calendar | The next meeting a week ahead: how long until it starts and a button that joins the call — Zoom, Meet, Teams and others. The rest of the meetings as a list |
| Translate | Type on the left, the translation appears on the right — by itself, offline, using macOS's own facilities. English goes to Russian, Russian to English; the direction comes from the script the text is written in. macOS does not preinstall language packs, so the first time you have to download one: System Settings → General → Language & Region → "Translation Languages…" |
| Notes | Scratch, on the right rail of icons: jot something down, come back, delete it or carry it off through the clipboard. Hovering lands with the caret ready; blank notes sweep themselves out |
The panel opens when the pointer reaches the notch and collapses when it leaves. Tabs switch on hover as well — but only if the pointer has come to rest on the icon: one passing through switches nothing. During a file drag the panel opens by itself and goes straight to the shelf. The menu bar icon toggles the panel, enables launch at login, and quits.
- macOS 15 or newer (the Translate tab runs on Translation.framework)
- Swift 6 toolchain (the full Xcode is not needed, Command Line Tools are enough)
The app works on Macs without a notch too: the panel then treats a 180 × 24 pt area at the top centre of the screen as one.
git clone https://github.com/akalikbergenov/cyclop.git
cd cyclop
./Scripts/bundle.sh # swift build + assemble the .app + ad-hoc sign
open build/Cyclop.appThe icon is generated in code, with no graphics editor involved:
swift Scripts/make-icon.swift "$PWD/Resources/AppIcon.icns"Open Cyclop-<version>.dmg and drag the app into Applications.
The first launch will not work: macOS will say the app cannot be verified. That is expected — the image is ad-hoc signed, without a Developer ID, and not notarised. It has to be allowed once:
System Settings → Privacy & Security, where a line about Cyclop and an "Open Anyway" button will be waiting near the bottom.
Or, if one command is easier:
xattr -dr com.apple.quarantine /Applications/Cyclop.appIn macOS 15 the familiar Control-click route no longer works for this case, so it is one of those two. The requirement itself only goes away with a paid Apple Developer ID and notarisation.
Updating works the same way: open the new image and replace the app. Allowing it again is not necessary. The version is the first line of the menu bar menu.
./Scripts/dmg.shPuts build/Cyclop-<version>.dmg next to the app, with an /Applications
shortcut inside. The version number comes from Scripts/version.
./Scripts/release.shBuilds the image, tags v<version> and creates a GitHub release with the .dmg
attached. The notes are two parts: a few lines written by hand, kept in
docs/releases/<version>.md, followed by the commit list GitHub assembles. The
script refuses to run without the hand-written part — a list of commits answers
"what changed in the code", while whoever arrives is asking "what does this give
me", and no generator turns the first answer into the second.
The number is written in one place, Scripts/version. From there it goes
into the app's Info.plist, into the image name and into the tag, so they cannot
drift apart. The script also refuses to run on a dirty tree, on unpushed commits,
or when the tag already exists.
Built images live on the releases page — that is the link to hand to people instead of a file.
None — until you open the calendar. The app asks for no Automation, no
Accessibility, no Screen Recording, and needs nothing configured in the browser.
The pointer position is read through NSEvent.mouseLocation, the clipboard
through the public NSPasteboard, Now Playing through a helper (see below).
Calendar access is the only permission Cyclop ever requests. It is needed by the Calendar tab alone, and the system dialog appears neither at launch nor when the tab is opened, but on an explicit press of a button on a screen that explains why. Don't use the calendar and the app stays without permissions entirely.
Permissions would only be needed by the fallback path, if the main one ever stops working: Automation for Apple Music and Spotify, and Accessibility for the media keys.
The window. A single NSPanel — borderless, non-activating, one level above
the menu bar, canJoinAllSpaces. It is always the same size (that of the
expanded panel) and never changes its frame: only the content animates. That is
what makes the animation smooth without the window geometry jerking about.
Click-through. The window frame is 700 × 252 pt at the top centre of the
screen, and most of the time almost all of it is transparent. Returning nil
from hitTest does not help: the window server has already chosen our window as
the recipient, and nil merely discards the event instead of passing it down.
Transparency does not affect routing either. The one thing that works is
ignoresMouseEvents, toggled by pointer position: inside the visible panel the
window takes clicks, outside it is completely transparent to events.
Hover. The pointer position is sampled on a timer rather than delivered by
event monitors. Monitors are structurally unreliable here: a global one never
sees events delivered to the app's own windows, and a local one only fires while
the app is active — which never happens to an .accessory app. Hovering would
then depend on which window happened to be under the pointer. Reading
NSEvent.mouseLocation does not depend on event routing and behaves identically
everywhere.
The cursor. Its shape is chosen by the window server from cursor regions: the
topmost window that claimed a region under the pointer wins. Claiming nothing
does not mean "leave the cursor alone", it means dropping out of that lookup, and
then the window below decides — over a text editor the panel was handed an
I-beam. Ordinary cursor rects will not do, as AppKit disables them for non-key
windows. So NotchRootView keeps an NSTrackingArea with .cursorUpdate and
.activeAlways over exactly its clickable area.
Hovering over tabs. The pointer crosses the panel in transit, so "hover
switches" would switch tabs on every crossing of the rail. The difference between
"I want this one" and "just passing" is time: a passing pointer clears an icon in
tens of milliseconds, a choosing one stops. A 150 ms threshold separates the two
cases, and nothing else is needed for it. The icon grows under the pointer via
scaleEffect rather than a change of frame: a layout that recalculates on
mouse movement reads as a stutter.
A tab that types takes the keyboard immediately — on hover as well. A panel that
shows a field but accepts no keys is worse than a caret dimmed for a second in
someone else's window, and the dwell on the rail already keeps a passing pointer
from arriving here at all. The reverse is still possible: a click into another
app drops the keyboard without touching the tab — what was typed stays, and the
panel is free to collapse. The keyboard comes back with a click on the field,
caught in the window's sendEvent: a gesture on TextEditor never fires,
because the text view claims mouseDown before SwiftUI does.
A screenshot from the iPhone. The Action button on the phone runs a shortcut of two steps: "Take Screenshot" and "Copy to Clipboard". Continuity carries the copy to the Mac, and the screenshot lands on the shelf. Not one tap, no cloud, no shared network: the link is direct, like AirDrop's, and encrypted the same way.
There is no shorter path, and the others were tried. Syncing through Photos would wait on the cloud. AirDrop cannot be aimed from a shortcut — iOS offers it only through the share sheet, which costs a choice of device every time. A receiver of our own on a port worked, but required a shared Wi-Fi and spoke plain HTTP. The clipboard requires nothing.
It cost one correction in how the clipboard is polled. A copy made on the phone arrives in two parts: macOS puts the type on the pasteboard the moment the phone announces the copy, while the picture is still coming over the air. The change counter has already moved and been marked as seen by then — so a single read returned nothing, and the screenshot vanished entirely, without an error and without a trace. An announced but not yet delivered picture is now waited for: the poll repeats for up to six seconds and stops as soon as the clipboard moves on, because a copy made meanwhile is the newer intention, and finishing a superseded transfer would put the wrong thing on the shelf. If the picture never arrives, the text that lay beside it is recorded instead — otherwise a copy that merely offered an image would disappear from the history altogether.
Snippets. Clipboard history is a queue ordered by recency, and the thing needed once a week is washed out of it precisely because it is rare. Snippets are the opposite discipline: a short, permanent list that nothing fills by itself. It lives in a file:
[
{ "label": "Email", "text": "name@example.com" },
{ "text": "+1 555 000 00 00" }
]~/Library/Application Support/Cyclop/snippets.json, where label may be left
out. "Show Snippets File" in the menu bar opens it in Finder.
Both sides can add to it: the button in the panel and your hands in the file. A snippet made in the panel is appended to that same file — but the file is re-read first. The copy in memory is only as fresh as the last visit to the tab, and writing over it blind would silently undo whatever was added in an editor meanwhile. A name without a value means nothing, so only the second field is required; a row without a name shows itself, which is usually enough for an address or a phone number.
The file is plain text — ordinary JSON in the user's folder, unencrypted. For an email and an address that is fine; for anything that should not be left readable, there is a password manager.
Clicking a snippet overwrites the clipboard, deliberately: what was overwritten stays in the Clipboard tab one click away, whereas restoring it on a timer would be guessing when the paste happens, and typing into another app's field directly would have required Accessibility.
Notes. A second column of icons, on the right — and scratch notes open it: a phone number from a call, half a link, a thought for the next half hour. This is deliberately not note-taking — no folders, no formatting, no search. It is the editor window with the unsaved buffer, replaced: jot, return, delete, or carry it off through the clipboard.
Hovering onto the tab lands with the caret ready, and when there are no notes an empty one is created on the spot: a welcome screen with a button would be slower than the window this tab replaces. Blank notes sweep themselves out when the tab is left — a trail of empty cards is exactly the clutter a scratchpad exists to avoid. The first line stands in for a title in the list: notes here are too short-lived to deserve naming as a separate step. Esc hands the keyboard back and never clears the text — this is the one text in the panel that cannot be re-derived from anywhere.
Everything is written to ~/Library/Application Support/Cyclop/notes.json a
moment after the typing pauses, not on every keystroke; unlike the snippets file
it is not meant to be edited by hand, and it is plain text. The right column is
not decoration: the six icons on the left already fill the panel's height, and a
seventh would not fit.
Languages. Russian and English; macOS picks by the user's preferred language
list. The keys in the tables are the English text, so a string without a
translation stays an English phrase instead of turning into an identifier — which
is also what keeps the app readable when run straight from SwiftPM, where the
.lproj folders are not around at all.
Everything the app composes itself follows the chosen language rather than the
system one: those two differ more often than one expects. The weekday in the
meeting list and the language names in the Translate header come from
Bundle.main.preferredLocalizations, or a column headed in one language above a
button worded in another would read as a mistake.
Capitalisation is a matter of position, not of language. A label starts with a capital in both, but the words it starts with may not carry one: English weekday and month names are proper nouns and come out of a formatter capitalised wherever they stand, while Russian ones are ordinary words and come out lower-case. So the capital is applied where the label is built and is not written into the translations. The countdown is abbreviated on purpose — "in 12 min" rather than a spelled-out word: the full form does not fit the panel header, and an abbreviation declines in no language, so plural forms are not needed at all.
An individual app's language can be changed in System Settings → General → Language & Region → Applications.
The keyboard. The panel cannot become key by default: taking focus means
dimming the title of whatever window the user is in and stopping the caret
blinking in their text, which is far too rude for a window one merely hovered.
The Translate tab turns canBecomeKey on for as long as it is open;
.nonactivatingPanel allows keyboard input without activating the app, so the
editor underneath stays active. The keyboard goes back on Esc, on a tab change,
on a click into another app — which the panel catches as the loss of key status —
and simply when the panel collapses.
The panel does not try to stay open on account of text typed into it: there is one rule for the whole app — open while the pointer is on it. What was typed survives, so leaving and coming back is safe at any moment. Pinning was tried here and removed: it added a second way to close the panel that had to be remembered separately, and a panel that sometimes disobeys the pointer is worse than one that always obeys it.
Translation. Translation.framework, entirely offline. Both languages are
named explicitly: Cyrillic goes out to English, everything else comes in to
Russian. The direction is decided by script rather than by language
identification — a single word is far too short to identify reliably, and
"привет" is regularly detected as Bulgarian. Leaving the source language to the
framework is not an option either: its identifier is a separate asset that is
equally not installed, so auto-detection fails with unableToIdentifyLanguage,
and the translate that follows never returns at all.
Language packs are not preinstalled in macOS. prepareTranslation() is what asks
the system for one, but it shows a window of its own and blocks until answered —
and there is nowhere to show it above the borderless panel of an app that is
never active. So the pair is checked through LanguageAvailability first, and if
the pack is missing the panel says so and offers a button into System Settings →
General → Language & Region → "Translation Languages…".
Notch geometry. The width is screen.frame.width minus
auxiliaryTopLeftArea and auxiliaryTopRightArea, the height comes from
safeAreaInsets.top. On the MacBook Air M4 it was developed on, that is
179 × 32 pt.
Now Playing. In macOS 15.4 the mediaremoted daemon began answering only
clients it trusts. For an ordinary app that looks like this (checked on 15.7.5
with music playing):
| Call | Answer |
|---|---|
MRMediaRemoteGetNowPlayingInfo |
0 keys |
MRMediaRemoteGetNowPlayingApplicationIsPlaying |
false |
MRMediaRemoteGetNowPlayingApplicationPID |
0 |
kMRMediaRemote…DidChange notifications, 180 s with track changes |
not one |
Claiming the com.apple.mediaremote.external-access entitlement is not an option
either: it makes it into the signature, but the process is killed at startup
(SIGKILL, exit 137).
The way around needs neither SIP disabled nor anything set in a browser.
/usr/bin/perl is an Apple platform binary (Platform identifier=16) that the
daemon trusts, and it is signed without library validation, meaning it can load a
foreign library. Sources/CyclopMediaHelper/helper.m compiles into
libcyclopmedia.dylib, is loaded into perl through DynaLoader and from there
receives the daemon's full answer:
$ perl -e 'use DynaLoader; DynaLoader::dl_load_file($ARGV[0], 0x01); sleep 4' libcyclopmedia.dylib
14 keys: Title=Sen, Artist=Yerbol Narimanuly, Album=Sen,
Duration=202.39, ElapsedTime=131.23, ArtworkData=<10681 bytes JPEG>
The helper prints one line of JSON per change and takes commands on stdin;
NowPlayingFeed reads its stdout. Play/pause, next/prev and seeking go the same
way (MRMediaRemoteSendCommand, MRMediaRemoteSetElapsedTime). The helper exits
as soon as its stdin closes, so it cannot outlive the app.
This works for any source macOS itself can see: a player, a browser tab, anything. The source name comes from the pid of the session's owner.
The fallback. If the helper fails to start three times in a row (perl
removed, the daemon closed to platform binaries too), MediaController switches
to scripting Apple Music and Spotify over AppleScript — and then, and only then,
the system asks for Automation.
The cost of sitting still. At rest the app does nothing, and that is
measurable: with the pointer still and the panel collapsed it sits at 0.0 % CPU,
and a sampling profiler shows the whole process asleep in mach_msg2_trap. The
fractions of a percent appear only while the pointer travels near the top edge
or the panel is open — the price of interaction, not of idling.
That comes from a rule rather than a trick: a timer runs only while somebody can see what it produces. Pointer sampling requires place and motion both: 60 Hz only while the pointer has moved recently near the top edge or on the open panel; one that has stood still for three seconds drops sampling to 8 Hz wherever it stands. Place alone was not enough — a cursor parked in the menu bar, which lies entirely inside the warm band, used to hold full rate forever. Movement is noticed on the next idle tick, 125 ms at worst, less than the dwell a hover has to survive anyway. A sleeping display stops sampling entirely.
The track-position ticker runs only while the panel is open: the position is
derivable at any moment from an anchor of where it stood and when, and moving a
bar inside a closed panel — four wake-ups a second for as long as anything
plays — is painting for nobody. The calendar timer lives only while the panel is
open; changes to the meetings themselves arrive through EKEventStoreChanged
regardless. Store updates do not repaint a collapsed panel at all. Clipboard
polling reads one change counter twice a second, and image data is not touched
while screenshot saving is off — it used to be encoded to PNG in full and thrown
away. Every timer carries a tolerance so the system can coalesce wake-ups. And
no leaks: leaks against the live process finds zero.
- Now Playing rests on a private framework and on
/usr/bin/perlremaining a platform binary without library validation. Apple can close this in any update — the Music and Spotify fallback takes over then. For the same reason the app is unfit for the App Store. - Apple has deprecated the scripting runtimes (perl among them) and will remove them from the system one day. The helper survives exactly until that moment.
- The shelf references files rather than copying them: move the original and the
card disappears on the next launch. The exception is clipboard screenshots,
which are saved into
~/Pictures/Cyclopand are never deleted automatically, even when the card leaves the shelf. Only the user clears that folder: the “Clear Screenshots Folder” menu bar item sends its contents to the Trash — a hand too, not a schedule. - Entries typed
org.nspasteboard.ConcealedType(password managers) never enter the clipboard history. - The join button appears only if the call link is in the event itself — in the location field, the notes or the URL. Meet, Zoom, Teams, Webex, Whereby, Jitsi, Telemost and Discord are recognised.
- A screenshot from the iPhone arrives through Universal Clipboard, so it needs what that needs: one Apple ID, Bluetooth and Wi-Fi on, Handoff enabled and the devices near each other. And it overwrites the clipboard on the Mac — what was overwritten stays in the Clipboard tab one click away.
- macOS does not preinstall translation languages — the first time, the pack has to be downloaded through System Settings; the panel says so and opens the right screen.
Sources/Cyclop
├── main.swift entry point, .accessory
├── App/
│ ├── AppDelegate.swift menu bar icon, launch at login
│ └── Strings.swift string lookup, current language
├── Notch/
│ ├── NotchGeometry.swift notch size and every rect derived from it
│ ├── NotchPanel.swift the NSPanel above the menu bar
│ ├── NotchRootView.swift panel hit-testing + drag & drop destination
│ ├── PointerWatcher.swift pointer sampling: hover and click-through
│ └── NotchController.swift window assembly, opening and closing
├── Model/NotchViewModel.swift
├── Services/
│ ├── MediaController.swift picks the Now Playing source
│ ├── NowPlayingFeed.swift runs the helper in perl, parses its stdout
│ ├── PlayerBridge.swift fallback: AppleScript + media keys
│ ├── ShelfStore.swift
│ ├── ClipboardStore.swift
│ ├── ScreenshotVault.swift clipboard screenshots onto disk
│ ├── SnippetStore.swift snippets: reading and writing snippets.json
│ ├── NoteStore.swift scratch notes: notes.json
│ ├── Translator.swift Translation.framework, direction by script
│ └── CalendarStore.swift EventKit: next meetings and the call link
└── UI/ NotchShape, tab panes, theme
Sources/CyclopMediaHelper
└── helper.m dylib for /usr/bin/perl: MediaRemote -> JSON
MIT
