Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ Anything that changes what the dashboard, the admin or a widget can do gets a li
rather than only the one that declared it.

### Added
- **A window that lands on the Edge comes back.** Another application could place a window on the
Edge display, where the kiosk covers it: the window was focused, it took the keyboard, and it
was invisible, with nothing to click to get it back. Safari or the Finder restoring a frame
saved before the Edge was plugged in is enough to fall into it. The helper now moves any such
window back onto the display you work on, within two seconds, under the same switch that keeps
the mouse out. That switch is called *Edge fence* now, since it fences more than the pointer
(@mcouzinet, #7).
- **Claude sessions: go to the session.** Touch a card and its window comes forward — the
exact Orca pane, the Terminal.app or iTerm2 tab, the VS Code window on that folder, or at
least the application — for every session, hooked or merely found running. The card also says which application the session lives in, which is what tells two panes of the
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ The name is the Fremen survival kit from *Dune*.
- **Connections** — named credential sets for outside services, with secrets kept in the macOS
keychain rather than in the config file.
- **A native helper** — a menu bar app that drives the Edge's touch panel with its own HID
driver, fences the mouse out of the display, shows the dashboard in a kiosk window and
driver, keeps the mouse and other applications' windows off the display, shows the dashboard
in a kiosk window and
supervises the server.
- **A widget marketplace** — *Sietch* in the admin installs widgets from a curated registry,
showing what each one will be allowed to do and asking again when an update wants more.
Expand Down Expand Up @@ -109,7 +110,7 @@ admin window: an older copy ignores it.

The dashboard then opens by itself on the Edge. If you would rather not run the helper at all,
`pnpm start` serves everything and `pnpm kiosk` opens a full-screen Chrome window on the Edge —
you lose the touch driver, the mouse fence and the Dock badges.
you lose the touch driver, the Edge fence and the Dock badges.

## First run

Expand Down Expand Up @@ -213,9 +214,9 @@ the widget library and the widgets themselves, live, without a reload.

**The helper, or a browser.** The dashboard is a web page: any browser can show it. The native
helper adds what a browser cannot — its own HID driver for the Edge's touch panel, a fence that
keeps the mouse cursor on your other displays, a kiosk window with no chrome and no cursor, the
keeps the mouse cursor and other applications' windows on your other displays, a kiosk window with no chrome and no cursor, the
Dock's notification badges, and supervision of the server. Its menu bar **F** shows what is
running: *Open the admin*, *Admin in the browser*, *Reload the dashboard*, the *Touch* / *Mouse
running: *Open the admin*, *Admin in the browser*, *Reload the dashboard*, the *Touch* / *Edge
fence* / *Notifications* / *Manage the server* / *Launch at login* toggles, *Log…* and *Quit*,
above state lines such as "Server: running" and "Touch: active".

Expand Down Expand Up @@ -350,7 +351,7 @@ pnpm helper:test
|---|---|
| `server/` | Fastify 5 on 127.0.0.1:4242 — config, widget catalog, providers, connections, secrets, the bridge, the WebSocket |
| `ui/` | Vue 3 — the dashboard, the admin, and the code they share |
| `native/` | The Swift helper — kiosk window, HID touch driver, mouse fence, admin window, Dock badges, server supervision |
| `native/` | The Swift helper — kiosk window, HID touch driver, Edge fence, admin window, Dock badges, server supervision |
| `widgets/` | One folder per widget |
| `scripts/` | Setup, dev, the helper's build and tests, the signing identity, kiosk, the Claude Code hooks |
| `data/` | Your configuration and its assets. Git-ignored |
Expand Down
4 changes: 2 additions & 2 deletions docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ string goes through `ui/src/shared/locales/`, and every manifest text is either
server/ Fastify 5 server on 127.0.0.1:4242 — config, widget catalog, providers, connections,
the secret store, the widget bridge and the WebSocket
ui/ Vue 3: the dashboard (src/dashboard), the admin (src/admin), shared code (src/shared)
native/ The Swift helper: kiosk window, HID touch driver, mouse fence, admin window,
native/ The Swift helper: kiosk window, HID touch driver, Edge fence, admin window,
Dock badges, server supervision
widgets/ One folder per widget
themes/ One folder per theme — see themes.md
Expand Down Expand Up @@ -166,7 +166,7 @@ run:
| `port` | `4242` | port watched to decide whether a server is already up |
| `display` | `2560 × 720` | the Edge's size, used to find the display |
| `touch` | `true` | the native touch driver |
| `fence` | `true` | keep the mouse cursor out of the Edge |
| `fence` | `true` | keep the mouse cursor out of the Edge, and move other applications' windows off it |
| `manageServer` | `true` | start and restart the server |
| `launchAtLogin` | `false` | login item |
| `scrollInvert` | `false` | flip the touch scroll direction |
Expand Down
1 change: 1 addition & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and "Notifications: active" above the toggles, and *Log…* opens the supervised
| "Touch: permission missing" | Input Monitoring not granted to *this build* of the helper | Add "Fremkit Helper" under Privacy & Security → Input Monitoring, then relaunch it |
| "Touch: taken by another driver" | Touchscreen Gestures, another driver or a `--probe` run holds the panel | Quit it, and unload its launchd agent so it does not come back |
| "Fence: permission missing" | Accessibility not granted | Add "Fremkit Helper" under Privacy & Security → Accessibility, then relaunch it |
| A window vanished behind the dashboard | An application placed it on the Edge, where the kiosk covers it | The Edge fence brings it back within two seconds; if it stays there, the helper is missing Accessibility, or the fence is off in its menu |
| Permissions reset after every rebuild | Ad-hoc signature: a new code identity each build | Run `scripts/create-signing-identity.sh`, rebuild, grant once more |
| Printer unreachable (`EHOSTUNREACH`) while `ping` and `curl` work | Local Network not granted to the app that runs the server | Allow it under Privacy & Security → Local Network |
| "Server: external" | Something already answers port 4242, so the helper steps aside | Expected under `pnpm dev`; otherwise stop the stray server, or turn *Manage the server* off |
Expand Down
4 changes: 2 additions & 2 deletions native/Sources/FremkitCore/L10n.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ public enum L10n {
.openAdminInBrowser: "Admin dans le navigateur",
.reloadDashboard: "Recharger le dashboard",
.toggleTouch: "Tactile",
.toggleFence: "Barrière souris",
.toggleFence: "Barrière Edge",
.toggleDock: "Notifications",
.toggleManageServer: "Gérer le serveur",
.toggleLaunchAtLogin: "Lancer au login",
Expand Down Expand Up @@ -152,7 +152,7 @@ public enum L10n {
.openAdminInBrowser: "Admin in the browser",
.reloadDashboard: "Reload the dashboard",
.toggleTouch: "Touch",
.toggleFence: "Mouse fence",
.toggleFence: "Edge fence",
.toggleDock: "Notifications",
.toggleManageServer: "Manage the server",
.toggleLaunchAtLogin: "Launch at login",
Expand Down
57 changes: 57 additions & 0 deletions native/Sources/FremkitCore/WindowSweep.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import CoreGraphics
import Foundation

/// One window of another application sitting where the kiosk is.
public struct StrayWindow: Equatable {
public let id: CGWindowID
public let pid: pid_t
public let frame: CGRect

public init(id: CGWindowID, pid: pid_t, frame: CGRect) {
self.id = id
self.pid = pid
self.frame = frame
}
}

/// Which windows have to be moved off the Edge display.
///
/// The kiosk covers that display above the status-bar level, so a window that opens there is
/// buried: it is on screen, it holds the focus and the keyboard, and the user sees the dashboard.
/// The mouse fence already keeps the pointer out, but nothing stops an application from *placing*
/// a window there, which is what happens when Safari or the Finder restores a frame it saved
/// before the Edge was set up, or when a display is rearranged under windows that were fine.
///
/// The reading of `CGWindowListCopyWindowInfo` lives here, away from AppKit and the Accessibility
/// API, so the rules can be tested with plain dictionaries.
public enum WindowSweep {
/// The windows that overlap `edge` and belong to somebody else, in the order they are listed.
///
/// - Parameters:
/// - infos: what `CGWindowListCopyWindowInfo` answered, on-screen windows.
/// - edge: the Edge display's bounds, in the same space as the window bounds.
/// - ownPid: this process, whose own kiosk window covers the whole Edge on purpose.
public static func strays(in infos: [[String: Any]], edge: CGRect, ownPid: pid_t) -> [StrayWindow] {
infos.compactMap { info in
// Layer 0 is an ordinary window. Everything else (the Dock, the menu bar extras, a
// screen saver, the kiosk itself) is furniture that is meant to be above or below.
guard let layer = info[kCGWindowLayer as String] as? Int, layer == 0 else { return nil }
guard let pid = info[kCGWindowOwnerPID as String] as? pid_t, pid != ownPid else { return nil }
guard let id = info[kCGWindowNumber as String] as? CGWindowID else { return nil }
guard let bounds = info[kCGWindowBounds as String] as? NSDictionary,
let frame = CGRect(dictionaryRepresentation: bounds as CFDictionary) else { return nil }
// Touching the Edge at all counts: a window straddling the boundary has the part the
// user reads on the display they cannot see.
guard !frame.isEmpty, frame.intersects(edge) else { return nil }
return StrayWindow(id: id, pid: pid, frame: frame)
}
}

/// The window numbers of every window in `infos`, whatever it is.
///
/// The caller counts its attempts per window, and a number that is gone from this set belongs
/// to a window that closed: forgetting it there keeps the count from outliving the window.
public static func present(in infos: [[String: Any]]) -> Set<CGWindowID> {
Set(infos.compactMap { $0[kCGWindowNumber as String] as? CGWindowID })
}
}
18 changes: 16 additions & 2 deletions native/Sources/FremkitHelper/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private let poster = EventPoster()
private var driver: TouchDriver?
private var fence: MouseFence?
private var windows: WindowFence?
private var dockBadges: DockBadges?
private var watcher: EdgeDisplay.Watcher?
private var kiosk: KioskWindow?
Expand Down Expand Up @@ -56,6 +57,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
self.fence = fence

self.windows = WindowFence(edge: nil)

let dockBadges = DockBadges(port: config.port)
dockBadges.onState = { [weak self] state in
self?.menu?.update { $0.dock = state }
Expand All @@ -80,7 +83,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}

if config.touch { driver.start() }
if config.fence { fence.start() }
if config.fence {
fence.start()
windows?.start()
}
if config.dock { dockBadges.start() }

// An orphan from a crashed helper still holds the port and would be mistaken for an
Expand Down Expand Up @@ -132,6 +138,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
server?.stopAndWait(timeout: 2)
driver?.stop()
fence?.stop()
windows?.stop()
dockBadges?.stop()
kiosk?.close()
}
Expand Down Expand Up @@ -186,6 +193,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self.edge = edge
driver?.displayBounds = edge
fence?.edge = edge
windows?.edge = edge

if let edge {
let frame = NSRect.fromCGDisplayBounds(edge, primaryHeight: NSRect.primaryScreenHeight)
Expand Down Expand Up @@ -248,7 +256,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
guard let self else { return }
self.config.fence = on
self.saveConfig()
if on { self.fence?.start() } else { self.fence?.stop() }
if on {
self.fence?.start()
self.windows?.start()
} else {
self.fence?.stop()
self.windows?.stop()
}
self.refreshMenu()
}

Expand Down
164 changes: 164 additions & 0 deletions native/Sources/FremkitHelper/WindowFence.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import ApplicationServices
import CoreGraphics
import Foundation
import FremkitCore

/// Keeps other applications' windows off the Edge display, which is a dashboard and not a workspace.
///
/// The mouse fence stops a window from being *dragged* there; this one deals with the windows that
/// arrive without the pointer, which is how it actually happens: an application restores a frame it
/// saved before the Edge existed, or a display is rearranged under windows that were fine where
/// they were. The kiosk covers that display above the status-bar level, so such a window is lost
/// for good: focused, taking the keyboard, and invisible under the dashboard.
///
/// A sweep costs one `CGWindowListCopyWindowInfo` call, which needs no permission and no round
/// trip to any application. The Accessibility API is only reached for an application that actually
/// has a window on the Edge, which is almost never.
final class WindowFence {
/// How often the window list is read. Slow enough to be free, quick enough that a window
/// landing there is gone before the user has finished wondering where it went.
private static let interval: TimeInterval = 2

/// How many times one window is moved before it is left alone.
///
/// An application that puts its window straight back would otherwise be fought forever, at
/// two rounds a second between the two of us, with the window flickering across two displays.
private static let maxAttempts = 3

/// How long an application is given to answer the Accessibility API, so a hung one cannot
/// stall the helper's main thread.
private static let messagingTimeout: Float = 1

/// Bounds of the Edge display; the fence does nothing while this is nil.
var edge: CGRect?

private var timer: Timer?
private var attempts: [CGWindowID: Int] = [:]
/// Logged once, rather than every two seconds, while Accessibility is missing.
private var warnedAboutPermission = false

private(set) var isRunning = false

init(edge: CGRect?) {
self.edge = edge
}

deinit { stop() }

// MARK: - Lifecycle

func start() {
guard !isRunning else { return }
// `.common` so the sweep keeps ticking while a menu is being tracked.
let timer = Timer(timeInterval: Self.interval, repeats: true) { [weak self] _ in self?.sweep() }
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
isRunning = true
sweep()
}

func stop() {
timer?.invalidate()
timer = nil
attempts = [:]
isRunning = false
}

// MARK: - Sweep

/// Moves every window of another application that overlaps the Edge back onto a display the
/// user can see. Safe to call at any time; it does nothing when there is nothing to do.
func sweep() {
guard let edge else { return }
guard let infos = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID)
as? [[String: Any]] else { return }

// A window that closed takes its attempt count with it, so the same number handed to a
// new window starts from zero.
let present = WindowSweep.present(in: infos)
attempts = attempts.filter { present.contains($0.key) }

let strays = WindowSweep.strays(in: infos, edge: edge, ownPid: getpid())
guard !strays.isEmpty else { return }

guard AXIsProcessTrusted() else {
if !warnedAboutPermission {
warnedAboutPermission = true
NSLog("fremkit: a window sits on the Edge but moving it needs Accessibility access")
}
return
}
warnedAboutPermission = false

let screens = Self.screenBounds()
for stray in strays {
let count = attempts[stray.id] ?? 0
guard count < Self.maxAttempts else { continue }
guard let target = AdminPlacement.rescue(frame: stray.frame, size: stray.frame.size,
screens: screens, kiosk: edge) else { continue }
attempts[stray.id] = count + 1
move(stray, to: target, edge: edge)
}
}

/// Every active display, the main one first, in the space window bounds are reported in.
private static func screenBounds() -> [CGRect] {
var count: UInt32 = 0
guard CGGetActiveDisplayList(0, nil, &count) == .success, count > 0 else { return [] }
var ids = [CGDirectDisplayID](repeating: 0, count: Int(count))
guard CGGetActiveDisplayList(count, &ids, &count) == .success else { return [] }
let main = CGMainDisplayID()
// `AdminPlacement.target` takes the first screen that is not the kiosk's, so the display
// the user works on has to come first.
return (ids.filter { $0 == main } + ids.filter { $0 != main }).map(CGDisplayBounds)
}

// MARK: - Accessibility

private func move(_ stray: StrayWindow, to target: CGRect, edge: CGRect) {
let app = AXUIElementCreateApplication(stray.pid)
AXUIElementSetMessagingTimeout(app, Self.messagingTimeout)
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(app, kAXWindowsAttribute as CFString, &value) == .success,
let windows = value as? [AXUIElement] else { return }

// The window list and the Accessibility API name the same window in two ways that have no
// identifier in common, so it is matched on its frame, and, if it moved in between, on
// being the one still overlapping the Edge.
let window = windows.first { frame(of: $0) == stray.frame }
?? windows.first { frame(of: $0)?.intersects(edge) == true }
guard let window else { return }

// The size first: a window wider than the display it is moved to is shrunk to fit, and
// setting the position afterwards is what decides where it lands.
if target.size != stray.frame.size {
var size = target.size
if let value = AXValueCreate(.cgSize, &size) {
AXUIElementSetAttributeValue(window, kAXSizeAttribute as CFString, value)
}
}
var origin = target.origin
if let value = AXValueCreate(.cgPoint, &origin) {
AXUIElementSetAttributeValue(window, kAXPositionAttribute as CFString, value)
}
}

private func frame(of window: AXUIElement) -> CGRect? {
guard let positionValue = axValue(window, kAXPositionAttribute),
let sizeValue = axValue(window, kAXSizeAttribute) else { return nil }
var origin = CGPoint.zero
var size = CGSize.zero
guard AXValueGetValue(positionValue, .cgPoint, &origin),
AXValueGetValue(sizeValue, .cgSize, &size) else { return nil }
return CGRect(origin: origin, size: size)
}

/// One `AXValue` attribute, or nil when the application answers something else entirely.
private func axValue(_ element: AXUIElement, _ attribute: String) -> AXValue? {
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success,
let value, CFGetTypeID(value) == AXValueGetTypeID() else { return nil }
// Checked just above: the answer is an AXValue and nothing else.
return (value as! AXValue)
}
}
Loading
Loading