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
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: CI

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: macos-26
steps:
- name: Check out Floodlight
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6

- name: Run tests
run: make test

- name: Build app bundle
run: make bundle
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ run: debug
./scripts/run.sh

test:
swift test --package-path Vendor/fff-swift
swift test

bundle: build
Expand Down
15 changes: 0 additions & 15 deletions Package.resolved

This file was deleted.

5 changes: 1 addition & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,7 @@ let package = Package(
.executable(name: "Floodlight", targets: ["Floodlight"])
],
dependencies: [
.package(
url: "https://github.com/vmg-dev/fff-swift",
from: "0.2.1"
)
.package(path: "Vendor/fff-swift")
],
targets: [
.executableTarget(
Expand Down
28 changes: 5 additions & 23 deletions Sources/Floodlight/Search/SearchCoordinator.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import AppKit
import Foundation
import Observation
import ServiceManagement

@MainActor
@Observable
Expand Down Expand Up @@ -55,6 +54,7 @@ final class SearchCoordinator {

private let index: FFFIndex
private let applicationCatalog: ApplicationCatalog
private let launchAtLoginController = LaunchAtLoginController()
private let recentStore: RecentStore
private let quickLook = QuickLookController()
private var allResults: [SearchItem] = []
Expand Down Expand Up @@ -102,6 +102,7 @@ final class SearchCoordinator {
index = FFFIndex(
rootURL: initialRoot,
storageURL: indexStorage,
enableHomeDirectoryScanning: true,
logFilePath: environment["FLOODLIGHT_FFF_LOG"],
logLevel: environment["FLOODLIGHT_FFF_LOG_LEVEL"] ?? "info"
)
Expand Down Expand Up @@ -323,7 +324,7 @@ final class SearchCoordinator {
}

var launchesAtLogin: Bool {
SMAppService.mainApp.status == .enabled
launchAtLoginController.isEnabled
}

/// Registers the login item on the very first launch only.
Expand All @@ -333,32 +334,13 @@ final class SearchCoordinator {
/// decision: if you later turn it off — here or in System Settings — the
/// next launch leaves it off instead of switching it back on.
func enableLaunchAtLoginOnFirstRun() {
let defaults = UserDefaults.standard
guard !defaults.bool(forKey: Self.launchAtLoginConfiguredKey) else { return }
defaults.set(true, forKey: Self.launchAtLoginConfiguredKey)

guard SMAppService.mainApp.status != .enabled else { return }
do {
try SMAppService.mainApp.register()
} catch {
NSLog(
"Floodlight could not enable launch at login: %@",
error.localizedDescription
)
}
launchAtLoginController.enableOnFirstRun()
}

func setLaunchAtLogin(_ enabled: Bool) throws {
if enabled {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
UserDefaults.standard.set(true, forKey: Self.launchAtLoginConfiguredKey)
try launchAtLoginController.setEnabled(enabled)
}

private static let launchAtLoginConfiguredKey = "launch-at-login-configured"

private var selectedItem: SearchItem? {
guard let selectedID else { return results.first }
return results.first { $0.id == selectedID }
Expand Down
122 changes: 122 additions & 0 deletions Sources/Floodlight/Utilities/LaunchAtLoginController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import Foundation
import ServiceManagement

@MainActor
protocol LaunchAtLoginService: AnyObject {
var status: SMAppService.Status { get }

func register() throws
func unregister() throws
}

extension SMAppService: LaunchAtLoginService {}

enum LaunchAtLoginError: LocalizedError {
case requiresApproval
case unavailable

var errorDescription: String? {
switch self {
case .requiresApproval:
"Allow Floodlight in System Settings → General → Login Items & Extensions."
case .unavailable:
"macOS did not enable Floodlight as a login item. Try again after moving Floodlight to Applications."
}
}
}

@MainActor
final class LaunchAtLoginController {
static let configuredKey = "launch-at-login-configured"

private let service: any LaunchAtLoginService
private let defaults: UserDefaults
private let logError: (String) -> Void

init(
service: any LaunchAtLoginService = SMAppService.mainApp,
defaults: UserDefaults = .standard,
logError: @escaping (String) -> Void = { message in
NSLog("Floodlight could not enable launch at login: %@", message)
}
) {
self.service = service
self.defaults = defaults
self.logError = logError
}

var isEnabled: Bool {
service.status == .enabled
}

/// Opts in on the first successful registration attempt.
///
/// A failed attempt deliberately leaves the preference unset so a later
/// launch can retry. A user-denied item is considered configured and is
/// never re-enabled behind the user's back.
func enableOnFirstRun() {
guard !defaults.bool(forKey: Self.configuredKey) else { return }

switch service.status {
case .enabled, .requiresApproval:
markConfigured()
return
case .notRegistered, .notFound:
break
@unknown default:
break
}

do {
try service.register()
try requireEnabledStatus()
markConfigured()
} catch {
logError(error.localizedDescription)
}
}

func setEnabled(_ enabled: Bool) throws {
if enabled {
switch service.status {
case .enabled:
break
case .requiresApproval:
throw LaunchAtLoginError.requiresApproval
case .notRegistered, .notFound:
try service.register()
try requireEnabledStatus()
@unknown default:
throw LaunchAtLoginError.unavailable
}
} else {
switch service.status {
case .enabled, .requiresApproval:
try service.unregister()
case .notRegistered, .notFound:
break
@unknown default:
break
}
}

markConfigured()
}

private func requireEnabledStatus() throws {
switch service.status {
case .enabled:
return
case .requiresApproval:
throw LaunchAtLoginError.requiresApproval
case .notRegistered, .notFound:
throw LaunchAtLoginError.unavailable
@unknown default:
throw LaunchAtLoginError.unavailable
}
}

private func markConfigured() {
defaults.set(true, forKey: Self.configuredKey)
}
}
112 changes: 112 additions & 0 deletions Tests/FloodlightTests/LaunchAtLoginControllerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import ServiceManagement
import XCTest
@testable import Floodlight

@MainActor
final class LaunchAtLoginControllerTests: XCTestCase {
func testFailedFirstRegistrationIsRetriedOnNextLaunch() throws {
let (defaults, suiteName) = try makeDefaults()
defer { defaults.removePersistentDomain(forName: suiteName) }
let service = FakeLaunchAtLoginService(status: .notRegistered)
service.registerError = TestError.registrationFailed
var loggedErrors: [String] = []
let controller = LaunchAtLoginController(
service: service,
defaults: defaults,
logError: { loggedErrors.append($0) }
)

controller.enableOnFirstRun()

XCTAssertEqual(service.registerCallCount, 1)
XCTAssertFalse(defaults.bool(forKey: LaunchAtLoginController.configuredKey))
XCTAssertEqual(loggedErrors.count, 1)

service.registerError = nil
controller.enableOnFirstRun()

XCTAssertEqual(service.registerCallCount, 2)
XCTAssertTrue(defaults.bool(forKey: LaunchAtLoginController.configuredKey))
XCTAssertTrue(controller.isEnabled)
}

func testAlreadyEnabledServiceCompletesFirstRunWithoutReregistering() throws {
let (defaults, suiteName) = try makeDefaults()
defer { defaults.removePersistentDomain(forName: suiteName) }
let service = FakeLaunchAtLoginService(status: .enabled)
let controller = LaunchAtLoginController(service: service, defaults: defaults)

controller.enableOnFirstRun()

XCTAssertEqual(service.registerCallCount, 0)
XCTAssertTrue(defaults.bool(forKey: LaunchAtLoginController.configuredKey))
}

func testExplicitOptOutPreventsAutomaticReregistration() throws {
let (defaults, suiteName) = try makeDefaults()
defer { defaults.removePersistentDomain(forName: suiteName) }
let service = FakeLaunchAtLoginService(status: .enabled)
let controller = LaunchAtLoginController(service: service, defaults: defaults)

try controller.setEnabled(false)
controller.enableOnFirstRun()

XCTAssertEqual(service.unregisterCallCount, 1)
XCTAssertEqual(service.registerCallCount, 0)
XCTAssertFalse(controller.isEnabled)
XCTAssertTrue(defaults.bool(forKey: LaunchAtLoginController.configuredKey))
}

func testDeniedServiceReturnsActionableError() throws {
let (defaults, suiteName) = try makeDefaults()
defer { defaults.removePersistentDomain(forName: suiteName) }
let service = FakeLaunchAtLoginService(status: .requiresApproval)
let controller = LaunchAtLoginController(service: service, defaults: defaults)

XCTAssertThrowsError(try controller.setEnabled(true)) { error in
XCTAssertEqual(
error.localizedDescription,
"Allow Floodlight in System Settings → General → Login Items & Extensions."
)
}
XCTAssertFalse(defaults.bool(forKey: LaunchAtLoginController.configuredKey))
}

private func makeDefaults() throws -> (UserDefaults, String) {
let suiteName = "FloodlightLaunchAtLoginTests-\(UUID().uuidString)"
return (try XCTUnwrap(UserDefaults(suiteName: suiteName)), suiteName)
}
}

@MainActor
private final class FakeLaunchAtLoginService: LaunchAtLoginService {
var status: SMAppService.Status
var registerError: Error?
var unregisterError: Error?
private(set) var registerCallCount = 0
private(set) var unregisterCallCount = 0

init(status: SMAppService.Status) {
self.status = status
}

func register() throws {
registerCallCount += 1
if let registerError {
throw registerError
}
status = .enabled
}

func unregister() throws {
unregisterCallCount += 1
if let unregisterError {
throw unregisterError
}
status = .notRegistered
}
}

private enum TestError: Error {
case registrationFailed
}
21 changes: 21 additions & 0 deletions Vendor/fff-swift/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 FFF Swift contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Loading