Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
724f99a
Add trigger automation and stabilize cursor moves
alvst Jun 21, 2026
bd8e950
Run SwiftFormat
alvst Jun 21, 2026
30e4bc9
Update trigger roadmap documentation
alvst Jun 21, 2026
b120197
Fix SwiftLint for VPN trigger scan
alvst Jun 22, 2026
8f7b728
Address CodeRabbit quick fixes
alvst Jun 23, 2026
4512286
Avoid app menu hiding when Dock reserves space
alvst Jun 23, 2026
c18cc0b
Address remaining CodeRabbit recommendations
alvst Jun 23, 2026
f7a6450
Skip virtual display provoke when Dock reserves space
alvst Jun 24, 2026
30877f5
Stabilize frontmost trigger moves
alvst Jun 24, 2026
69a7382
Fix hidden bar move verification
alvst Jun 24, 2026
a6bdd34
Allow drops into badge-only layout sections
alvst Jun 24, 2026
13eb10a
Improve trigger timing diagnostics
alvst Jun 24, 2026
ebd47df
Make app running triggers respond faster
alvst Jun 24, 2026
867e762
Add app running trigger poll fallback
alvst Jun 24, 2026
17e541f
Update strings for trigger and layout fixes
alvst Jun 24, 2026
88f5ee4
UI improvements to Triggers
alvst Jun 24, 2026
3d84629
Recover missing menu bar source PID
alvst Jun 24, 2026
4c4706e
Improve trigger scheduling and diagnostics
alvst Jun 24, 2026
33dd723
Fix cursor warps across external displays
alvst Jun 27, 2026
893f985
Remove profile layout cursor restore
alvst Jun 27, 2026
2701caf
Restore pre-fix cursor behavior
alvst Jun 27, 2026
b092735
Instrument cursor warp diagnostics
alvst Jun 27, 2026
d4b525f
Keep cursor hidden across move retries
alvst Jun 27, 2026
cf8cbe1
Back off failed pending cursor moves
alvst Jun 27, 2026
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
6 changes: 3 additions & 3 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,14 @@ body:
id: logs
attributes:
label: Logs / Console Output
description: Attach relevant logs.
description: Please ensure you have reproduced the issue with Diagnostic logging enabled (Settings → Advanced → Diagnostics → Enable diagnostic logging).
validations:
required: false
required: true

- type: textarea
id: additional
attributes:
label: Additional Context
description: Screenshots, screen recordings, configuration details, etc.
validations:
required: false
required: false
6 changes: 3 additions & 3 deletions .github/aw/actions-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
"version": "v9.0.0",
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
},
"github/gh-aw-actions/setup@v0.76.1": {
"github/gh-aw-actions/setup@v0.79.6": {
"repo": "github/gh-aw-actions/setup",
"version": "v0.76.1",
"sha": "46d564922b082d0db93244972e8005ea6904ee5f"
"version": "v0.79.6",
"sha": "5c2fe865bb4dc46e1450f6ee0d0541d759aea73a"
},
"github/gh-aw/actions/setup@v0.76.1": {
"repo": "github/gh-aw/actions/setup",
Expand Down
280 changes: 206 additions & 74 deletions .github/workflows/issue-triage.lock.yml

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions .github/workflows/issue-triage.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
---
description: "Triages new issues: labels by type and priority, identifies duplicates, and asks clarifying questions ONLY when required fields are missing."
engine:
id: copilot
model: gpt-5-mini
on:
issues:
types: [opened]
Expand Down
42 changes: 42 additions & 0 deletions .github/workflows/remove.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Remove Skipped Workflow Runs
on:
workflow_dispatch:
jobs:
remove-skipped-runs:
runs-on: ubuntu-slim
permissions:
actions: write
steps:
- name: Remove Skipped Workflow Runs
uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const runs = await github.paginate(
github.rest.actions.listWorkflowRunsForRepo,
{
owner: context.repo.owner,
repo: context.repo.repo,
status: "skipped",
per_page: 100,
},
);

let deletedCount = 0;
let failedCount = 0;

for (const run of runs) {
try {
await github.rest.actions.deleteWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
});
deletedCount++;
} catch (error) {
console.error(`Error deleting workflow run: ${error.message}`);
failedCount++;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add error handling for pagination failures and improve logging.

The workflow has two error handling gaps:

  1. Pagination failures not caught (line 15): If github.paginate() throws an error (e.g., API rate limit, network timeout), the entire script fails silently. Wrap it in try/catch.

  2. Limited deletion auditing (lines 28-40): Only counts are logged; individual run IDs are not recorded. If something goes wrong, there's no way to verify which runs were deleted or retry failures.

  3. No rate limiting (line 28): Attempting to delete many runs in quick succession could trigger GitHub API throttling.

Consider adding per-deletion delays (await new Promise(r => setTimeout(r, 100))) and logging individual run IDs for audit trails.

💡 Suggested improvements
  script: |
+   let paginationError = false;
    const runs = await github.paginate(
      github.rest.actions.listWorkflowRunsForRepo,
      {
        owner: context.repo.owner,
        repo: context.repo.repo,
        status: "skipped",
        per_page: 100,
      },
+   ).catch(err => {
+     console.error(`Pagination error: ${err.message}`);
+     paginationError = true;
+     return [];
+   });
    
+   if (paginationError) {
+     core.setFailed('Failed to fetch skipped workflow runs');
+     return;
+   }
+   
    let deletedCount = 0;
    let failedCount = 0;
+   const deletedRunIds = [];
+   const failedRunIds = [];
    
    for (const run of runs) {
+     // Rate limiting: 100ms delay between deletions
+     await new Promise(r => setTimeout(r, 100));
      try {
        await github.rest.actions.deleteWorkflowRun({
          owner: context.repo.owner,
          repo: context.repo.repo,
          run_id: run.id,
        });
        deletedCount++;
+       deletedRunIds.push(run.id);
      } catch (error) {
        console.error(`Error deleting run ${run.id}: ${error.message}`);
        failedCount++;
+       failedRunIds.push(run.id);
      }
    }
    console.log(`Deleted skipped workflow runs: ${deletedCount}`);
+   console.log(`Deleted run IDs: ${deletedRunIds.join(', ')}`);
    console.log(`Failed to delete workflow runs: ${failedCount}`);
+   if (failedCount > 0) {
+     console.log(`Failed run IDs: ${failedRunIds.join(', ')}`);
+   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const runs = await github.paginate(
github.rest.actions.listWorkflowRunsForRepo,
{
owner: context.repo.owner,
repo: context.repo.repo,
status: "skipped",
per_page: 100,
},
);
let deletedCount = 0;
let failedCount = 0;
for (const run of runs) {
try {
await github.rest.actions.deleteWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
});
deletedCount++;
} catch (error) {
console.error(`Error deleting workflow run: ${error.message}`);
failedCount++;
}
}
let paginationError = false;
const runs = await github.paginate(
github.rest.actions.listWorkflowRunsForRepo,
{
owner: context.repo.owner,
repo: context.repo.repo,
status: "skipped",
per_page: 100,
},
).catch(err => {
console.error(`Pagination error: ${err.message}`);
paginationError = true;
return [];
});
if (paginationError) {
core.setFailed('Failed to fetch skipped workflow runs');
return;
}
let deletedCount = 0;
let failedCount = 0;
const deletedRunIds = [];
const failedRunIds = [];
for (const run of runs) {
// Rate limiting: 100ms delay between deletions
await new Promise(r => setTimeout(r, 100));
try {
await github.rest.actions.deleteWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
});
deletedCount++;
deletedRunIds.push(run.id);
} catch (error) {
console.error(`Error deleting run ${run.id}: ${error.message}`);
failedCount++;
failedRunIds.push(run.id);
}
}
console.log(`Deleted skipped workflow runs: ${deletedCount}`);
console.log(`Deleted run IDs: ${deletedRunIds.join(', ')}`);
console.log(`Failed to delete workflow runs: ${failedCount}`);
if (failedCount > 0) {
console.log(`Failed run IDs: ${failedRunIds.join(', ')}`);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/remove.yml around lines 15 - 40, The pagination call to
github.paginate() is not wrapped in error handling, so API failures cause silent
script failure. Wrap the github.paginate() call in a try/catch block to handle
potential API errors. Additionally, the deletion loop lacks per-run logging and
rate limiting. Add console logging for each individual run.id when successfully
deleted (in the try block) and when failures occur (in the catch block for each
run). Finally, add a small delay between deletion attempts using await new
Promise(r => setTimeout(r, 100)) after each successful deletion to avoid
triggering GitHub API rate limits during batch operations.

console.log(`Deleted skipped workflow runs: ${deletedCount}`);
console.log(`Failed to delete workflow runs: ${failedCount}`);
123 changes: 104 additions & 19 deletions MenuBarItemService/SourcePIDCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ final class SourcePIDCache {
return state.apps
}

let ccBundleID = "com.apple.controlcenter"
var appsChecked = 0
var appsWithBar = 0
var totalChildrenChecked = 0
Expand All @@ -366,18 +367,41 @@ final class SourcePIDCache {
let children = AXHelpers.children(for: bar)
for child in children {
totalChildrenChecked += 1
guard AXHelpers.isEnabled(child),
// Skip only children the app marks explicitly disabled. A
// missing AXEnabled attribute (nil) is treated as enabled:
// some status items hosted by Control Center (The Clock's
// among them) never publish AXEnabled, and treating absent as
// disabled would drop an otherwise exact positional match and
// leave the item unresolved.
guard AXHelpers.enabledAttribute(child) != false,
let childFrame = AXHelpers.frame(for: child)
else {
continue
}

let childCenter = childFrame.center

// Match this child to ANY window in our list.
// Match this child to ANY window in our list, but skip
// Control-Center-hosted generic slots. Control Center is the
// CG owner for every CC-hosted NSStatusItem. When the matched
// app is Control Center and the window title is a generic
// Item-N slot, the spatial match only confirms the window is
// CC-hosted; it does not identify the owning app. Writing
// Control Center's PID would tag the item as a transient CC
// widget (isTransientControlCenterItem true, canBeHidden
// false), hiding it from profile management and the
// virtual-display provoke's orphan scan. Leaving it
// unresolved lets the marker-pair pass below supply the real
// owner PID; named CC items (BentoBox-0, Clock, WiFi,
// NowPlaying) carry non-generic titles and resolve to Control
// Center normally.
if let matchedWindow = allWindows.first(where: {
$0.bounds.center.distance(to: childCenter) <= 1
}) {
}), !MarkerPairResolver.isCCHostedGenericSlot(
appBundleID: app.bundleIdentifier,
windowTitle: matchedWindow.title,
ccBundleID: ccBundleID
) {
totalMatchesFound += 1
unresolvedWindows.remove(matchedWindow.windowID)
let pid = app.processIdentifier
Expand All @@ -387,6 +411,53 @@ final class SourcePIDCache {
}
}

// Corroborated spatial fallback for Control-Center-hosted items
// whose own app DOES publish an extras-bar AX child, but offset from
// the CG window center by more than the strict 1pt pass tolerates.
// The hosting CG slot is wider than the real icon, so their centers
// diverge: AirBuddy's by ~2pt, SpamSieve's by up to ~8pt. Accept the
// nearest such child within a generous radius ONLY when the window's
// reverse-DNS title is in an owner relationship with the app's bundle
// identifier (HostedItemOwnership). The title corroboration, not the
// distance, is what makes this safe: a nearby unrelated neighbor
// (WireGuard's slot beside Updatest at ~2pt) fails the owner check and
// is left for later passes. Runs BEFORE marker-pair so items that have
// their own AX child are claimed here and never reach that fallback.
// Empirically the furthest correct owner-corroborated match across
// captured logs is ~15pt; 20 leaves margin while staying well inside
// a neighbor's slot. The owner check is the real guard.
let hostedExtrasMatchRadius: CGFloat = 20
for app in apps {
if unresolvedWindows.isEmpty { break }
guard let appBundleID = app.bundleIdentifier else { continue }
let candidateWindows = allWindows.filter {
unresolvedWindows.contains($0.windowID)
&& HostedItemOwnership.titleIndicatesOwner($0.title, bundleID: appBundleID)
}
guard !candidateWindows.isEmpty else { continue }
autoreleasepool {
guard let bar = app.getOrCreateExtrasMenuBar() else { return }
let childCenters = AXHelpers.children(for: bar).compactMap { child -> CGPoint? in
guard AXHelpers.enabledAttribute(child) != false,
let frame = AXHelpers.frame(for: child)
else {
return nil
}
return frame.center
}
guard !childCenters.isEmpty else { return }
for window in candidateWindows {
let target = window.bounds.center
let nearest = childCenters.lazy.map { $0.distance(to: target) }.min()
?? .greatestFiniteMagnitude
guard nearest <= hostedExtrasMatchRadius else { continue }
totalMatchesFound += 1
unresolvedWindows.remove(window.windowID)
state.withLock { $0.pids[window.windowID] = app.processIdentifier }
}
}
}

// Marker-pair PID resolution.
//
// On macOS 26 some widgets (Little Snitch's agent observed in
Expand Down Expand Up @@ -420,7 +491,6 @@ final class SourcePIDCache {
// never be attributed to a third-party widget.
if !unresolvedWindows.isEmpty {
let thawBundleID = "com.stonerl.Thaw"
let ccBundleID = "com.apple.controlcenter"
let markers = MarkerPairResolver.extractMarkers(
from: allWindows.map { win in
(
Expand Down Expand Up @@ -509,38 +579,53 @@ final class SourcePIDCache {
let unresolvedWindowInfos = allWindows.filter { unresolvedWindows.contains($0.windowID) }
for window in unresolvedWindowInfos {
let target = window.bounds.center
var bestDistance = CGFloat.greatestFiniteMagnitude
var bestLabel = "(none)"
var bestFrame: CGRect?
// Collect every extras-bar child across all apps as a candidate,
// not just the single closest, so the diagnostic shows whether the
// nearest match is unique or whether a competing child sits within
// the match radius. Paired with each candidate's enabled state and
// distance, this is usually enough to see why an item failed to
// resolve (wrong distance, missing AXEnabled, or ambiguity).
var candidates: [(distance: CGFloat, label: String, frame: CGRect, enabled: Bool?)] = []
for app in apps {
guard let bar = app.getOrCreateExtrasMenuBar() else { continue }
let children = AXHelpers.children(for: bar)
for child in children {
let label = app.bundleIdentifier ?? app.localizedName ?? "pid=\(app.processIdentifier)"
for child in AXHelpers.children(for: bar) {
guard let frame = AXHelpers.frame(for: child) else { continue }
let d = frame.center.distance(to: target)
if d < bestDistance {
bestDistance = d
bestLabel = app.bundleIdentifier ?? app.localizedName ?? "pid=\(app.processIdentifier)"
bestFrame = frame
}
candidates.append((frame.center.distance(to: target), label, frame, AXHelpers.enabledAttribute(child)))
}
}
let nearest = candidates.sorted { $0.distance < $1.distance }
let best = nearest.first
let cgOwner = window.owningApplication.map { app in
"\(app.bundleIdentifier ?? app.localizedName ?? "?"):pid=\(app.processIdentifier)"
} ?? "nil"
// closestAXEnabled distinguishes a missing AXEnabled attribute (nil)
// from an explicitly disabled child, and nearest lists the top
// candidates with their owning app and enabled state, so a future
// unresolved item can be diagnosed from a single log line.
let nearestDesc = nearest.prefix(3).map {
"\($0.label)@\(String(format: "%.1f", $0.distance))(enabled=\($0.enabled.map { "\($0)" } ?? "nil"))"
}.joined(separator: ", ")
SourcePIDCache.diagLog.debug(
"SourcePIDCache diag unresolved: windowID=\(window.windowID) title=\(window.title ?? "nil") bounds=\(window.bounds) center=\(target) | cgOwner=\(cgOwner) ownerName=\(window.ownerName ?? "nil") | closestAXFrame=\(bestFrame.map { "\($0)" } ?? "nil") in app=\(bestLabel) distance=\(bestDistance)"
"SourcePIDCache diag unresolved: windowID=\(window.windowID) title=\(window.title ?? "nil") bounds=\(window.bounds) center=\(target) | cgOwner=\(cgOwner) ownerName=\(window.ownerName ?? "nil") | closestAXFrame=\(best.map { "\($0.frame)" } ?? "nil") in app=\(best?.label ?? "(none)") distance=\(best?.distance ?? .greatestFiniteMagnitude) closestAXEnabled=\(best?.enabled.map { "\($0)" } ?? "nil") | nearest=[\(nearestDesc)]"
)
}

for app in apps {
guard let bar = app.getOrCreateExtrasMenuBar() else { continue }
let children = AXHelpers.children(for: bar)
let frames = children.compactMap { AXHelpers.frame(for: $0) }
guard !frames.isEmpty else { continue }
// Include each child's raw enabled value (nil = attribute absent)
// next to its frame, so a child the matching pass excluded as
// explicitly disabled is visible here.
let childDescs = children.compactMap { child -> String? in
guard let frame = AXHelpers.frame(for: child) else { return nil }
let enabled = AXHelpers.enabledAttribute(child).map { "\($0)" } ?? "nil"
return "(x=\(frame.minX),y=\(frame.minY),w=\(frame.width),h=\(frame.height),enabled=\(enabled))"
}
guard !childDescs.isEmpty else { continue }
let label = app.bundleIdentifier ?? app.localizedName ?? "pid=\(app.processIdentifier)"
SourcePIDCache.diagLog.debug(
"SourcePIDCache diag app=\(label) extrasBar children=\(children.count) frames=\(frames.map { "(x=\($0.minX),y=\($0.minY),w=\($0.width),h=\($0.height))" }.joined(separator: " "))"
"SourcePIDCache diag app=\(label) extrasBar children=\(children.count) frames=\(childDescs.joined(separator: " "))"
)
}
}
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,8 @@ If a language you'd like to help to translate is not listed here, let us know an
- [x] Profiles for menu bar layout
- [ ] Individual spacer items
- [ ] Menu bar item groups
- [ ] Show menu bar items when trigger conditions are met
- [x] Show menu bar items when battery/power trigger conditions are met
- [ ] Optimize additional trigger sources

### Menu bar appearance

Expand Down
25 changes: 25 additions & 0 deletions Shared/Bridging/Bridging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// Licensed under the GNU GPLv3

import Cocoa
import os
@preconcurrency import ScreenCaptureKit

// MARK: - Bridging
Expand Down Expand Up @@ -79,6 +80,27 @@ extension Bridging {
extension Bridging {
// MARK: Private Display Helpers

/// A display to exclude from Thaw's display enumeration while it exists.
///
/// Set to the identifier of the transient virtual display that
/// VirtualDisplayProvoker creates to provoke marker-pair resolution on a
/// single-display machine, and cleared when it is removed. Filtering it
/// here keeps the phantom out of every display list derived from
/// getActiveDisplayList (including the active-menu-bar-display lookup),
/// so it never drives profile auto-switch or per-display state. nil during
/// normal operation, which makes the filter a no-op.
///
/// Backed by an unfair lock: the MainActor writer (VirtualDisplayProvoker)
/// and the off-MainActor readers (the nonisolated image-capture tasks reach
/// it through getActiveMenuBarDisplayID) would otherwise race on this
/// non-atomic optional.
static var excludedDisplayID: CGDirectDisplayID? {
get { excludedDisplayIDStorage.withLock { $0 } }
set { excludedDisplayIDStorage.withLock { $0 = newValue } }
}

private static let excludedDisplayIDStorage = OSAllocatedUnfairLock<CGDirectDisplayID?>(initialState: nil)

private static func getActiveDisplayCount() -> UInt32? {
var count: UInt32 = 0
let result = CGGetActiveDisplayList(0, nil, &count)
Expand All @@ -99,6 +121,9 @@ extension Bridging {
diagLog.error("CGGetActiveDisplayList failed with error \(result.logString)")
return []
}
if let excluded = excludedDisplayID {
list.removeAll { $0 == excluded }
}
return list
}

Expand Down
25 changes: 25 additions & 0 deletions Shared/Utilities/AXHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ enum AXHelpers {
queue.sync { try? element.attribute(.enabled) } ?? false
}

/// The raw AXEnabled attribute, or nil when the element does not expose it.
/// isEnabled collapses a missing attribute to false, so it cannot tell an
/// explicitly disabled element from one that simply does not publish the
/// attribute. Callers that must keep that distinction use this: source-PID
/// matching treats absent as enabled, and the unresolved-item diagnostics
/// report it verbatim.
static func enabledAttribute(_ element: UIElement) -> Bool? {
queue.sync { try? element.attribute(.enabled) }
}

static func frame(for element: UIElement) -> CGRect? {
queue.sync { try? element.attribute(.frame) }
}
Expand All @@ -56,4 +66,19 @@ enum AXHelpers {
return result == .success ? pid : nil
}
}

/// Performs the press action on the given element, returning whether it
/// succeeded. Used to open the menus of Electron/Chromium tray items, which
/// ignore synthetic mouse clicks.
@discardableResult
static func press(_ element: UIElement) -> Bool {
queue.sync {
do {
try element.performAction(.press)
return true
} catch {
return false
}
}
}
}
Loading
Loading