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
14 changes: 13 additions & 1 deletion ExtensionHost/ExtensionJSRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ final class ExtensionJSRuntime {
private var timers: [Int: Timer] = [:]
private var nextTimerID: Int = 1
private var didActivate = false
private var timersSuspended = false

private let defaults = UserDefaults.standard
private var islandActivationModule: ActiveModule {
Expand Down Expand Up @@ -112,6 +113,10 @@ final class ExtensionJSRuntime {
deactivate()
}

func setTimersSuspended(_ suspended: Bool) {
timersSuspended = suspended
}

@MainActor
func fetchState() -> ExtensionViewState? {
syncIslandState()
Expand Down Expand Up @@ -861,15 +866,22 @@ final class ExtensionJSRuntime {
let timerID = nextTimerID
nextTimerID += 1

let interval = max(0.01, milliseconds / 1000)
let requestedInterval = max(0.01, milliseconds / 1000)
let interval = repeats ? max(0.25, requestedInterval) : requestedInterval
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: repeats) { [weak self] timer in
guard let self else { return }
if repeats, self.timersSuspended {
return
}
self.invokeJS("timer(\(timerID))") { callback.call(withArguments: []) }
if !repeats {
self.timers.removeValue(forKey: timerID)
timer.invalidate()
}
}
if repeats {
timer.tolerance = max(0.05, interval * 0.2)
}

RunLoop.main.add(timer, forMode: .common)
timers[timerID] = timer
Expand Down
56 changes: 47 additions & 9 deletions ExtensionHost/ExtensionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ final class ExtensionManager: ObservableObject {
installed.first(where: { $0.id == extensionID })?.capabilities.notificationFeed == true
}

private var refreshTimers: [String: Timer] = [:]
private var refreshTokens: [String: ModuleRefreshToken] = [:]
private var immediateRefreshWorkItems: [String: DispatchWorkItem] = [:]
private var presentedInteractionContexts: [String: PresentedInteractionContext] = [:]
private let fileManager = FileManager.default
Expand Down Expand Up @@ -244,6 +244,7 @@ final class ExtensionManager: ObservableObject {
runtime.activate()

startRefreshTimer(for: manifest)
syncRuntimeEnergyState()
refreshState(extensionID: extensionID)

ExtensionLogger.shared.log(extensionID, .info, "Activated extension")
Expand Down Expand Up @@ -280,6 +281,13 @@ final class ExtensionManager: ObservableObject {
}
}

func syncRuntimeEnergyState() {
for manifest in installed {
guard let runtime = runtimes[manifest.id] else { continue }
runtime.setTimersSuspended(shouldSuspendRuntimeTimers(for: manifest))
}
}

/// Routes a settings change into the extension's JS `onSettingsChanged`
/// hook. Called by `ExtensionSettingsStore.set` whenever a toggle/slider
/// writes a new value. We intentionally don't kick an immediate
Expand Down Expand Up @@ -433,19 +441,49 @@ final class ExtensionManager: ObservableObject {
return
}

let timer = Timer.scheduledTimer(withTimeInterval: max(0.1, manifest.refreshInterval), repeats: true) { [weak self] _ in
Task { @MainActor in
self?.refreshState(extensionID: manifest.id)
let interval = max(2, manifest.refreshInterval)
let module = refreshModule(for: manifest)
let policy: ModuleRefreshPolicy = manifest.capabilities.notificationFeed
? .interval(interval, tolerance: max(0.5, interval * 0.25))
: .visibleOnly(interval, tolerance: max(0.5, interval * 0.25))

refreshTokens[manifest.id] = ModuleRefreshScheduler.shared.register(
id: "extension.\(manifest.id).refresh",
name: "\(manifest.name) extension refresh",
module: module,
policy: policy,
enabled: { [weak self] in
guard let self else { return false }
return self.runtimes[manifest.id] != nil
&& manifest.capabilities.backgroundRefresh
&& manifest.activationTriggers.contains { $0.caseInsensitiveCompare("timer") == .orderedSame }
}
) { [weak self] in
self?.refreshState(extensionID: manifest.id)
}

RunLoop.main.add(timer, forMode: .common)
refreshTimers[manifest.id] = timer
}

private func stopRefreshTimer(for extensionID: String) {
refreshTimers[extensionID]?.invalidate()
refreshTimers.removeValue(forKey: extensionID)
ModuleRefreshScheduler.shared.unregister(refreshTokens[extensionID])
refreshTokens.removeValue(forKey: extensionID)
}

private func refreshModule(for manifest: ExtensionManifest) -> ActiveModule {
manifest.capabilities.notificationFeed ? .builtIn(.notifications) : .extension_(manifest.id)
}

private func shouldSuspendRuntimeTimers(for manifest: ExtensionManifest) -> Bool {
let module = refreshModule(for: manifest)
let appState = AppState.shared
guard !appState.isModuleVisibleForRefresh(module) else { return false }

if appState.effectiveEnergyMode == .lowPower || appState.disableBackgroundExtensionRefresh {
return true
}

return appState.effectiveEnergyMode == .smart
&& appState.currentState == .compact
&& !appState.isHovering
}
}
struct WhatsAppWebMessage: Identifiable {
Expand Down
2 changes: 1 addition & 1 deletion Extensions/agents-status/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@
"backgroundRefresh": true,
"settings": true
},
"refreshInterval": 0.5,
"refreshInterval": 5.0,
"activationTriggers": ["manual", "timer"]
}
40 changes: 40 additions & 0 deletions PR_NOTES_PR-01.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# PR-01 Notes

Title: Reduce idle energy usage with central module refresh scheduling

Branch: `perf/energy-and-refresh-scheduler`

Linked issues:
- #67 Energy consumption on MacBook Air is very poor
- #68 Poor power management
- #69 Using Significant Energy

Summary:
- Adds a central refresh scheduler for built-in modules and extensions.
- Adds Normal, Smart, and Low Power profiles in Settings -> General -> Power.
- Pauses or slows non-essential refresh while modules are hidden, disabled, collapsed, or inactive.
- Adds optional Low Power suggestions for battery transitions and sustained refresh activity.
- Adds scheduler diagnostics in Settings -> Advanced.
- Adds timer tolerance, lifecycle cleanup, and duplicate state suppression across the highest-frequency managers.
- Guards extension refresh intervals and suspends inactive extension timers in Smart and Low Power conditions.

Validation:
- `git diff --check` passed.
- Direct Swift typecheck of app sources passed with the package-backed analytics source excluded because package resolution depends on generated project setup.
- `xcodebuild -version` reported Xcode 26.5.
- `xcodegen generate` could not run because `xcodegen` is unavailable locally.
- `xcodebuild -project SuperIsland.xcodeproj -scheme SuperIsland -configuration Debug build` could not run because the project file is generated and is not present without XcodeGen.

Screenshots needed:
- Settings -> General -> Power section.
- Settings -> Advanced -> Energy Diagnostics section with scheduled jobs visible.

Risk notes:
- Refresh cadence changes can affect perceived freshness for notifications, extensions, and Now Playing fallback updates.
- Calendar still has existing Swift concurrency warnings around EventKit values used off the main queue.
- Weather still has existing macOS 26 deprecation warnings for reverse geocoding APIs.
- Manual idle-energy verification in Activity Monitor or Instruments is still needed on a real Mac session.

PR status:
- Branch is prepared locally.
- PR was not opened locally because the GitHub CLI is unavailable.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ scripts/ Build & release scripts

Extensions are JavaScript packages that run inside a sandboxed JavaScriptCore context. Read the full guide at [dynamicisland.app/docs](https://dynamicisland.app/docs) or in [EXTENSIONS.md](EXTENSIONS.md).

## Energy settings

Settings -> General -> Power includes Normal, Smart, and Low Power modes. Smart reduces background refresh while the island is collapsed, while Low Power slows non-essential work and pauses inactive extension timers. See [docs/ENERGY.md](docs/ENERGY.md) for profiling notes and scheduler behavior.

## Appearance

Home slots, compact island size, animation intensity, and reduced motion can be configured in Settings. See [docs/APPEARANCE.md](docs/APPEARANCE.md).
Expand Down
29 changes: 29 additions & 0 deletions SuperIsland/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var updateCancellable: AnyCancellable?
private var statusItem: NSStatusItem?
private var menuBarDefaultsObserver: NSObjectProtocol?
private var powerStateObserver: NSObjectProtocol?
private var quitHotkeyMonitor: Any?
private var didBootstrapApp = false
private static var fallbackSettingsWindowController: NSWindowController?
Expand Down Expand Up @@ -44,10 +45,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
AgentsStatusBridge.shared.stop()
}

func applicationDidBecomeActive(_ notification: Notification) {
AppState.shared.setAppActive(true)
}

func applicationDidResignActive(_ notification: Notification) {
AppState.shared.setAppActive(false)
}

deinit {
if let quitHotkeyMonitor {
NSEvent.removeMonitor(quitHotkeyMonitor)
}
if let powerStateObserver {
NotificationCenter.default.removeObserver(powerStateObserver)
}
}

private func bootstrapApp() {
Expand All @@ -56,6 +68,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
setupIslandWindow()
applyMenuBarVisibility()
observeMenuBarSetting()
observePowerState()
initializeManagers()
}

Expand Down Expand Up @@ -123,6 +136,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
extensions.discoverExtensions()
extensions.activateDiscoveredExtensions()
rebuildStatusMenu()
state.refreshEnergyState()

UpdateChecker.shared.checkIfDue()
observeUpdateState()
Expand Down Expand Up @@ -355,6 +369,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.applyMenuBarVisibility()
ModuleRefreshScheduler.shared.refreshScheduling()
ExtensionManager.shared.syncRuntimeEnergyState()
}
}
}

private func observePowerState() {
guard powerStateObserver == nil else { return }
powerStateObserver = NotificationCenter.default.addObserver(
forName: Notification.Name.NSProcessInfoPowerStateDidChange,
object: nil,
queue: .main
) { _ in
Task { @MainActor in
AppState.shared.refreshEnergyState()
}
}
}
Expand Down
88 changes: 84 additions & 4 deletions SuperIsland/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -233,12 +233,20 @@ final class AppState: ObservableObject {
didChangeState?(oldValue, currentState)
}
handleStateTransition(from: oldValue, to: currentState)
refreshEnergyState()
}
}
@Published var activeModule: ActiveModule? = nil
@Published var activeModule: ActiveModule? = nil {
didSet { refreshEnergyState() }
}
@Published var previousModule: ActiveModule? = nil
@Published var fullExpandedSelectedTab: FullExpandedTab = .home
@Published var isHovering: Bool = false
@Published var fullExpandedSelectedTab: FullExpandedTab = .home {
didSet { refreshEnergyState() }
}
@Published var isHovering: Bool = false {
didSet { refreshEnergyState() }
}
@Published private(set) var isAppActive: Bool = true
@Published private(set) var isShelfDragActive = false
/// Set by IslandWindowController during overshoot animations to prevent
/// hover-triggered dismiss from firing while the window frame is resizing.
Expand Down Expand Up @@ -292,6 +300,12 @@ final class AppState: ObservableObject {
@AppStorage("onboarding.completed") var onboardingCompleted = false
@AppStorage("debug.alwaysShowOnboarding") var debugAlwaysShowOnboarding = false

// Energy settings
@AppStorage("energy.mode") private var energyModeRawValue = EnergyMode.smart.rawValue
@AppStorage("energy.reduceAnimations") var reduceAnimations = false
@AppStorage("energy.disableBackgroundExtensionRefresh") var disableBackgroundExtensionRefresh = false
@AppStorage("energy.lowPowerSuggestionDoNotAskAgain") var lowPowerSuggestionDoNotAskAgain = false

private var autoDismissWorkItem: DispatchWorkItem?
private var fullExpandedDismissWorkItem: DispatchWorkItem?
private var hoverActivationWorkItem: DispatchWorkItem?
Expand Down Expand Up @@ -332,8 +346,25 @@ final class AppState: ObservableObject {
: .easeOut(duration: 0.18 / max(0.5, animationSpeed))
}

var energyMode: EnergyMode {
get { EnergyMode(rawValue: energyModeRawValue) ?? .smart }
set {
guard energyModeRawValue != newValue.rawValue else { return }
energyModeRawValue = newValue.rawValue
refreshEnergyState()
}
}

var effectiveEnergyMode: EnergyMode {
ProcessInfo.processInfo.isLowPowerModeEnabled ? .lowPower : energyMode
}

var shouldReduceAnimations: Bool {
reduceAnimations || effectiveEnergyMode == .lowPower || NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
}

var shouldReduceMotion: Bool {
reduceMotion || animationLevel == .reduced
reduceMotion || animationLevel == .reduced || shouldReduceAnimations
}

var animationLevel: AnimationLevel {
Expand All @@ -357,6 +388,55 @@ final class AppState: ObservableObject {
}
}

func setAppActive(_ active: Bool) {
guard isAppActive != active else { return }
isAppActive = active
refreshEnergyState()
}

func refreshEnergyState() {
let activity = IslandActivityState(
islandState: currentState,
activeModule: activeModule,
fullExpandedTab: fullExpandedSelectedTab,
isHovering: isHovering,
isAppActive: isAppActive
)
ModuleRefreshScheduler.shared.updateActivityState(activity)
ExtensionManager.shared.syncRuntimeEnergyState()
}

func isModuleVisibleForRefresh(_ module: ActiveModule) -> Bool {
isModuleVisibleForRefresh(
module,
state: IslandActivityState(
islandState: currentState,
activeModule: activeModule,
fullExpandedTab: fullExpandedSelectedTab,
isHovering: isHovering,
isAppActive: isAppActive
)
)
}

func isModuleVisibleForRefresh(_ module: ActiveModule, state: IslandActivityState) -> Bool {
if state.activeModule == module {
return true
}

if state.islandState == .fullExpanded,
case .module(let selectedModule) = state.fullExpandedTab,
selectedModule == module {
return true
}

if state.islandState == .compact, compactPresentationModule == module {
return true
}

return false
}

// MARK: - State Transitions

func toggleExpansion() {
Expand Down
Loading