Compare commits

..

5 Commits

Author SHA1 Message Date
tommaso 1867a4c0a6 convert bundled defaults into an installable configuration template 2026-07-13 16:44:50 +02:00
tommaso a894449d15 improve error messages 2026-07-13 16:43:52 +02:00
tommaso a49f78cbb0 make theme persistent across monitors 2026-07-13 16:43:05 +02:00
tommaso 1a2eddcd5d refactor CLI 2026-07-13 11:43:37 +02:00
tommaso 909d890837 improve cli documentation 2026-07-13 11:41:25 +02:00
21 changed files with 257 additions and 146 deletions
+1 -4
View File
@@ -34,10 +34,7 @@ let package = Package(
exclude: [ exclude: [
"Models/PHEvent/README.md" "Models/PHEvent/README.md"
], ],
resources: [ resources: [.copy("config")]
.process("Models/PHConfig/config.toml"),
.process("Models/PHTheme/theme.toml"),
]
), ),
.testTarget( .testTarget(
name: "phbarTests", name: "phbarTests",
-34
View File
@@ -1,34 +0,0 @@
import AppKit
import ArgumentParser
import Foundation
extension phbar {
struct start: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "start",
abstract: "Start the status bar."
)
@Flag(name: .long, help: "Display visual guides to help align elements.")
var debug: Bool = false
mutating func run() throws {
let config = try PHConfig.load()
// NOTE: Make sure NSApp.run() runs in the main thread
dispatchPrecondition(condition: .onQueue(.main))
try MainActor.assumeIsolated {
guard !NSScreen.screens.isEmpty else { throw CleanExit.message("No monitor found") }
let delegate = PHBarDelegate(config: config, debug: debug)
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
app.delegate = delegate
app.run()
throw ExitCode.success
}
}
}
}
@@ -0,0 +1,69 @@
import ArgumentParser
import Foundation
extension PHBar {
struct Install: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "install",
abstract: "Start the status bar.",
discussion: """
Launch the bar as a non-stopping background process.
This command is meant to be used for both testing and production.
When testing, you can provide the `--debug` flag to get visual
guidance that helps you configure the blocks and a verbose output from
the shell scripts to debug the behaviour.
"""
)
private var source: URL? {
Bundle.module.url(forResource: "config", withExtension: "")
}
private var destination: URL {
let environment = ProcessInfo.processInfo.environment
let homeDirectory = FileManager.default.homeDirectoryForCurrentUser
let standardConfigDirectory = homeDirectory.appending(path: ".config")
let xdgConfigDirectory = environment["XDG_CONFIG_HOME"]?.trimmingCharacters(
in: .whitespaces)
var candidateDirectory: URL
if let xdgConfigDirectory, xdgConfigDirectory.hasPrefix("/") {
candidateDirectory = URL(fileURLWithPath: xdgConfigDirectory)
} else if FileManager.default.fileExists(atPath: standardConfigDirectory.relativePath) {
candidateDirectory = standardConfigDirectory
} else {
candidateDirectory = homeDirectory
}
return candidateDirectory.appending(path: "phbar")
}
mutating func run() throws {
do {
guard let source else { throw PHBar.Error("Failed to locate bundled config") }
try FileManager.default.copyItem(at: source, to: destination)
try makeExecutable()
} catch {
throw PHBar.Error("Failed to create config directory", underlyingError: error)
}
throw CleanExit.message("Config directory created at \(destination.relativePath)")
}
private func makeExecutable() throws {
let scriptsDirectory = destination.appending(path: "scripts", directoryHint: .isDirectory)
let scripts = try FileManager.default.contentsOfDirectory(
atPath: scriptsDirectory.relativePath
)
for script in scripts {
let path = scriptsDirectory.appending(path: script)
var attributes: [FileAttributeKey: Any] = [:]
attributes[.posixPermissions] = 0o755 // rwxr-xr-x
try FileManager.default.setAttributes(attributes, ofItemAtPath: path.relativePath)
}
}
}
}
@@ -1,7 +1,7 @@
import ArgumentParser import ArgumentParser
extension phbar { extension PHBar {
struct refresh: ParsableCommand { struct Refresh: ParsableCommand {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
commandName: "refresh", commandName: "refresh",
abstract: "Refresh the running status bar." abstract: "Refresh the running status bar."
+44
View File
@@ -0,0 +1,44 @@
import AppKit
import ArgumentParser
import Foundation
extension PHBar {
struct Start: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "start",
abstract: "Start the status bar.",
discussion: """
Launch the bar as a non-stopping background process.
This command is meant to be used for both testing and production.
When testing, you can provide the `--debug` flag to get visual
guidance that helps you configure the blocks and a verbose output from
the shell scripts to debug the behaviour.
"""
)
@Flag(name: .long, help: "Display visual guides and print verbose output.")
var debug: Bool = false
mutating func run() throws {
let config = try PHConfig.load()
let environment = PHEnvironment.process.merging(config.env)
let theme = try PHTheme.load(config.theme, environment: environment)
// NOTE: Make sure NSApp.run() runs in the main thread
dispatchPrecondition(condition: .onQueue(.main))
try MainActor.assumeIsolated {
guard !NSScreen.screens.isEmpty else { throw CleanExit.message("No monitor found") }
let delegate = PHBarDelegate(config: config, theme: theme, debug: debug)
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
app.delegate = delegate
app.run()
throw ExitCode.success
}
}
}
}
+7 -2
View File
@@ -19,9 +19,14 @@ final class PHBarDelegate: NSObject, NSApplicationDelegate {
/// `config` is captured here (not re-read per screen) so all bars share one /// `config` is captured here (not re-read per screen) so all bars share one
/// resolved config + environment for their lifetime. Live reload swaps the /// resolved config + environment for their lifetime. Live reload swaps the
/// whole factory via `reloadAll()`. /// whole factory via `reloadAll()`.
init(config: PHConfig, debug: Bool) { init(config: PHConfig, theme: PHTheme, debug: Bool) {
let environment = PHEnvironment.process.merging(config.env) let environment = PHEnvironment.process.merging(config.env)
self.factory = PHBarFactory(config: config, environment: environment, debug: debug) self.factory = PHBarFactory(
config: config,
theme: theme,
environment: environment,
debug: debug
)
super.init() super.init()
} }
@@ -10,7 +10,7 @@ extension PHBarDelegate {
do { do {
controller = try factory.make(for: screen) controller = try factory.make(for: screen)
} catch { } catch {
stderr("skipping screen \(screen.localizedName): \(error)") stderr("skipping screen \(screen.localizedName): \(error.localizedDescription)")
return false return false
} }
let window = BarWindow(controller: controller) let window = BarWindow(controller: controller)
@@ -1,19 +1,23 @@
extension PHBarDelegate { extension PHBarDelegate {
// MARK: - Reload (entry points for a future `phbar reload`) // MARK: - Reload (entry points for a future `phbar reload`)
/// Re-read config from disk and rebuild every bar against it. Screens are /// Re-read config and theme from disk and rebuild every bar against them.
/// kept; only theme/blocks/window are re-resolved. This is the global path /// Screens are kept; only theme/blocks/window are re-resolved.
/// for picking up config edits at runtime. /// This is the global path for picking up config edits at runtime.
func reloadAll() { func reloadAll() {
let config: PHConfig let config: PHConfig
let theme: PHTheme
do { do {
config = try PHConfig.load() config = try PHConfig.load()
let environment = PHEnvironment.process.merging(config.env)
theme = try PHTheme.load(config.theme, environment: environment)
} catch { } catch {
stderr("reload failed, could not read config: \(error)") stderr("reload failed, could not read config: \(error)")
return return
} }
factory = PHBarFactory( factory = PHBarFactory(
config: config, config: config,
theme: theme,
environment: PHEnvironment.process.merging(config.env), environment: PHEnvironment.process.merging(config.env),
debug: factory.debug debug: factory.debug
) )
+4 -13
View File
@@ -1,31 +1,22 @@
import AppKit import AppKit
/// Builds a `BarController` for a screen by resolving its theme, blocks, and /// Builds a `BarController` for a screen by resolving its blocks and
/// window from the config. /// window from the config.
///
/// Extracted from `AppDelegate` so the exact same resolution drives initial
/// launch, hot-plugged monitors (`syncScreens`), and live reload
/// (`reloadAll` / `reload(screenNamed:)`). Each screen's theme/blocks are
/// resolved independently, so a failure on one screen can be skipped without
/// taking down the others.
@MainActor @MainActor
struct PHBarFactory { struct PHBarFactory {
let config: PHConfig let config: PHConfig
let theme: PHTheme
let environment: PHEnvironment let environment: PHEnvironment
let debug: Bool let debug: Bool
/// Resolve theme + blocks for `screen` and build its controller. /// Resolve blocks for `screen` and build its controller.
/// ///
/// - Parameter screen: The screen to build a bar for. Its localized name /// - Parameter screen: The screen to build a bar for. Its localized name
/// and index in `NSScreen.screens` select any per-monitor config override. /// and index in `NSScreen.screens` select any per-monitor config override.
/// - Throws: `PHTheme.load` / `PHBlock.load` errors for the resolved names. /// - Throws: `PHBlock.load` error for the resolved names.
func make(for screen: NSScreen) throws -> BarController { func make(for screen: NSScreen) throws -> BarController {
let name = screen.localizedName let name = screen.localizedName
let index = NSScreen.screens.firstIndex(of: screen) let index = NSScreen.screens.firstIndex(of: screen)
let theme = try PHTheme.load(
config.theme(screenName: name, screenIndex: index),
environment: environment
)
let blocks = try PHBlock.load(config.blocks(screenName: name, screenIndex: index)) let blocks = try PHBlock.load(config.blocks(screenName: name, screenIndex: index))
return BarController( return BarController(
config: config, config: config,
@@ -19,7 +19,7 @@ extension PHBlock {
let setFile = base.appending(path: "blocks").appending(path: "\(blocks).toml") let setFile = base.appending(path: "blocks").appending(path: "\(blocks).toml")
guard FileManager.default.fileExists(atPath: setFile.relativePath) else { guard FileManager.default.fileExists(atPath: setFile.relativePath) else {
throw phbar.Error("Failed to load blocks '\(blocks)'") throw PHBar.Error("Failed to load blocks '\(blocks)'")
} }
return try load(from: setFile) return try load(from: setFile)
} }
@@ -30,11 +30,11 @@ extension PHBlock {
do { do {
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
guard let contents = String(data: data, encoding: .utf8), !contents.isEmpty else { guard let contents = String(data: data, encoding: .utf8), !contents.isEmpty else {
throw phbar.Error("the file is empty") throw PHBar.Error("the file is empty")
} }
return try load(from: contents) return try load(from: contents)
} catch { } catch {
throw phbar.Error("Failed to load blocks file", underlyingError: error) throw PHBar.Error("Failed to load blocks file", underlyingError: error)
} }
} }
@@ -46,7 +46,7 @@ extension PHBlock {
let wrapper = try decoder.decode(PHBlock.Wrapper.self, from: contents) let wrapper = try decoder.decode(PHBlock.Wrapper.self, from: contents)
return wrapper.blocks return wrapper.blocks
} catch { } catch {
throw phbar.Error("Failed to parse blocks file", underlyingError: error) throw PHBar.Error("Failed to parse blocks file", underlyingError: error)
} }
} }
} }
@@ -3,39 +3,34 @@ import TOML
extension PHConfig { extension PHConfig {
/// Load configuration from the resolved config directory /// Load configuration from the resolved config directory
/// (see `PHPaths`). If the file doesn't exist or fails to parse, the /// (see `PHPaths`). If the file doesn't exist or fails to parse,
/// bundled default is used. /// the execution is interrupted.
static func load() throws -> PHConfig { static func load() throws -> PHConfig {
let url = PHPaths.configDirectory.appending(path: "config.toml") let url = PHPaths.configDirectory.appending(path: "config.toml")
if FileManager.default.fileExists(atPath: url.relativePath) { if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url) return try load(from: url)
} else { } else {
return try loadFromBundle() throw PHBar.Error(
} """
} Configuration file not found.
Make sure to have a file named `config.toml` inside the
directory: `\(url.deletingLastPathComponent().relativePath)`
/// Load the bundled configuration. Tip: If you've never used phbar before, run the `phbar install` command
/// If the file doesn't exist or fails to parse, the execution is interrupted. to automatically generate the required configuration files.
static private func loadFromBundle() throws -> PHConfig { """
guard )
let defaultConfig = Bundle.module.url(
forResource: "config",
withExtension: "toml"
)?.resolvingSymlinksInPath()
else {
throw phbar.Error("Failed to load config file")
} }
return try load(from: defaultConfig)
} }
/// Decode configuration from a file at URL. /// Decode configuration from a file at URL.
/// If the file doesn't exist or fails to parse, the execution is interrupted. /// If the file doesn't exist or fails to parse, the execution is interrupted.
static private func load(from url: URL) throws -> PHConfig { static private func load(from url: URL) throws -> PHConfig {
guard let data = try? Data(contentsOf: url), guard let data = try? Data(contentsOf: url),
let contents = String(data: data, encoding: .utf8) let contents = String(data: data, encoding: .utf8), !contents.isEmpty
else { else {
throw phbar.Error("Failed to load config file") throw PHBar.Error("Configuration file not readable or empty.")
} }
return try load(from: contents) return try load(from: contents)
@@ -53,7 +48,7 @@ extension PHConfig {
// resolve the same set. // resolve the same set.
return config.resolvingEnv() return config.resolvingEnv()
} catch { } catch {
throw phbar.Error("Failed to parse config file", underlyingError: error) throw PHBar.Error("Configuration file not valid", underlyingError: error)
} }
} }
} }
@@ -5,12 +5,10 @@ import Foundation
/// Either field is optional; an unset field inherits the matching global /// Either field is optional; an unset field inherits the matching global
/// (`theme`, `window`, or `blocks`) so a monitor can override just one of them. /// (`theme`, `window`, or `blocks`) so a monitor can override just one of them.
struct PHConfigMonitorOverride: Decodable { struct PHConfigMonitorOverride: Decodable {
let theme: String?
let window: String? let window: String?
let blocks: String? let blocks: String?
init(theme: String? = nil, window: String? = nil, blocks: String? = nil) { init(window: String? = nil, blocks: String? = nil) {
self.theme = theme
self.window = window self.window = window
self.blocks = blocks self.blocks = blocks
} }
@@ -30,12 +28,6 @@ extension PHConfig {
return nil return nil
} }
/// Theme name for a screen: the override's `theme` if set, otherwise the
/// global `theme`.
func theme(screenName: String, screenIndex: Int?) -> String {
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.theme ?? theme
}
/// Window definition name for a screen: the override's `window` if set, /// Window definition name for a screen: the override's `window` if set,
/// otherwise the global `window`. /// otherwise the global `window`.
func window(screenName: String, screenIndex: Int?) -> String { func window(screenName: String, screenIndex: Int?) -> String {
@@ -3,48 +3,39 @@ import TOML
extension PHTheme { extension PHTheme {
/// Load theme from the resolved config directory (`PHPaths`/themes). /// Load theme from the resolved config directory (`PHPaths`/themes).
/// Falls back to the bundled theme when the file is absent or parsing fails. /// If the file doesn't exist or fails to parse, the execution is interrupted.
/// ///
/// - Parameter environment: Used to expand `$VAR` references in `@EnvExpanded` /// - Parameter environment: Used to expand `$VAR` references in `@EnvExpanded`
/// fields (colors, fonts); defaults to the process environment. /// fields (colors, fonts); defaults to the process environment.
static func load(_ theme: String?, environment: PHEnvironment = .process) throws -> PHTheme { static func load(_ theme: String, environment: PHEnvironment = .process) throws -> PHTheme {
guard let theme else { return try loadFromBundle() }
let url = PHPaths.configDirectory.appending(path: "themes").appending(path: "\(theme).toml") let url = PHPaths.configDirectory.appending(path: "themes").appending(path: "\(theme).toml")
if FileManager.default.fileExists(atPath: url.relativePath) { if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url, environment: environment) return try load(from: url, environment: environment)
} else { } else {
return try loadFromBundle() throw PHBar.Error(
} """
} Theme file not found.
Make sure to have a file named `\(theme).toml` inside the
directory: `\(url.deletingLastPathComponent().relativePath)`
/// Load the bundled theme. Tip: If you've never used phbar before, run the `phbar install` command
/// If the file doesn't exist or fails to parse, the execution is interrupted. to automatically generate the required configuration files.
static private func loadFromBundle() throws -> PHTheme { """
guard )
let defaultConfig = Bundle.module.url(
forResource: "theme",
withExtension: "toml"
)?.resolvingSymlinksInPath()
else {
throw phbar.Error("Failed to load theme file")
} }
return try load(from: defaultConfig, environment: .process)
} }
/// Load theme from a file at URL. /// Load theme from a file at URL.
/// If the file doesn't exist or fails to parse, the execution is interrupted. /// If the file doesn't exist or fails to parse, the execution is interrupted.
static private func load(from url: URL, environment: PHEnvironment) throws -> PHTheme { static private func load(from url: URL, environment: PHEnvironment) throws -> PHTheme {
do { guard let data = try? Data(contentsOf: url),
let data = try Data(contentsOf: url) let contents = String(data: data, encoding: .utf8), !contents.isEmpty
guard let contents = String(data: data, encoding: .utf8), !contents.isEmpty else { else {
throw phbar.Error("the file is empty") throw PHBar.Error("Theme file not readable or empty.")
}
return try load(from: contents, environment: environment)
} catch {
throw phbar.Error("Failed to load theme file", underlyingError: error)
} }
return try load(from: contents, environment: environment)
} }
/// Load theme from a TOML string. /// Load theme from a TOML string.
@@ -55,7 +46,7 @@ extension PHTheme {
decoder.userInfo[PHEnvironment.userInfoKey] = environment decoder.userInfo[PHEnvironment.userInfoKey] = environment
return try decoder.decode(PHTheme.self, from: contents) return try decoder.decode(PHTheme.self, from: contents)
} catch { } catch {
throw phbar.Error("Failed to parse theme file", underlyingError: error) throw PHBar.Error("Theme file not valid", underlyingError: error)
} }
} }
} }
@@ -8,21 +8,42 @@ import ArgumentParser
import Foundation import Foundation
@main @main
struct phbar: ParsableCommand { struct PHBar: ParsableCommand {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
commandName: "phbar", commandName: "phbar",
abstract: "Modular status bar for macOS.", abstract: "Modular status bar for macOS.",
discussion: """ discussion: """
pmenu reads a list of newline-separated items from stdin and presents them to the user. phbar renders a status bar on every visible monitor. Each status bar
When the user selects an item and presses Return, their choice is printed to stdout and pmenu terminates. is dynamically built from the given theme, window configuration,
Entering text will narrow the items to those matching the tokens in the input. and block set. Thanks to the modularity of phbar, each one of these
elements can be overwritten for any given monitor.
phbar relies on blocks to determine what is visible on a bar and how
it should look. Everything in phbar is a block, not just texts but
also spaces and dividers. Each block can have a different visual style,
refresh conditions, and content source. The content of a block, if any,
is computed by calling a shell script, giving you the maximum flexibility.
Run `phbar install` to create the required config directory filled with
a template to start with. The directory will be created in one of these
locations in order of priority: $XDG_CONFIG_HOME/phbar, ~/.config/phbar,
or ~/.phbar.
Edit `<config-directory>/blocks/default.toml` to customize the block set used
by default and `<config-directory>/themes/default.toml` to customize the look
and feel of the bar. Check out the full documentation to learn more about all
the possible customisations.
Run `phbar start` to launch the bar and verify the appearance and behaviour.
Once you're ready to go to production, you can create a launch agent that uses
the same command to start the bar automatically at login.
""", """,
version: "1.0.0", version: "1.0.0",
subcommands: [start.self] subcommands: [Start.self, Install.self]
) )
} }
extension phbar { extension PHBar {
struct Error: LocalizedError { struct Error: LocalizedError {
let message: String let message: String
let underlyingError: Swift.Error? let underlyingError: Swift.Error?
+31
View File
@@ -0,0 +1,31 @@
[[block]]
name = "version"
style = "elevated"
[[block]]
name = "_space 14"
[[block]]
name = "attribution"
command = 'echo "by Panini House"'
text = "italic"
[[block]]
name = "_space"
[[block]]
name = "gesture"
[[block]]
name = "_space 14"
[[block]]
name = "clock"
refresh = 1
style = "elevated"
[[block]]
name = "greetings"
command = 'echo "􀟱 Hi $USER", welcome to phbar!'
text = "italic"
centered = true
@@ -6,19 +6,17 @@ theme = "default"
window = "default" window = "default"
blocks = "default" blocks = "default"
# Optional: override the theme, window, and/or block set per monitor. # Optional: override the window and/or block set per monitor.
# - quoted numeric key → NSScreen index # - quoted numeric key → NSScreen index
# - string key → NSScreen.localizedName (stable across replugs) # - string key → NSScreen.localizedName (stable across replugs)
# Precedence: monitor name → monitor index → the globals above. # Precedence: monitor name → monitor index → the globals above.
# Either field may be omitted to inherit its global value. # Either field may be omitted to inherit its global value.
# #
# [monitor."1"] # [monitor."1"]
# theme = "default"
# window = "bottom" # window = "bottom"
# blocks = "laptop" # blocks = "laptop"
# #
# [monitor."DELL U2723QE"] # [monitor."DELL U2723QE"]
# theme = "external"
# window = "clock" # window = "clock"
# blocks = "external" # blocks = "external"
@@ -32,6 +30,5 @@ blocks = "default"
# theme (e.g. `ACCENT = "#ff0000"`, then `color = "$ACCENT"` in the theme) # theme (e.g. `ACCENT = "#ff0000"`, then `color = "$ACCENT"` in the theme)
# #
# [env] # [env]
# TEST = "ciao"
# TEST_2 = "hello world"
# PATH = "$PATH:/opt/homebrew/bin" # PATH = "$PATH:/opt/homebrew/bin"
# MY_CUSTOM_VAR = "hello world"
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
echo "􀐬 $(date "+%a %d, %H:%M:%S")"
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
text="Click me"
case $GESTURE in
"left_mouse_down") text="That's left mouse button!" ;;
"right_mouse_down") text="That's right mouse button!" ;;
"other_mouse_down") text="That's mouse button $GESTURE_INFO!" ;;
"scroll_wheel") text="Yes, you can even scroll!" ;;
esac
echo "􀭆 $text"
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
echo "phbar v$(phbar --version)"
@@ -8,39 +8,29 @@ anchor = "top"
height = 30 height = 30
width = "100%" width = "100%"
[[window]]
name = "bottom"
anchor = "bottom"
height = 30
width = "100%"
[[text]] [[text]]
name = "default" name = "default"
font = "Comic Code" font = "monospace"
size = 14 size = 14
weight = "regular" weight = "regular"
style = "normal" style = "normal"
offset = -0.5 offset = -1
[[text]] [[text]]
name = "italic" name = "italic"
font = "Comic Code" font = "monospace"
size = 14 size = 14
weight = "regular" weight = "regular"
style = "italic" style = "italic"
offset = -0.5 offset = -1
[[style]] [[style]]
name = "default" name = "default"
foreground = { color = "#aed3f3" } foreground = { color = "#aed3f3" }
background = { color = "#010408", alpha = 0.825 } background = { color = "#010408" }
[[style]] [[style]]
name = "floating" name = "elevated"
foreground = { color = "#aed3f3" }
[[style]]
name = "tinted"
foreground = { color = "#aed3f3" } foreground = { color = "#aed3f3" }
background = { color = "#0f304a" } background = { color = "#0f304a" }
padding = { left = 12.0, right = 12.0 } padding = { left = 12.0, right = 12.0 }
+1 -1
View File
@@ -7,7 +7,7 @@ import Testing
// MARK: - Refresh command // MARK: - Refresh command
@Test func refreshCommandConfigurationIsCorrect() async throws { @Test func refreshCommandConfigurationIsCorrect() async throws {
let config = phbar.refresh.configuration let config = PHBar.Refresh.configuration
#expect(config.commandName == "refresh") #expect(config.commandName == "refresh")
#expect(config.abstract == "Refresh the running status bar.") #expect(config.abstract == "Refresh the running status bar.")
} }