diff --git a/SwiftBar/AppDelegate.swift b/SwiftBar/AppDelegate.swift index 4432f22..04c946c 100644 --- a/SwiftBar/AppDelegate.swift +++ b/SwiftBar/AppDelegate.swift @@ -80,14 +80,30 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUStandardUserDriverDelegat var softwareUpdater: SPUUpdater! #endif + /// True when SwiftBar is hosting an XCTest / Swift Testing bundle. + /// Set by the test runner via the `XCTestConfigurationFilePath` env var. + static var isRunningTests: Bool { + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil || + NSClassFromString("XCTestCase") != nil + } + func applicationDidFinishLaunching(_: Notification) { + // Wire up the plugin manager so tests that reference + // `delegate.pluginManager` have a usable instance, then bail out before + // touching anything that keeps the runloop alive (Sparkle, the + // missing-folder modal, file-system observers). Without this the test + // host never reaches application termination and the test process hangs + // indefinitely after the last @Test finishes. + pluginManager = PluginManager.shared + if Self.isRunningTests { return } + preferencesWindowController.window?.delegate = self setupToolbar() - + // Clean up any corrupted NSStatusItem visibility states from UserDefaults // This fixes issues where menubar items disappear after initial setup cleanupStatusItemVisibility() - + let hostBundle = Bundle.main #if !MAC_APP_STORE let updateDriver = SPUStandardUserDriver(hostBundle: hostBundle, delegate: self) @@ -109,8 +125,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUStandardUserDriverDelegat prefs.pluginDirectoryPath = nil } - // Instance of Plugin Manager must be created after app launch - pluginManager = PluginManager.shared pluginManager.loadPlugins() pluginManager.persistLatestSystemReport(reason: "application-did-finish-launching") diff --git a/SwiftBar/Plugin/PackagedPlugin.swift b/SwiftBar/Plugin/PackagedPlugin.swift index 668368c..6a00a1b 100644 --- a/SwiftBar/Plugin/PackagedPlugin.swift +++ b/SwiftBar/Plugin/PackagedPlugin.swift @@ -108,10 +108,14 @@ class PackagedPlugin: TimerArmingPlugin { return nil } + // contentsOfDirectory(at:) does not follow a directory-typed URL that is + // a symlink (it returns ENOTDIR), so resolve the link before enumerating. + // The `.swiftbar` extension check above already used the unresolved URL. let fileManager = FileManager.default + let enumerationURL = directory.resolvingSymlinksInPath() let contents: [URL] do { - contents = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + contents = try fileManager.contentsOfDirectory(at: enumerationURL, includingPropertiesForKeys: nil) } catch { os_log("Failed to read packaged plugin directory %{public}@: %{public}@", log: Log.plugin, type: .error, directory.path, error.localizedDescription) diff --git a/SwiftBar/Plugin/PluginManger.swift b/SwiftBar/Plugin/PluginManger.swift index 8b4bd29..f19a71e 100644 --- a/SwiftBar/Plugin/PluginManger.swift +++ b/SwiftBar/Plugin/PluginManger.swift @@ -57,8 +57,12 @@ func pluginSyncPath(for plugin: Plugin) -> String { } private func packagedPluginFileState(for packageURL: URL, fileManager: FileManager = .default) -> PluginFileState? { + // `findMainExecutable` requires the `.swiftbar` extension, which only the + // unresolved URL is guaranteed to have when the package is reached through + // a symlink. The enumerator still needs the resolved path so we can read + // file attributes off the real directory. let resolvedPackageURL = packageURL.resolvingSymlinksInPath() - guard PackagedPlugin.findMainExecutable(in: resolvedPackageURL) != nil, + guard PackagedPlugin.findMainExecutable(in: packageURL) != nil, let enumerator = fileManager.enumerator(at: resolvedPackageURL, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else { return nil