Skip to content
Draft
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
1 change: 1 addition & 0 deletions Icon.icon/icon.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
{
"layers" : [
{
"fill" : "automatic",
"image-name" : "stack-rect.svg",
"name" : "stack-rect",
"position" : {
Expand Down
271 changes: 120 additions & 151 deletions Sources/Application/Coordinators/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,13 @@ final class AppCoordinator: ObservableObject {
@Published var isCheckingSingleton = true

// MARK: - Window Management

@Published var showMainWindow = false
@Published var mainWindowId = "main-window"
@Published var configWindow: NSWindow?

// MARK: - Private Properties

private var cancellables = Set<AnyCancellable>()
private var notificationObservers: [NSObjectProtocol] = []

// MARK: - Initialization

Expand All @@ -60,33 +59,30 @@ final class AppCoordinator: ObservableObject {
setupObservationChains()

logger.info("AppCoordinator initialized")

// Start initialization immediately for faster startup
Task { @MainActor in
self.checkSingletonStatus()
}
}

private func setupObservationChains() {
// Forward objectWillChange notifications from child objects to this coordinator
yabaiInterface.objectWillChange.sink { [weak self] in
self?.objectWillChange.send()
}.store(in: &cancellables)

stackDetector.objectWillChange.sink { [weak self] in
self?.objectWillChange.send()
}.store(in: &cancellables)

signalListener.objectWillChange.sink { [weak self] in
self?.objectWillChange.send()
}.store(in: &cancellables)

configManager.objectWillChange.sink { [weak self] in
self?.objectWillChange.send()
}.store(in: &cancellables)

indicatorManager.objectWillChange.sink { [weak self] in
self?.objectWillChange.send()
}.store(in: &cancellables)

signalManager.objectWillChange.sink { [weak self] in
// Combine all objectWillChange publishers and debounce once
let debounceInterval = 0.1 // 100ms

Publishers.MergeMany(
yabaiInterface.objectWillChange,
stackDetector.objectWillChange,
signalListener.objectWillChange,
configManager.objectWillChange,
indicatorManager.objectWillChange,
signalManager.objectWillChange
)
.debounce(for: .seconds(debounceInterval), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}.store(in: &cancellables)
}
.store(in: &cancellables)
}

// MARK: - Singleton Management
Expand Down Expand Up @@ -157,23 +153,18 @@ final class AppCoordinator: ObservableObject {
isAppInitialized = true
logger.info("Initializing Stackline app")

// Set initial activation policy based on whether we should show main window at launch
if configManager.config.behavior.showMainWindowAtLaunch {
// Show dock icon since main window will be visible
NSApplication.shared.setActivationPolicy(.regular)
} else {
// Start as accessory (no dock icon) - will show dock icon when main window opens
NSApplication.shared.setActivationPolicy(.accessory)
}
// Always start as menu bar only app
NSApplication.shared.setActivationPolicy(.accessory)

configManager.syncLaunchAgentStatus()

setupNotifications()
setupSignalHandlers()
setupWindowCloseHandler()

Task {
await signalManager.startSignalHandling()
logger.notice("Signal manager started successfully")
logger.notice("Socket-based signal manager started successfully")
}

DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
Expand All @@ -182,13 +173,16 @@ final class AppCoordinator: ObservableObject {
}

private func setupNotifications() {
NotificationCenter.default.addObserver(
let observer = NotificationCenter.default.addObserver(
forName: NSApplication.willTerminateNotification,
object: nil,
queue: .main
) { _ in
self.cleanup()
) { [weak self] _ in
Task { @MainActor in
self?.cleanup()
}
}
notificationObservers.append(observer)
}

private func setupSignalHandlers() {
Expand All @@ -209,24 +203,31 @@ final class AppCoordinator: ObservableObject {

private func setupStackDetection() {
logger.debug("Setting up stack detection")


// Combine stack updates to avoid duplicate processing
stackDetector.$detectedStacks
.removeDuplicates { oldStacks, newStacks in
// Only update if stacks actually changed
return oldStacks == newStacks
}
.receive(on: DispatchQueue.main)
.debounce(for: .milliseconds(10), scheduler: DispatchQueue.main)
.sink { [weak indicatorManager] newStacks in
indicatorManager?.updateIndicators(for: newStacks)
.sink { [weak self] newStacks in
self?.indicatorManager.updateIndicators(for: newStacks)
}
.store(in: &cancellables)

configManager.$config
.removeDuplicates()
.receive(on: DispatchQueue.main)
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
.sink { [weak indicatorManager] _ in
indicatorManager?.refreshAll()
.sink { [weak self] _ in
self?.indicatorManager.refreshAll()
}
.store(in: &cancellables)

NotificationCenter.default.addObserver(
// Listen for internal stack update notifications
let updateObserver = NotificationCenter.default.addObserver(
forName: Notification.Name("StacklineUpdate"),
object: nil,
queue: .main
Expand All @@ -235,16 +236,7 @@ final class AppCoordinator: ObservableObject {
stackDetector?.forceStackDetection()
}
}

NotificationCenter.default.addObserver(
forName: Notification.Name("StacklineExternalSignal"),
object: nil,
queue: .main
) { [weak signalListener] notification in
if let event = notification.object as? String {
signalListener?.handleExternalSignal(event)
}
}
notificationObservers.append(updateObserver)

signalListener.startListening()

Expand All @@ -259,105 +251,30 @@ final class AppCoordinator: ObservableObject {
logger.info("Stack detection setup completed")
}

func setupInitialWindowIfNeeded() {
// If we're showing the main window at launch, set up its close handler
if configManager.config.behavior.showMainWindowAtLaunch {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if let mainWindow = NSApplication.shared.windows.first(where: { window in
return window.level == .normal &&
window.styleMask.contains(.titled) &&
window.contentView != nil &&
(window.title == "Stackline" || window.title == "") &&
!window.title.contains("Configuration")
}) {
self.setupWindowCloseHandler(for: mainWindow)
logger.debug("Set up close handler for initial main window")
}
}
}
}
// No longer needed - main window is created on demand

// MARK: - Window Management

func openMainWindow() {
// Show dock icon when main window opens
NSApplication.shared.setActivationPolicy(.regular)

let mainWindow = NSApplication.shared.windows.first { window in
return window.level == .normal &&
window.styleMask.contains(.titled) &&
window.contentView != nil &&
(window.title == "Stackline" || window.title == "") &&
!window.title.contains("Configuration")
}

if let window = mainWindow {
setupWindowCloseHandler(for: window)
window.alphaValue = 1.0
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
logger.debug("Restored and shown main window")
} else {
let newId = "main-window-\(UUID().uuidString)"
mainWindowId = newId

NSApp.activate(ignoringOtherApps: true)

DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if let newWindow = NSApplication.shared.windows.first(where: {
$0.level == .normal &&
$0.styleMask.contains(.titled) &&
$0.contentView != nil &&
($0.title == "Stackline" || $0.title == "") &&
!$0.title.contains("Configuration")
}) {
self.setupWindowCloseHandler(for: newWindow)
newWindow.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
logger.debug("Created and activated new main window")
} else {
logger.warning("Failed to find newly created main window")
}
}
}
}

private func setupWindowCloseHandler(for window: NSWindow) {
// Set up notification for when window closes
NotificationCenter.default.addObserver(
func setupWindowCloseHandler() {
// Set up global handler for main window closing to hide dock icon
let observer = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: window,
object: nil,
queue: .main
) { _ in
// Hide dock icon when main window closes
NSApplication.shared.setActivationPolicy(.accessory)
logger.debug("Main window closed, hiding dock icon")
) { notification in
guard let window = notification.object as? NSWindow else { return }

// Check if this is our main window (not config window)
if window.title == "Stackline" && window.styleMask.contains(.titled) {
// Hide dock icon when main window closes
NSApplication.shared.setActivationPolicy(.accessory)
logger.debug("Main window closed, hiding dock icon")
}
}
notificationObservers.append(observer)
}

func openConfigurationWindow() {
configWindow?.close()
configWindow = nil

let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 450, height: 400),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered,
defer: false
)
window.title = "Stackline Configuration"
window.center()
window.isReleasedWhenClosed = true

window.contentView = NSHostingView(
rootView: ConfigurationView(configManager: configManager)
)

configWindow = window

window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
// Configuration window now managed by SwiftUI WindowGroup

func showAboutPanel() {
let aboutPanel = NSAlert()
Expand All @@ -375,21 +292,73 @@ final class AppCoordinator: ObservableObject {
}

// MARK: - Cleanup

private func cleanup() {
logger.info("Starting cleanup process")


// Stop stack detection
stackDetector.stopDetection()

// Clean up indicator manager
indicatorManager.cleanup()

// Stop signal listener
signalListener.stopListening()

// Stop signal manager
Task { @MainActor in
await signalManager.stopSignalHandling()
}

// Clean up all notification observers
for observer in notificationObservers {
NotificationCenter.default.removeObserver(observer)
}
notificationObservers.removeAll()

// Config window now managed by SwiftUI

// Cancel all Combine subscriptions
cancellables.removeAll()

// Clean up temporary files
let lockFilePath = "/tmp/stackline.lock"
let socketPath = "/tmp/stackline.sock"

do {
try FileManager.default.removeItem(atPath: lockFilePath)
logger.debug("Removed lock file")
} catch {
logger.debug("Could not remove lock file: \(error.localizedDescription)")
}


do {
try FileManager.default.removeItem(atPath: socketPath)
logger.debug("Removed socket file")
} catch {
logger.debug("Could not remove socket file: \(error.localizedDescription)")
}

logger.info("Cleaning up yabai signals on app termination...")
let yabaiInterface = YabaiInterface()
yabaiInterface.performSignalCleanup(timeout: 20.0)
logger.info("Cleanup process completed")
}
}

deinit {
// Clean up all notification observers
for observer in notificationObservers {
NotificationCenter.default.removeObserver(observer)
}


// Cancel all Combine subscriptions
cancellables.removeAll()

// Remove lock file
let lockFilePath = "/tmp/stackline.lock"
try? FileManager.default.removeItem(atPath: lockFilePath)

logger.debug("AppCoordinator deinitialized")
}
}
Loading