HashNotch is built so that every capability is a plug-in. The core knows how to draw an island around the notch and how to talk to a feature through one small protocol — it never knows what any feature actually does. That is what lets features be added or removed without editing the core.
HashNotchKit Core framework. Notch detection, the overlay window, the
island and panel, the theme, shared UI pieces, settings, and
the NotchFeature contract. Depends on nothing in this repo.
FeatureMedia One self-contained feature each. Every feature module depends
FeatureActivities only on HashNotchKit — never on another feature.
FeatureDownloads
FeatureTimer
FeatureTokens
FeatureNetwork
FeatureBattery
FeatureAirPods
FeatureCall
FeatureThermal
FeatureCPU
FeatureMemory
FeatureStorage
HashNotch The executable. The only place features are wired together.
Depends on the core + every feature it enables.
HashNotchChecks Framework-free checks for the core and the parsers, runnable
under the Command Line Tools (`swift run HashNotchChecks`).
Dependencies only ever point inward toward the core:
FeatureMedia ────┐
FeatureBattery ──┼─▶ HashNotchKit
… every other ───┘
▲
HashNotch ─────┘ (also depends on each feature, to register them)
Every feature implements NotchFeature (in HashNotchKit):
@MainActor
public protocol NotchFeature: AnyObject {
var id: String { get }
var title: String { get }
var displayOptions: [FeatureOption] { get }
/// The feature's row in the open panel; nil to show nothing there.
func makeExpandedView(context: FeatureContext) -> AnyView?
/// Always-on views flanking the notch while this feature is live;
/// nil for none.
func makeCompactLeadingView(context: FeatureContext) -> AnyView?
func makeCompactTrailingView(context: FeatureContext) -> AnyView?
/// Where this feature comes in the queue for the live strip.
var livePriority: Int { get }
/// The colour the island's edge wears while this feature owns the strip,
/// and how hard that line should press. nil / 0 for no colour at all.
var outlineTint: Color? { get }
var outlineUrgency: Double { get }
/// Handle a sideways swipe across the open panel; false to pass it on.
func handleSwipe(_ direction: SwipeDirection) -> Bool
func start(context: FeatureContext) // begin sampling
func stop() // release resources
}Everything but id and title has a default, so a simple feature implements
two members and its own view.
Two requirements have been taken out, and they went together. makeView built
a compact pill to sit beside the notch, and placement said which side of it
the pill went. Nothing had drawn a pill since the island was redesigned, so
every feature was implementing a view that could not appear, and any display
choice that only changed a pill was a setting that did nothing. placement
outlived it by a little, read by nothing and never offered in the settings
window, until it went too.
What appears beside the notch now is the live strip, which one feature at a time
owns through makeCompactLeadingView and makeCompactTrailingView, and which
is decided by livePriority rather than by any placement.
A feature owns its own data source (an ObservableObject monitor) and its own
SwiftUI views. FeatureContext is how it reaches shared services: the settings
store, LivePresence (to say "I have something live right now"), and the
closure that opens the settings window.
NotchIslandView draws three separate layers, stacked, each with its own shape:
- Collapsed — a black shape matching the physical notch exactly, so at rest
the app is invisible. On a display with no notch there is nothing to hide
behind, so
NotchGeometryhangs the island from the top of the screen exactly as the hardware notch does and makes it exactly as tall as the menu bar, filling the band between the app menus and the status icons rather than floating below the bar attached to nothing. - Live — a slim strip that appears beside the notch, at menu-bar height,
whenever any feature signals
LivePresence: artwork and title to one side, a countdown to the other. No hover needed. - Expanded — a rounded panel that drops straight down below the menu bar,
listing every enabled feature's
makeExpandedView, with the settings gear in its corner. Because it opens below the menu bar, it can never overlap app menus or status items.
Window frames and hover zones both hang from NotchGeometry.islandTop rather
than the screen's top edge, which is what lets the notchless case work without
a second layout path. A user's IslandAdjustment — remembered per display — is
applied to the measured geometry before anything else reads it, so a hand
correction needs no special case either.
NotchWindowController owns the overlay window. The window keeps one width
for its whole life — the widest any state needs — and only its height follows
the state. A window sized tight to the current state has to move its left edge
to stay centred on the notch, and that move is instant while SwiftUI animates
the content re-centring inside it; the two do not cancel and the island visibly
sweeps sideways. Growing downward moves nothing that is anchored to the top,
and the unused width is invisible because the window is transparent. Hover is
detected with observe-only mouse-position monitors against tight, hysteretic
zones (a small notch-sized zone to open; a keep-open area that must fully
contain every zone that can trigger opening, or the panel flickers at the
edges). The window is click-through in every state except while the panel is
open.
Low-power behavior also lives in the core: PollingSampler uses tolerant,
coalesced timers; monitors publish only when a displayed value actually changes;
VisibleSampler keeps panel-only readouts idle until the panel is open; and
PowerCoordinator stops all sampling while the screen is asleep.
User choices live in SettingsStore (in HashNotchKit), persisted to
UserDefaults. It is the single source of truth for:
- which features are enabled and in what order,
- each feature's chosen display style,
- appearance (accent, panel fill, corner rounding, motion) and alert length,
- battery saver, which scales every sampling interval,
- the per-display
IslandAdjustment, and - open-at-login.
"Enabled" governs whether a feature runs, not merely whether it is drawn.
FeatureRegistry.syncRunning(context:) reconciles the running set with the
store — starting an enabled feature that is stopped, stopping a disabled one
that is running, and leaving anything already correct untouched — and the app
calls it whenever the feature settings change, on wake, and at launch. A
feature that is off opens no files and spawns no subprocess, which is what
makes the switch a privacy control rather than a display one.
Features declare their display choices via displayOptions and read the
selected one with context.settings.style(for: id) inside the view they build.
The island observes the store, so changing a setting updates the panel live.
A choice must change something the reader can actually see, which now means the
panel. A style whose only effect was on the compact pill is not a style any
more — see BatteryStyle, which lost "Icon only" for exactly that reason. A
stored choice that no longer exists falls back to the feature's default rather
than failing.
There is no menu-bar item: SettingsView is reached through the gear button in
the expanded panel (FeatureContext.openSettings), and it is also where the app
is quit.
- Create
Sources/Feature<Name>/with:- a
Monitor(ObservableObject) that samples your data, - a SwiftUI
View, - a type conforming to
NotchFeaturethat ties them together.
- a
- In
Package.swift, add a.target(name: "Feature<Name>", dependencies: ["HashNotchKit"])and add"Feature<Name>"to theHashNotchtarget's dependencies. - In
Sources/HashNotch/FeatureManifest.swift,import Feature<Name>and add one line to the returned array.
The core (HashNotchKit) does not change.
Delete its line from FeatureManifest.swift. Optionally delete the module folder
and its Package.swift entries. Nothing else is affected.
- Isolation — a bug or a rewrite in one feature can't reach another; the compiler enforces the module boundaries.
- Scale — new features are additive. The core and existing features stay untouched, so the risk of each addition stays flat as the app grows.
- Testability — the core is verified against a stub feature, with no real feature present, proving the decoupling holds.