Skip to content

Flowlight 0.8.1 - #5

Merged
blessdyb merged 2 commits into
mainfrom
release/0.8.1
Sep 27, 2026
Merged

blessdyb merged 2 commits into
mainfrom
release/0.8.1

Conversation

@blessdyb

Copy link
Copy Markdown
Contributor

Two fixes to one symptom: macOS listing Flowlight under Using Significant Energy while it sat idle in the menu bar. Measured 6.3% of a core at idle, 37% averaged over three and a half hours.

Occlusion, not view lifecycle

The live chart series and the per-app list were already gated on uiVisible — but that was driven by SwiftUI's onAppear/onDisappear, which only say a 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 rebuilt every second for an audience of nobody.

It now follows NSWindow.occlusionState, which is the state that actually answers "can anyone see this".

The counter became a set

Occlusion notifications are not guaranteed to pair up. A window closed while already occluded, or one missed teardown, leaks a count — and a leaked count pins visibility to true for the rest of the session, silently restoring the cost the gate exists to remove. Keying on window identity makes every update idempotent.

WindowVisibility is its own type so the state machine is testable without constructing a TrafficMonitor, whose init opens the real traffic database.

SMAppService out of a @State default

SettingsView read "launch at login" in a property's default value:

@State private var launchAtLogin = SMAppService.mainApp.status == .enabled

That expression re-runs every time the view struct is constructed, and Settings { } is rebuilt whenever the App body is invalidated — which TrafficMonitor did every second through its @Published properties. SMAppService.status is a synchronous XPC round trip to smd, so this put blocking IPC on the main thread at 1 Hz for the life of the process, whether or not Settings was ever opened.

A sample caught the whole chain:

AppBodyAccessor.updateBody(of:changed:)
  Settings.init(content:)
    SettingsView.init
      -[SMAppService status]
        _send_smd_request
          _xpc_pipe_routine

Now read in .task, once, when the pane appears.

Tests

470 pass, including five new ones covering the drift cases a counter could not survive: repeated notifications for one window, hiding a window that was never visible, and two windows closing in either order.

Two fixes to the same symptom: macOS listing Flowlight under "Using
Significant Energy" while it sat idle in the menu bar. Measured 6.3% of a
core at idle, 37% averaged over three and a half hours.

Occlusion, not view lifecycle. The live chart series and the per-app list
were gated on uiVisible, which was driven by SwiftUI's onAppear and
onDisappear. Those track the view's lifecycle, which says only that a
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 rebuilt every second for an audience of nobody. It now
follows NSWindow.occlusionState, which is the state that actually answers
"can anyone see this".

The counter became a set. Occlusion notifications are not guaranteed to
pair up: a window closed while already occluded, or one missed teardown,
leaks a count — and a leaked count pins visibility to true for the rest of
the session, silently restoring the cost the gate exists to remove. Keying
on window identity makes every update idempotent. WindowVisibility is its
own type so the state machine is testable without constructing a
TrafficMonitor, whose init opens the real traffic database.

SMAppService out of a @State default. SettingsView read "launch at login"
in a property's default value. That expression re-runs every time the view
struct is constructed, and Settings { } is rebuilt whenever the App body
is invalidated — which TrafficMonitor did every second through its
@published properties. SMAppService.status is a synchronous XPC round trip
to smd, so this put blocking IPC on the main thread at 1 Hz for the life
of the process, whether or not Settings was ever opened. A sample caught
the whole chain: AppBodyAccessor.updateBody → Settings.init(content:) →
SettingsView.init → -[SMAppService status] → _xpc_pipe_routine.

It is now read in .task, once, when the pane appears.

470 tests pass, including five new ones covering the drift cases that a
counter could not survive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The version bump landed without regenerating `docs/`, so the branch carries a site that still says 0.8.0 —
the version string, the structured data and the download button, in all ten languages — while `project.yml`
says 0.8.1. That is what the `site` check has been failing on since the branch was pushed.

It is the safe direction of the two (the site behind the release rather than ahead of it), but it is the
reason `docs/` and `site/` are checked against each other at all: they travel in one commit, because
`build_site.py` reads `MARKETING_VERSION`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@blessdyb
blessdyb merged commit dfd3a7a into main Sep 27, 2026
4 checks passed
@blessdyb
blessdyb deleted the release/0.8.1 branch September 27, 2026 07:35
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.

1 participant