Skip to content

Commit

Permalink
CLI client prototype
Browse files Browse the repository at this point in the history
  • Loading branch information
nikitabobko committed Nov 12, 2023
1 parent 0911904 commit 4a96402
Show file tree
Hide file tree
Showing 21 changed files with 297 additions and 21 deletions.
63 changes: 63 additions & 0 deletions AeroSpace.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
{
"pins" : [
{
"identity" : "bluesocket",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Kitura/BlueSocket",
"state" : {
"revision" : "7b23a867008e0027bfd6f4d398d44720707bc8ca",
"version" : "2.0.4"
}
},
{
"identity" : "hotkey",
"kind" : "remoteSourceControl",
Expand Down
4 changes: 2 additions & 2 deletions build-debug.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ set -o pipefail # Any command failed in the pipe fails the whole pipe

cd "$(dirname "$0")"

xcodegen # https://github.com/yonaskolb/XcodeGen
./generate.sh
xcodebuild -scheme AeroSpace build -configuration Debug # no clean because it may lead to accessibility permission loss
xcodebuild -scheme AeroSpace-cli build -configuration Debug # no clean because it may lead to accessibility permission loss

Expand All @@ -19,4 +19,4 @@ pushd ~/Library/Developer/Xcode/DerivedData > /dev/null
fi
popd > /dev/null
cp -r ~/Library/Developer/Xcode/DerivedData/AeroSpace*/Build/Products/Debug/AeroSpace-Debug.app .build
cp -r ~/Library/Developer/Xcode/DerivedData/AeroSpace*/Build/Products/Debug/AeroSpace-cli .build/aerospace
cp -r ~/Library/Developer/Xcode/DerivedData/AeroSpace*/Build/Products/Debug/AeroSpace-cli .build/aerospace-debug
5 changes: 3 additions & 2 deletions build-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,20 @@ checkCleanGitWorkingDir() {
}

generateGitHash() {
cat > src/gitHashGenerated.swift <<-EOF
tee src/gitHashGenerated.swift cli/gitHashGenerated.swift > /dev/null <<EOF
public let gitHash = "$(git rev-parse HEAD)"
public let gitShortHash = "$(git rev-parse --short HEAD)"
EOF
}

xcodegen # https://github.com/yonaskolb/XcodeGen
./generate.sh
checkCleanGitWorkingDir
generateGitHash
xcodebuild -scheme AeroSpace build -configuration Release
xcodebuild -scheme AeroSpace-cli build -configuration Release

git checkout src/gitHashGenerated.swift
git checkout cli/gitHashGenerated.swift

rm -rf .build && mkdir .build
pushd ~/Library/Developer/Xcode/DerivedData > /dev/null
Expand Down
2 changes: 1 addition & 1 deletion clean-project.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ set -o pipefail # Any command failed in the pipe fails the whole pipe

cd "$(dirname "$0")"
rm -rf AeroSpace.xcodeproj
xcodegen # https://github.com/yonaskolb/XcodeGen
./generate.sh
xcodebuild clean
rm -rf ~/Library/Developer/Xcode/DerivedData/AeroSpace-*
19 changes: 19 additions & 0 deletions cli/cliUtil.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation
import Darwin

#if DEBUG
let appId: String = "bobko.debug.aerospace"
#else
let appId: String = "bobko.aerospace"
#endif

public func error(_ message: String = "") -> Never {
errorT(message)
}

public func errorT<T>(_ message: String = "") -> T {
print(message)
exit(1)
}

let cliClientVersionAndHash: String = "\(cliClientVersion) \(gitHash)"
3 changes: 3 additions & 0 deletions cli/gitHashGenerated.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// BEWARE! This file is auto-updated by build-release.sh
public let gitHash = "DEBUG"
public let gitShortHash = "DEBUG"
56 changes: 55 additions & 1 deletion cli/main.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,57 @@
import Socket
import Foundation

print("Hello, World!")
let command: [String] = Array(CommandLine.arguments.dropFirst())

for word in command {
if word.contains(" ") {
error("Spaces in arguments are not permitted. '\(word)' argument contains spaces.")
}
}

let usage =
"""
USAGE: \(CommandLine.arguments.first ?? "aerospace") COMMAND
See https://github.com/nikitabobko/AeroSpace/blob/main/docs/commands.md for the list of all available commands
"""
if command.first == "--help" || command.first == "-h" {
print(usage)
} else {
let socket = try! Socket.create(family: .unix, type: .stream, proto: .unix)
defer {
socket.close()
}
let socketFile = "/tmp/\(appId).sock"
(try? socket.connect(to: socketFile)) ??
errorT("Can't connect to AeroSpace server. Is AeroSpace.app running?")

func run(_ command: String) -> String {
try! socket.write(from: command)
_ = try! Socket.wait(for: [socket], timeout: 0, waitForever: true)
return try! socket.readString() ?? errorT("fatal error: received nil from socket")
}

let serverVersionAndHash = run("version")
if serverVersionAndHash != cliClientVersionAndHash {
error(
"""
Corrupted AeroSpace installation
- CLI client version: \(cliClientVersionAndHash)
- AeroSpace.app server version: \(serverVersionAndHash)
The versions don't match. Please reinstall AeroSpace
"""
)
}

if command.isEmpty {
error(usage)
} else {
let output = run(command.joined(separator: " "))
if output != "PASS" {
print(output)
}
}
}
2 changes: 2 additions & 0 deletions cli/versionGenerated.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// FILE IS GENERATED BY generate.sh
let cliClientVersion = "0.4.0-Beta"
20 changes: 20 additions & 0 deletions docs/cli-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# CLI commands

In addition to [regular commands](./commands.md), the CLI provides commands listed in this file

**Table of contents**
- [version](#version)

## version

```
version
--version
-v
```

- Available since: 0.4.0-Beta

Prints the version and commit hash to stdout

This command doesn't have any arguments
2 changes: 2 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Commands

Commands listed in this file can be used in the config and CLI

**Table of contents**
- [close-all-windows-but-current](#close-all-windows-but-current)
- [exec-and-forget](#exec-and-forget)
Expand Down
20 changes: 20 additions & 0 deletions generate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -e # Exit if one of commands exit with non-zero exit code
set -u # Treat unset variables and parameters other than the special parameters ‘@’ or ‘*’ as an error
set -o pipefail # Any command failed in the pipe fails the whole pipe
# set -x # Print shell commands as they are executed (or you can try -v which is less verbose)

cd "$(dirname "$0")"

version=$(head -1 ./version.txt) # Build number CFBundleVersion
build_number=$(tail -1 ./version.txt) # User visible version CFBundleShortVersionString

cat > cli/versionGenerated.swift <<EOF
// FILE IS GENERATED BY generate.sh
let cliClientVersion = "$version"
EOF

sed -i "s/CURRENT_PROJECT_VERSION.*/CURRENT_PROJECT_VERSION: $build_number # GENERATED BY generate.sh/" project.yml
sed -i "s/MARKETING_VERSION.*/MARKETING_VERSION: $version # GENERATED BY generate.sh/" project.yml

xcodegen # https://github.com/yonaskolb/XcodeGen
12 changes: 10 additions & 2 deletions project.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
# Project configuration. Managed by https://github.com/yonaskolb/XcodeGen

name: AeroSpace

packages:
HotKey:
url: https://github.com/soffes/HotKey
exactVersion: 0.1.3
TOMLKit:
url: https://github.com/LebJe/TOMLKit
exactVersion: 0.5.5
Socket:
url: https://github.com/Kitura/BlueSocket
exactVersion: 2.0.4

targets:
AeroSpace:
type: application
Expand All @@ -17,13 +22,14 @@ targets:
dependencies:
- package: HotKey
- package: TOMLKit
- package: Socket
settings:
base:
SWIFT_VERSION: 5.8
CODE_SIGN_STYLE: Automatic
GENERATE_INFOPLIST_FILE: YES
CURRENT_PROJECT_VERSION: 0.4.0 # Build number CFBundleVersion
MARKETING_VERSION: 0.4.0-Beta # User visible version CFBundleShortVersionString
MARKETING_VERSION: 0.4.0-Beta # GENERATED BY generate.sh
CURRENT_PROJECT_VERSION: 0.4.0 # GENERATED BY generate.sh
SWIFT_OBJC_BRIDGING_HEADER: "src/Bridged-Header.h"
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/LaunchServicesKeys.html#//apple_ref/doc/uid/20001431-108256
# Specifies whether the app runs as an agent app. If this key is set to YES, Launch Services runs the app as an agent app.
Expand Down Expand Up @@ -59,6 +65,8 @@ targets:
type: tool
platform: macOS
sources: [cli/]
dependencies:
- package: Socket
settings:
base:
CODE_SIGN_STYLE: Automatic
Expand Down
1 change: 1 addition & 0 deletions src/AeroSpaceApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct AeroSpaceApp: App {
}

checkAccessibilityPermissions()
startServer()
GlobalObserver.initObserver()
refresh(startup: true)
Task {
Expand Down
7 changes: 6 additions & 1 deletion src/command/Command.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ protocol Command {
func runWithoutLayout() async
}

protocol QueryCommand {
@MainActor
func run() -> String
}

extension Command {
@MainActor
func run() async {
func run() async {
refresh(layout: false)
await runWithoutLayout()
refresh()
Expand Down
7 changes: 7 additions & 0 deletions src/command/cli/VersionCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
struct VersionCommand: QueryCommand {
@MainActor
func run() -> String {
check(Thread.current.isMainThread)
return "\(Bundle.appVersion) \(gitHash)"
}
}
24 changes: 19 additions & 5 deletions src/command/parseCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ import TOMLKit
typealias ParsedCommand<T> = Result<T, String>
extension String: Error {}

func parseQueryCommand(_ raw: String) -> ParsedCommand<QueryCommand> {
if raw.contains("'") {
return .failure("Single quotation mark is reserved for future use")
} else if raw == "version" || raw == "--version" || raw == "-v" {
return .success(VersionCommand())
} else if raw == "" {
return .failure("Can't parse empty string query command")
} else {
return .failure("Unrecognized query command '\(raw)'")
}
}

func parseCommand(_ raw: TOMLValueConvertible) -> ParsedCommand<Command> {
if let rawString = raw.string {
return parseSingleCommand(rawString)
Expand All @@ -18,10 +30,12 @@ func parseCommand(_ raw: TOMLValueConvertible) -> ParsedCommand<Command> {
}

func parseSingleCommand(_ raw: String) -> ParsedCommand<Command> {
let words = raw.split(separator: " ")
let args = words[1...].map { String($0) }
let words: [String] = raw.split(separator: " ").map { String($0) }
let args: [String] = Array(words[1...])
let firstWord = String(words.first ?? "")
if firstWord == "workspace" {
if raw.contains("'") {
return .failure("Single quotation mark is reserved for future use")
} else if firstWord == "workspace" {
return parseSingleArg(args, firstWord).map { WorkspaceCommand(workspaceName: $0) }
} else if firstWord == "move-node-to-workspace" {
return parseSingleArg(args, firstWord).map { MoveNodeToWorkspaceCommand(targetWorkspaceName: $0) }
Expand Down Expand Up @@ -71,9 +85,9 @@ func parseSingleCommand(_ raw: String) -> ParsedCommand<Command> {
} else if raw == "close-all-windows-but-current" {
return .success(CloseAllWindowsButCurrentCommand())
} else if raw == "" {
return .failure("Can't parse empty string command")
return .failure("Can't parse empty string action command")
} else {
return .failure("Unrecognized command '\(raw)'")
return .failure("Unrecognized action command '\(raw)'")
}
}

Expand Down
5 changes: 1 addition & 4 deletions src/config/parseConfig.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import TOMLKit
import HotKey

private let isRelease = !Bundle.appId.contains("debug")

func reloadConfig() {
let configUrl = FileManager.default.homeDirectoryForCurrentUser
.appending(path: isRelease ? ".aerospace.toml" : ".aerospace.debug.toml")
let rawConfig = try? String(contentsOf: configUrl)
let (parsedConfig, errors) = rawConfig?.lets { parseConfig($0) } ?? (defaultConfig, [])
let (parsedConfig, errors) = parseConfig((try? String(contentsOf: configUrl)) ?? "")

if !errors.isEmpty {
activateMode(mainModeId)
Expand Down
45 changes: 45 additions & 0 deletions src/server.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Socket

func startServer() {
let socket = (try? Socket.create(family: .unix, type: .stream, proto: .unix)) ?? errorT("Can't create socket")
let socketFile = "/tmp/\(Bundle.appId).sock"
(try? socket.listen(on: socketFile, maxBacklogSize: 1)) ?? errorT("Can't listen to socket \(socketFile)")
DispatchQueue.global().async {
while true {
guard let connection = try? socket.acceptClientConnection() else { continue }
Task { await newConnection(connection) }
}
}
}

private func newConnection(_ socket: Socket) async {
defer {
debug("Close connection")
socket.close()
}
while true {
_ = try? Socket.wait(for: [socket], timeout: 0, waitForever: true)
guard let string = (try? socket.readString()) else { return }
let (action, error1) = parseSingleCommand(string).getOrNils()
let (query, error2) = parseQueryCommand(string).getOrNils()
if let error1, let error2 {
_ = try? socket.write(from: error1 + "\n" + error2)
continue
}
if action is ExecAndForgetCommand || action is ExecAndWaitCommand {
_ = try? socket.write(from: "exec commands are prohibited from CLI")
continue
}
if let action {
await action.run()
_ = try? socket.write(from: "PASS")
continue
}
if let query {
let result = await query.run()
_ = try? socket.write(from: result)
continue
}
error("Unreachable")
}
}
Loading

0 comments on commit 4a96402

Please sign in to comment.