Skip to content
Merged
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
20 changes: 9 additions & 11 deletions Flowlight/App/TrafficMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,19 +69,17 @@ final class TrafficMonitor: ObservableObject {
private var source: TrafficSource?
private var timers: [Timer] = []

/// How many Flowlight windows are on screen. With none, the menu bar only needs the current rates, so the live
/// chart series and the per-app list aren't built at all.
private var visibleWindows = 0
var uiVisible: Bool { visibleWindows > 0 }
private var windows = WindowVisibility()

func windowAppeared() { visibleWindows += 1 }
/// Whether anything on screen needs the live chart series and the per-app list. With nothing visible the menu
/// bar only needs the current rates, so neither is built at all.
var uiVisible: Bool { windows.isVisible }

func windowDisappeared() {
visibleWindows = max(0, visibleWindows - 1)
if !uiVisible {
liveSeries = []
talkers = []
}
func windowVisibility(_ window: AnyObject, isVisible: Bool) {
guard windows.update(window, isVisible: isVisible) == .becameHidden else { return }
// Nothing can see them, and they are rebuilt on the next tick once something can.
liveSeries = []
talkers = []
}

private let liveWindow = 120
Expand Down
39 changes: 39 additions & 0 deletions Flowlight/App/WindowVisibility.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation

/// Which windows are genuinely on screen.
///
/// The answer gates the expensive half of every tick — the live chart series and the per-app list — so getting it
/// wrong is not a visual bug, it is a battery bug that nothing on screen reveals.
///
/// Membership follows `NSWindow.occlusionState` rather than SwiftUI's `onAppear`/`onDisappear`. The view lifecycle
/// only says the window *exists*: it stays "appeared" while the window is minimised, fully covered by another
/// app, or on a Space nobody is looking at. In all three the chart was still being rebuilt every second for an
/// audience of nobody. Occlusion is the state that actually answers "can anyone see this".
///
/// A set rather than a counter, because occlusion notifications are not guaranteed to pair up. A window closed
/// while already occluded, or one teardown missed, leaks a count — and a leaked count pins visibility to `true`
/// for the rest of the session, silently restoring the cost this exists to remove. Keying on identity makes every
/// update idempotent, so a repeat cannot drift the state.
struct WindowVisibility {
/// What an update did to the *overall* state, which is all the caller acts on.
enum Change: Equatable {
case unchanged
case becameVisible
case becameHidden
}

private var visible: Set<ObjectIdentifier> = []

var isVisible: Bool { !visible.isEmpty }

/// Record one window's visibility. Safe to call repeatedly with the same value.
@discardableResult
mutating func update(_ window: AnyObject, isVisible: Bool) -> Change {
let was = self.isVisible
let id = ObjectIdentifier(window)
if isVisible { visible.insert(id) } else { visible.remove(id) }
let now = self.isVisible
if was == now { return .unchanged }
return now ? .becameVisible : .becameHidden
}
}
59 changes: 59 additions & 0 deletions Flowlight/UI/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,65 @@ enum TrafficColors {
/// `setFrameAutosaveName` looks like the answer and quietly isn't: SwiftUI's `WindowGroup` owns the window's
/// restoration, so the name is rejected and nothing is ever written. Watching the window and storing the frame
/// is a few more lines and actually works.
/// Reports whether the hosting window is actually on screen.
///
/// `onAppear`/`onDisappear` track the view's lifecycle, which says nothing about whether anyone can see the
/// window: it stays "appeared" when the window is minimised, fully covered, or on another Space. AppKit answers
/// the real question through `occlusionState`, and posts when it changes.
struct WindowVisibilityReporter: NSViewRepresentable {
let report: (AnyObject, Bool) -> Void

func makeCoordinator() -> Coordinator { Coordinator(report: report) }

func makeNSView(context: Context) -> NSView {
let view = NSView()
// The view has no window until it is in the hierarchy.
DispatchQueue.main.async { context.coordinator.attach(to: view.window) }
return view
}

func updateNSView(_ view: NSView, context: Context) {
// A window can be reassigned — moved between scenes, or restored after a close.
if view.window !== context.coordinator.window {
DispatchQueue.main.async { context.coordinator.attach(to: view.window) }
}
}

static func dismantleNSView(_ view: NSView, coordinator: Coordinator) {
coordinator.detach()
}

final class Coordinator {
private(set) weak var window: NSWindow?
private let report: (AnyObject, Bool) -> Void
private var observer: NSObjectProtocol?

init(report: @escaping (AnyObject, Bool) -> Void) { self.report = report }

func attach(to window: NSWindow?) {
guard window !== self.window else { return }
detach()
guard let window else { return }
self.window = window
observer = NotificationCenter.default.addObserver(
forName: NSWindow.didChangeOcclusionStateNotification, object: window, queue: .main
) { [weak self, weak window] _ in
guard let self, let window else { return }
self.report(window, window.occlusionState.contains(.visible))
}
report(window, window.occlusionState.contains(.visible))
}

/// Report gone before dropping the window, or a close while occluded leaves it counted forever.
func detach() {
if let observer { NotificationCenter.default.removeObserver(observer) }
observer = nil
if let window { report(window, false) }
window = nil
}
}
}

struct WindowSizer: NSViewRepresentable {
private static let key = "window.mainFrame"

Expand Down
5 changes: 3 additions & 2 deletions Flowlight/UI/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@ struct ContentView: View {
.id(localization.revision)
.onReceive(NotificationCenter.default.publisher(for: .flowlightOpenUpdate)) { _ in openWindow(id: "update") }
.background(WindowSizer())
.onAppear { monitor.windowAppeared() }
.onDisappear { monitor.windowDisappeared() }
.background(WindowVisibilityReporter { window, visible in
monitor.windowVisibility(window, isVisible: visible)
})
.onChange(of: updater.showWindow) { _, show in
if show { openWindow(id: "update"); updater.showWindow = false }
}
Expand Down
8 changes: 7 additions & 1 deletion Flowlight/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ struct SettingsView: View {
@AppStorage(K.agentAway) private var agentAway = true
@AppStorage(K.agentAwayMinutes) private var agentAwayMinutes = 15.0
@AppStorage(AnomalySettings.Keys.backgroundOnly) private var backgroundOnly = false
@State private var launchAtLogin = SMAppService.mainApp.status == .enabled
// Read once the pane is on screen, never in the property's default expression: that
// expression re-runs every time this struct is constructed, and `Settings { }` is
// rebuilt whenever the App body is invalidated — which TrafficMonitor does every
// second. SMAppService.status is a synchronous XPC round trip to smd, so the default
// form put blocking IPC on the main thread at 1 Hz for the life of the process.
@State private var launchAtLogin = false
@State private var loginItemError: String?

var body: some View {
Expand All @@ -29,6 +34,7 @@ struct SettingsView: View {
LanguageRow()
Toggle(L("Launch at login"), isOn: $launchAtLogin)
.onChange(of: launchAtLogin) { _, enabled in setLaunchAtLogin(enabled) }
.task { launchAtLogin = SMAppService.mainApp.status == .enabled }
if let loginItemError { Text(loginItemError).font(.caption).foregroundStyle(.red) }
Toggle(isOn: $backgroundOnly) {
VStack(alignment: .leading, spacing: 2) {
Expand Down
86 changes: 86 additions & 0 deletions FlowlightTests/WindowVisibilityTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import XCTest
@testable import Flowlight

/// `WindowVisibility` decides whether the live chart series and the per-app list are built at all, so it is the
/// switch between an idle Flowlight costing nothing and an idle Flowlight rebuilding both every second for a
/// window nobody can see. A stuck `true` is invisible from the outside — the app keeps working correctly and only
/// the battery shows it — so the drift cases are covered explicitly.
final class WindowVisibilityTests: XCTestCase {
/// Stand-in for an NSWindow. Only identity is ever used.
private final class FakeWindow {}

func testNothingIsVisibleUntilAWindowSaysSo() {
var windows = WindowVisibility()
XCTAssertFalse(windows.isVisible)

let window = FakeWindow()
XCTAssertEqual(windows.update(window, isVisible: true), .becameVisible)
XCTAssertTrue(windows.isVisible)

XCTAssertEqual(windows.update(window, isVisible: false), .becameHidden)
XCTAssertFalse(windows.isVisible)
}

/// AppKit posts occlusion changes freely, including ones that repeat the current state. A counter drifted
/// here; identity cannot.
func testRepeatedUpdatesForOneWindowDoNotDrift() {
var windows = WindowVisibility()
let window = FakeWindow()

XCTAssertEqual(windows.update(window, isVisible: true), .becameVisible)
for _ in 0..<5 {
XCTAssertEqual(windows.update(window, isVisible: true), .unchanged, "a repeat is not a new window")
}

XCTAssertEqual(windows.update(window, isVisible: false), .becameHidden,
"one hide must undo any number of shows for the same window")
for _ in 0..<3 {
XCTAssertEqual(windows.update(window, isVisible: false), .unchanged)
}
XCTAssertFalse(windows.isVisible)
}

func testVisibleWhileAnyWindowIsVisible() {
var windows = WindowVisibility()
let first = FakeWindow()
let second = FakeWindow()

XCTAssertEqual(windows.update(first, isVisible: true), .becameVisible)
XCTAssertEqual(windows.update(second, isVisible: true), .unchanged, "already visible overall")

XCTAssertEqual(windows.update(first, isVisible: false), .unchanged, "the second is still on screen")
XCTAssertTrue(windows.isVisible)

XCTAssertEqual(windows.update(second, isVisible: false), .becameHidden)
XCTAssertFalse(windows.isVisible)
}

/// A window torn down while already occluded reports hidden twice. Under the old counter that double
/// decrement was clamped, and the matching double increment was not — which is how a session ended up
/// permanently "visible".
func testHidingAWindowThatWasNeverVisibleIsHarmless() {
var windows = WindowVisibility()
let ghost = FakeWindow()
let real = FakeWindow()

XCTAssertEqual(windows.update(ghost, isVisible: false), .unchanged)
XCTAssertFalse(windows.isVisible)

XCTAssertEqual(windows.update(real, isVisible: true), .becameVisible)
XCTAssertEqual(windows.update(ghost, isVisible: false), .unchanged, "a stranger cannot hide someone else")
XCTAssertTrue(windows.isVisible)
}

/// Two windows closing in either order must both end hidden; only the last one reports the flip.
func testOnlyTheLastWindowReportsTheFlip() {
var windows = WindowVisibility()
let a = FakeWindow()
let b = FakeWindow()
windows.update(a, isVisible: true)
windows.update(b, isVisible: true)

XCTAssertEqual(windows.update(b, isVisible: false), .unchanged)
XCTAssertEqual(windows.update(a, isVisible: false), .becameHidden)
XCTAssertFalse(windows.isVisible)
}
}
2 changes: 1 addition & 1 deletion docs/404.html
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ <h1>That page isn't here</h1><p class="lede">Try the <a href="/">home page</a>,
<div class="legal">
<span>© 2026 The Flowlight contributors. Flowlight is free software, released under the
<a href="https://github.com/xinbetween/flowlight/blob/main/LICENSE">GNU General Public License v3.0</a>.</span>
<span>Version 0.8.0 · Not affiliated with Apple or any AI provider named on this site.</span>
<span>Version 0.8.1 · Not affiliated with Apple or any AI provider named on this site.</span>
</div>
</div>
</footer>
Expand Down
2 changes: 1 addition & 1 deletion docs/about/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ <h2 id="thanks">Thanks</h2>
<div class="legal">
<span>© 2026 The Flowlight contributors. Flowlight is free software, released under the
<a href="https://github.com/xinbetween/flowlight/blob/main/LICENSE">GNU General Public License v3.0</a>.</span>
<span>Version 0.8.0 · Not affiliated with Apple or any AI provider named on this site.</span>
<span>Version 0.8.1 · Not affiliated with Apple or any AI provider named on this site.</span>
</div>
</div>
</footer>
Expand Down
2 changes: 1 addition & 1 deletion docs/de/about/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ <h2 id="thanks">Dank</h2>
</div>
<div class="legal">
<span>© 2026 Die Flowlight-Mitwirkenden. Flowlight ist freie Software, veröffentlicht unter der <a href="https://github.com/xinbetween/flowlight/blob/main/LICENSE">GNU General Public License v3.0</a>.</span>
<span>Version 0.8.0 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.</span>
<span>Version 0.8.1 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.</span>
</div>
</div>
</footer>
Expand Down
4 changes: 2 additions & 2 deletions docs/de/docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
<section class="page-head"><div class="wrap">
<p class="eyebrow">Dokumentation</p>
<h1>Flowlight benutzen</h1>
<p class="lede">Alles vom ersten Start bis zum Feinschliff an den Agentenregeln. Flowlight 0.8.0, macOS 15 oder neuer.</p>
<p class="lede">Alles vom ersten Start bis zum Feinschliff an den Agentenregeln. Flowlight 0.8.1, macOS 15 oder neuer.</p>
</div></section>
<div class="wrap prose-wrap">
<nav class="toc" aria-label="Auf dieser Seite">
Expand Down Expand Up @@ -611,7 +611,7 @@ <h2 id="limits">Grenzen</h2>
</div>
<div class="legal">
<span>© 2026 Die Flowlight-Mitwirkenden. Flowlight ist freie Software, veröffentlicht unter der <a href="https://github.com/xinbetween/flowlight/blob/main/LICENSE">GNU General Public License v3.0</a>.</span>
<span>Version 0.8.0 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.</span>
<span>Version 0.8.1 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.</span>
</div>
</div>
</footer>
Expand Down
6 changes: 3 additions & 3 deletions docs/de/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700;12..96,800&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
<link rel="stylesheet" href="../assets/site.css?v=0e85a400">
<script type="application/ld+json">{"@context": "https://schema.org", "@graph": [{"@type": "WebSite", "@id": "https://flowlight.xinbetween.com/#website-de", "url": "https://flowlight.xinbetween.com/de/", "name": "Flowlight", "inLanguage": "de", "publisher": {"@id": "https://flowlight.xinbetween.com/#publisher"}}, {"@type": "Organization", "@id": "https://flowlight.xinbetween.com/#publisher", "name": "xinbetween", "url": "https://flowlight.xinbetween.com/", "logo": "https://flowlight.xinbetween.com/assets/icon.png", "sameAs": ["https://github.com/xinbetween/flowlight"]}, {"@type": "SoftwareApplication", "@id": "https://flowlight.xinbetween.com/#app-de", "name": "Flowlight", "description": "Flowlight ist Open-Source-Netzwerküberwachung für macOS – app-genau, mit dem tiefsten Blick auf KI-Agenten, ihre Tools und MCP-Server.", "url": "https://flowlight.xinbetween.com/de/", "applicationCategory": "SecurityApplication", "applicationSubCategory": "Network monitor", "operatingSystem": "macOS 15 or later", "softwareVersion": "0.8.0", "downloadUrl": "https://github.com/xinbetween/flowlight/releases/latest/download/Flowlight.dmg", "installUrl": "https://flowlight.xinbetween.com/de/docs/#install", "image": "https://flowlight.xinbetween.com/assets/social-card.png", "screenshot": "https://flowlight.xinbetween.com/assets/screenshots/agents.png", "license": "https://www.gnu.org/licenses/gpl-3.0.html", "isAccessibleForFree": true, "publisher": {"@id": "https://flowlight.xinbetween.com/#publisher"}, "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD", "availability": "https://schema.org/InStock"}}]}</script>
<script type="application/ld+json">{"@context": "https://schema.org", "@graph": [{"@type": "WebSite", "@id": "https://flowlight.xinbetween.com/#website-de", "url": "https://flowlight.xinbetween.com/de/", "name": "Flowlight", "inLanguage": "de", "publisher": {"@id": "https://flowlight.xinbetween.com/#publisher"}}, {"@type": "Organization", "@id": "https://flowlight.xinbetween.com/#publisher", "name": "xinbetween", "url": "https://flowlight.xinbetween.com/", "logo": "https://flowlight.xinbetween.com/assets/icon.png", "sameAs": ["https://github.com/xinbetween/flowlight"]}, {"@type": "SoftwareApplication", "@id": "https://flowlight.xinbetween.com/#app-de", "name": "Flowlight", "description": "Flowlight ist Open-Source-Netzwerküberwachung für macOS – app-genau, mit dem tiefsten Blick auf KI-Agenten, ihre Tools und MCP-Server.", "url": "https://flowlight.xinbetween.com/de/", "applicationCategory": "SecurityApplication", "applicationSubCategory": "Network monitor", "operatingSystem": "macOS 15 or later", "softwareVersion": "0.8.1", "downloadUrl": "https://github.com/xinbetween/flowlight/releases/latest/download/Flowlight.dmg", "installUrl": "https://flowlight.xinbetween.com/de/docs/#install", "image": "https://flowlight.xinbetween.com/assets/social-card.png", "screenshot": "https://flowlight.xinbetween.com/assets/screenshots/agents.png", "license": "https://www.gnu.org/licenses/gpl-3.0.html", "isAccessibleForFree": true, "publisher": {"@id": "https://flowlight.xinbetween.com/#publisher"}, "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD", "availability": "https://schema.org/InStock"}}]}</script>
</head>
<body>
<a class="skip" href="#main" style="position:absolute;left:-999px">Zum Inhalt springen</a>
Expand Down Expand Up @@ -76,7 +76,7 @@ <h1 style="margin-top:16px">Sieh, was deine Apps im Netz tun.<br><span class="fl
Stern auf GitHub
</a>
<p class="brew"><code>brew install --cask xinbetween/tap/flowlight</code><button type="button" class="copy" data-copy="brew install --cask xinbetween/tap/flowlight">Kopieren</button></p>
<p class="meta-row"><span>v0.8.0</span><span>macOS 15+</span><span>Universal</span><span>GPL-3.0</span><span>Keine Telemetrie</span></p>
<p class="meta-row"><span>v0.8.1</span><span>macOS 15+</span><span>Universal</span><span>GPL-3.0</span><span>Keine Telemetrie</span></p>
</div>
</div>

Expand Down Expand Up @@ -613,7 +613,7 @@ <h2>Wisse, was deinen Mac verlässt.</h2>
</div>
<div class="legal">
<span>© 2026 Die Flowlight-Mitwirkenden. Flowlight ist freie Software, veröffentlicht unter der <a href="https://github.com/xinbetween/flowlight/blob/main/LICENSE">GNU General Public License v3.0</a>.</span>
<span>Version 0.8.0 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.</span>
<span>Version 0.8.1 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.</span>
</div>
</div>
</footer>
Expand Down
2 changes: 1 addition & 1 deletion docs/de/privacy/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ <h2 id="contact">Kontakt</h2>
</div>
<div class="legal">
<span>© 2026 Die Flowlight-Mitwirkenden. Flowlight ist freie Software, veröffentlicht unter der <a href="https://github.com/xinbetween/flowlight/blob/main/LICENSE">GNU General Public License v3.0</a>.</span>
<span>Version 0.8.0 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.</span>
<span>Version 0.8.1 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.</span>
</div>
</div>
</footer>
Expand Down
Loading
Loading