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
158 changes: 132 additions & 26 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,40 +29,32 @@
runs-on: ubuntu-24.04
outputs:
app: ${{ steps.what.outputs.app }}
site: ${{ steps.what.outputs.site }}
python: ${{ steps.what.outputs.python }}
actions: ${{ steps.what.outputs.actions }}
pages: ${{ steps.what.outputs.pages }}
head_sha: ${{ steps.what.outputs.head_sha }}
tree: ${{ steps.what.outputs.tree }}
fingerprint: ${{ steps.what.outputs.fingerprint }}
schema: ${{ steps.what.outputs.schema }}
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
# A pull-request check is attached to the head SHA, so build that head—not GitHub's synthetic merge ref.
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0

- id: what
name: Did anything outside the website change?
name: Classify the exact change
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: |
set -euo pipefail
# Anything we can't compare against runs the full build. Being wrong in that direction costs ten
# minutes; being wrong the other way ships an untested commit.
case "${{ github.event_name }}" in
pull_request) base="${{ github.event.pull_request.base.sha }}" ;;
push) base="${{ github.event.before }}" ;;
*) base="" ;;
esac
if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ] \
|| ! git cat-file -e "$base^{commit}" 2>/dev/null; then
echo "No base to compare against — building everything."
echo "app=true" >> "$GITHUB_OUTPUT"
exit 0
fi
changed=$(git diff --name-only "$base" "${{ github.sha }}")
echo "::group::Changed files"; echo "$changed"; echo "::endgroup::"
# The website and its sources. Everything else — Swift, project.yml, packaging, these workflows —
# is the app. project.yml is deliberately *not* here: it carries the version the site prints, but it
# also carries the build, and a version bump should be built.
rest=$(echo "$changed" | grep -Ev '^(site/|docs/|scripts/build_site\.py$|[^/]*\.md$|LICENSE$)' || true)
if [ -z "$rest" ]; then
echo "Website-only change — skipping the macOS build."
echo "app=false" >> "$GITHUB_OUTPUT"
else
echo "app=true" >> "$GITHUB_OUTPUT"
fi
python3 -I scripts/classify_changes.py \
--repo . --base "$BASE_SHA" --head "$HEAD_SHA" --github-output "$GITHUB_OUTPUT" \
> "$RUNNER_TEMP/change-classification.json"
cat "$RUNNER_TEMP/change-classification.json"

# Seconds of Python, so it runs on everything: the site is generated from Swift sources and project.yml as well
# as from site/, and "docs/ is out of date" should be caught whichever of those moved.
Expand All @@ -76,6 +68,7 @@
# clone can only see one commit, so every page would look as though it changed today.
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0

- name: Validate and verify the official rule-pack catalog
Expand Down Expand Up @@ -161,6 +154,8 @@
name: test (${{ matrix.xcode }} Xcode)
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}

- name: Select Xcode
run: |
Expand All @@ -184,6 +179,8 @@
scripts/test-notarize.sh
scripts/test-rule-pack-policy.sh
scripts/test-swift-warnings.sh
python3 -I scripts/test_classify_changes.py
python3 -I scripts/test_check_site.py
python3 scripts/test_release_checks.py

# This compiles a different product graph and activation path from the Developer ID build. The build clears
Expand Down Expand Up @@ -272,3 +269,112 @@
fi
set -e
scripts/check-swift-warnings.sh "$LOG"

# Exact-head evidence proves what was reviewed; this separate PR-only build proves that same head still compiles
# when combined with the current base. The trees are intentionally different, so this is not duplicate work.
merge-compatibility:
name: merge compatibility
needs: changes
if: github.event_name == 'pull_request' && needs.changes.outputs.app == 'true'
runs-on: macos-15
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
ref: refs/pull/${{ github.event.number }}/merge

- name: Build the prospective merge
Comment on lines +281 to +285
run: |
set -euo pipefail
sudo xcode-select -s "$(ls -d /Applications/Xcode*.app | sort -V | tail -1)"
xcodebuild build -project Flowlight.xcodeproj -scheme Flowlight -destination 'platform=macOS' \
-derivedDataPath "$RUNNER_TEMP/merge-build" \
CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM= CODE_SIGN_ENTITLEMENTS=

gate:
name: Review / gate
if: always()
needs: [changes, site, test, merge-compatibility]
runs-on: ubuntu-24.04
steps:
- name: Require every applicable review job
env:
CHANGES_RESULT: ${{ needs.changes.result }}
SITE_RESULT: ${{ needs.site.result }}
TEST_RESULT: ${{ needs.test.result }}
MERGE_RESULT: ${{ needs.merge-compatibility.result }}
EVENT_NAME: ${{ github.event_name }}
APP_ROUTE: ${{ needs.changes.outputs.app }}
HEAD_SHA: ${{ needs.changes.outputs.head_sha }}
TREE: ${{ needs.changes.outputs.tree }}
FINGERPRINT: ${{ needs.changes.outputs.fingerprint }}
SCHEMA: ${{ needs.changes.outputs.schema }}
run: |
set -euo pipefail
[[ "$CHANGES_RESULT" == success ]] || {
echo "::error::Change classification did not succeed: $CHANGES_RESULT"
exit 1
}
[[ "$SITE_RESULT" == success ]] || {
echo "::error::Site verification did not succeed: $SITE_RESULT"
exit 1
}
case "$APP_ROUTE:$TEST_RESULT" in
true:success|false:skipped) ;;
*)
echo "::error::Application route $APP_ROUTE produced test result $TEST_RESULT."
exit 1
;;
esac
if [[ "$EVENT_NAME" == pull_request && "$APP_ROUTE" == true ]]; then
[[ "$MERGE_RESULT" == success ]] || {
echo "::error::Prospective-merge compatibility did not succeed: $MERGE_RESULT"
exit 1
}
else
[[ "$MERGE_RESULT" == skipped ]] || {
echo "::error::Unexpected prospective-merge result: $MERGE_RESULT"
exit 1
}
fi
[[ -n "$HEAD_SHA" && -n "$TREE" && -n "$FINGERPRINT" && -n "$SCHEMA" ]] || {
echo "::error::The review receipt identity is incomplete."
exit 1
}
jq -n \
--arg schema "$SCHEMA" \
--arg repository "${{ github.repository }}" \
--arg workflow ".github/workflows/ci.yml" \
--arg event "${{ github.event_name }}" \
--arg source_sha "$HEAD_SHA" \
--arg source_tree "$TREE" \
--arg app_fingerprint "$FINGERPRINT" \
--arg app_route "$APP_ROUTE" \
--arg changes "$CHANGES_RESULT" \
--arg site "$SITE_RESULT" \
--arg tests "$TEST_RESULT" \
--arg merge_compatibility "$MERGE_RESULT" \
--arg run_id "${{ github.run_id }}" \
--arg run_attempt "${{ github.run_attempt }}" \
'{schema: $schema, repository: $repository, workflow: $workflow, event: $event,
source_sha: $source_sha, source_tree: $source_tree, app_fingerprint: $app_fingerprint,
app_route: $app_route,
conclusions: {changes: $changes, site: $site, tests: $tests,
merge_compatibility: $merge_compatibility},
run_id: $run_id, run_attempt: $run_attempt}' \
> "$RUNNER_TEMP/review-receipt.json"
{
echo "### Review gate passed"
echo
echo "- Source: \`$HEAD_SHA\`"
echo "- Tree: \`$TREE\`"
echo "- App route: \`$APP_ROUTE\`"
echo "- App fingerprint: \`$FINGERPRINT\`"
} >> "$GITHUB_STEP_SUMMARY"

- name: Preserve the shadow verification receipt
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: review-receipt-${{ needs.changes.outputs.head_sha }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/review-receipt.json
if-no-files-found: error
retention-days: 30
3 changes: 3 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ jobs:
build-mode: none
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
# Code scanning reports against the PR head, so compile that exact commit rather than the merge ref.
ref: ${{ github.event.pull_request.head.sha || github.sha }}

# Everything that is not the compilation happens before init, and deliberately so. Once init has run,
# CodeQL traces the job by preloading an x86_64 libtrace.dylib through DYLD_INSERT_LIBRARIES, which puts
Expand Down
12 changes: 6 additions & 6 deletions Flowlight.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2252,7 +2252,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 75;
CURRENT_PROJECT_VERSION = 76;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
Expand All @@ -2267,7 +2267,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.17.1;
MARKETING_VERSION = 0.17.2;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand Down Expand Up @@ -2313,7 +2313,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 75;
CURRENT_PROJECT_VERSION = 76;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 38RJUJHKZS;
ENABLE_HARDENED_RUNTIME = YES;
Expand All @@ -2328,7 +2328,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.17.1;
MARKETING_VERSION = 0.17.2;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand Down Expand Up @@ -2393,7 +2393,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 75;
CURRENT_PROJECT_VERSION = 76;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
Expand All @@ -2414,7 +2414,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.17.1;
MARKETING_VERSION = 0.17.2;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
Expand Down
36 changes: 36 additions & 0 deletions Flowlight/Analysis/Notifier.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import Foundation
import UserNotifications

Expand All @@ -19,6 +20,19 @@ final class NotificationRouter: NSObject, UNUserNotificationCenterDelegate {
}
}

enum NotificationAuthorizationState: Equatable {
case unknown, notRequested, denied, active

init(_ status: UNAuthorizationStatus) {
switch status {
case .notDetermined: self = .notRequested
case .denied: self = .denied
case .authorized, .provisional, .ephemeral: self = .active
@unknown default: self = .unknown
}
}
}

enum Notifier {
static let updateCategory = "flowlight.update"

Expand All @@ -32,6 +46,28 @@ enum Notifier {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
}

/// The live macOS permission state, rather than an inference from Flowlight's preference.
static func authorizationState() async -> NotificationAuthorizationState {
NotificationAuthorizationState(await UNUserNotificationCenter.current().notificationSettings().authorizationStatus)
}

/// Used when someone switches warnings on while Flowlight is already running. Startup requests alone made the
/// toggle appear ineffective until the next launch.
static func requestAuthorizationAndWait() async -> NotificationAuthorizationState {
configure()
do {
_ = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])
} catch {
// The settings query below remains authoritative and represents denial or an unavailable service.
}
return await authorizationState()
}

static func openSystemNotificationSettings() {
guard let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension") else { return }
NSWorkspace.shared.open(url)
}

/// Tells the person a new version is out. Clicking it opens the update window.
static func postUpdate(version: String, summary: String?) {
let content = UNMutableNotificationContent()
Expand Down
27 changes: 25 additions & 2 deletions Flowlight/App/AppNavigation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,18 @@ enum SidebarItem: String, CaseIterable, Identifiable {
case .capture: return "antenna.radiowaves.left.and.right"
}
}
/// The exact visual order of the sidebar. Enum declaration order is an implementation detail and used to
/// make Connections appear fourth while Alerts still claimed ⌘4. Commands and documentation both promise
/// positional shortcuts, so derive them from the grouped navigation people can actually see.
static var sidebarOrder: [SidebarItem] {
SidebarSection.allCases.flatMap(\.items)
}

/// ⌘1…⌘9 and then ⌘0, the way every browser numbers its tabs. Nil past the tenth: there is no eleventh
/// digit, and the arithmetic that assumed there was turned `10` into a `Character` and trapped the moment
/// a tenth screen was added.
var shortcut: KeyEquivalent? {
guard let index = Self.availableCases.firstIndex(of: self), index < 10 else { return nil }
guard let index = Self.sidebarOrder.firstIndex(of: self), index < 10 else { return nil }
return KeyEquivalent(Character("\(index == 9 ? 0 : index + 1)"))
}

Expand All @@ -97,6 +104,20 @@ enum SidebarItem: String, CaseIterable, Identifiable {
case .devices, .coverage, .capture: return .sources
}
}

/// Whether the destination can honestly say that the complete active Focus scope is filtering its content.
/// Alerts can apply app targets but have no destination field, while operational/configuration screens and Ask
/// do not apply traffic scope at all. They still show the persistent Focus notice, using neutral wording.
func fullyAppliesFocus(_ scope: FocusScope) -> Bool {
switch self {
case .live, .agents, .reports, .visualize, .inspect:
return true
case .alerts:
return scope.hosts.isEmpty
case .ask, .rules, .devices, .coverage, .capture:
return false
}
}
}

/// The sidebar in task-focused groups rather than one long column.
Expand Down Expand Up @@ -171,7 +192,9 @@ final class AppNavigation: ObservableObject {
let restored = SidebarItem(rawValue: UserDefaults.standard.string(forKey: "FLScreen") ?? "") ?? .live
return SidebarItem.availableCases.contains(restored) ? restored : .live
}()
@Published var rulesTab: RulesTab = .rules
@Published var rulesTab: RulesTab = {
RulesTab(rawValue: UserDefaults.standard.string(forKey: "FLRulesTab") ?? "") ?? .rules
}()
@Published var reportRequest: ReportRequest?
@Published var inspectRequest: InspectRequest?
@Published var ruleRequest: RuleRequest?
Expand Down
2 changes: 1 addition & 1 deletion Flowlight/App/FlowlightApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ struct FlowlightApp: App {
Button(L("Report an Issue")) { NSWorkspace.shared.open(Help.issues) }
}
CommandGroup(before: .sidebar) {
ForEach(SidebarItem.availableCases) { item in
ForEach(SidebarItem.sidebarOrder) { item in
if let shortcut = item.shortcut {
Button(item.title) { nav.show(item) }
.keyboardShortcut(shortcut, modifiers: .command)
Expand Down
Loading
Loading