Skip to content

Commit 74031aa

Browse files
committed
2.3.0_Add CLI/TUI support
Introduce a command-line interface and TUI for LaunchNext and add automatic shim installation/cleanup. - Add LaunchNextCLI.swift: implements --cli and --tui modes, command parsing, a TUI line editor, command history, and CLI commands (list, snapshot, search, create-folder, move, history, examples, help). - Add LaunchNextCLIIPC.swift: implements a simple UNIX-socket IPC client for communicating CLI requests with the running GUI (JSON request/response over cli.sock in Application Support). - Update AppStore to expose a development setting to enable the CLI, persist the flag, and install/uninstall a small zsh shim at common bin targets (e.g. /opt/homebrew/bin, /usr/local/bin, ~/.local/bin, ~/bin). The shim forwards args to the app executable; AppStore also manages a .zprofile PATH snippet. - Update project build version and marketing version to 2.3.0. - Update README and localized README files to include CLI-related documentation. The new CLI is gated behind a settings flag and attempts to be conservative when replacing existing files (only replaces managed shims). IPC client returns structured success/failure messages for tooling use.
1 parent 17c7f77 commit 74031aa

19 files changed

+2964
-59
lines changed

LaunchNext.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@
347347
CLANG_ENABLE_MODULES = YES;
348348
CODE_SIGN_STYLE = Automatic;
349349
COMBINE_HIDPI_IMAGES = YES;
350-
CURRENT_PROJECT_VERSION = 20250908;
350+
CURRENT_PROJECT_VERSION = 20260224;
351351
DEAD_CODE_STRIPPING = YES;
352352
DEVELOPMENT_TEAM = "";
353353
ENABLE_APP_SANDBOX = NO;
@@ -363,7 +363,7 @@
363363
"$(inherited)",
364364
"@executable_path/../Frameworks",
365365
);
366-
MARKETING_VERSION = 2.2.0;
366+
MARKETING_VERSION = 2.3.0;
367367
PRODUCT_BUNDLE_IDENTIFIER = LaunchNext;
368368
PRODUCT_NAME = "$(TARGET_NAME)";
369369
REGISTER_APP_GROUPS = YES;
@@ -385,7 +385,7 @@
385385
CLANG_ENABLE_MODULES = YES;
386386
CODE_SIGN_STYLE = Automatic;
387387
COMBINE_HIDPI_IMAGES = YES;
388-
CURRENT_PROJECT_VERSION = 20250908;
388+
CURRENT_PROJECT_VERSION = 20260224;
389389
DEAD_CODE_STRIPPING = YES;
390390
DEVELOPMENT_TEAM = "";
391391
ENABLE_APP_SANDBOX = NO;
@@ -401,7 +401,7 @@
401401
"$(inherited)",
402402
"@executable_path/../Frameworks",
403403
);
404-
MARKETING_VERSION = 2.2.0;
404+
MARKETING_VERSION = 2.3.0;
405405
PRODUCT_BUNDLE_IDENTIFIER = LaunchNext;
406406
PRODUCT_NAME = "$(TARGET_NAME)";
407407
REGISTER_APP_GROUPS = YES;

LaunchNext/AppStore.swift

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,10 @@ final class AppStore: ObservableObject {
224224
static let followScrollPagingKey = "followScrollPagingEnabled"
225225
static let reverseWheelPagingKey = "reverseWheelPagingDirection"
226226
static let useCAGridRendererKey = "useCAGridRenderer"
227+
static let developmentEnableCLICodeKey = "developmentEnableCLICode"
228+
private static let cliShimMarker = "# LaunchNext CLI shim"
229+
private static let cliPathSnippetHeader = "# >>> LaunchNext CLI >>>"
230+
private static let cliPathSnippetFooter = "# <<< LaunchNext CLI <<<"
227231
static let backgroundStyleKey = "launchpadBackgroundStyle"
228232
static let backgroundMaskEnabledKey = "launchpadBackgroundMaskEnabled"
229233
static let backgroundMaskLightKey = "launchpadBackgroundMaskLight"
@@ -462,6 +466,20 @@ final class AppStore: ObservableObject {
462466
// Development-only override to capture flat screenshots quickly.
463467
@Published var developmentBackgroundOverride: DevelopmentBackgroundOverride = .none
464468

469+
@Published var developmentEnableCLICode: Bool = {
470+
if UserDefaults.standard.object(forKey: AppStore.developmentEnableCLICodeKey) == nil { return false }
471+
return UserDefaults.standard.bool(forKey: AppStore.developmentEnableCLICodeKey)
472+
}() {
473+
didSet {
474+
UserDefaults.standard.set(developmentEnableCLICode, forKey: Self.developmentEnableCLICodeKey)
475+
if developmentEnableCLICode && !oldValue {
476+
installCLICommandIfNeeded()
477+
} else if !developmentEnableCLICode && oldValue {
478+
uninstallCLICommandIfNeeded()
479+
}
480+
}
481+
}
482+
465483
@Published var backgroundMaskEnabled: Bool = AppStore.loadBackgroundMaskEnabled() {
466484
didSet {
467485
UserDefaults.standard.set(backgroundMaskEnabled, forKey: Self.backgroundMaskEnabledKey)
@@ -520,6 +538,7 @@ final class AppStore: ObservableObject {
520538
scrollSensitivity = UserDefaults.standard.object(forKey: "scrollSensitivity") as? Double ?? scrollSensitivity
521539
reverseWheelPagingDirection = UserDefaults.standard.object(forKey: Self.reverseWheelPagingKey) as? Bool ?? false
522540
useCAGridRenderer = UserDefaults.standard.object(forKey: Self.useCAGridRendererKey) as? Bool ?? useCAGridRenderer
541+
developmentEnableCLICode = UserDefaults.standard.object(forKey: Self.developmentEnableCLICodeKey) as? Bool ?? false
523542

524543
// Keep imported appearance/input settings in sync without requiring relaunch.
525544
iconScale = UserDefaults.standard.object(forKey: "iconScale") as? Double ?? iconScale
@@ -1690,6 +1709,9 @@ final class AppStore: ObservableObject {
16901709
if defaults.object(forKey: Self.useCAGridRendererKey) == nil {
16911710
defaults.set(true, forKey: Self.useCAGridRendererKey)
16921711
}
1712+
if defaults.object(forKey: Self.developmentEnableCLICodeKey) == nil {
1713+
defaults.set(false, forKey: Self.developmentEnableCLICodeKey)
1714+
}
16931715
if defaults.object(forKey: Self.backgroundMaskEnabledKey) == nil {
16941716
defaults.set(false, forKey: Self.backgroundMaskEnabledKey)
16951717
}
@@ -1748,6 +1770,12 @@ final class AppStore: ObservableObject {
17481770

17491771
searchQuery = searchText
17501772

1773+
if developmentEnableCLICode {
1774+
installCLICommandIfNeeded()
1775+
} else {
1776+
uninstallCLICommandIfNeeded()
1777+
}
1778+
17511779
scheduleAutomaticUpdateCheck()
17521780

17531781
self.rememberLastPage = shouldRememberPage
@@ -1765,6 +1793,194 @@ final class AppStore: ObservableObject {
17651793
loginItemUpdateInProgress = false
17661794
}
17671795

1796+
private func installCLICommandIfNeeded() {
1797+
guard let executablePath = Bundle.main.executableURL?.path else { return }
1798+
for path in cliCommandTargets() {
1799+
if installCLIShim(at: path, executablePath: executablePath) {
1800+
let directory = (path as NSString).deletingLastPathComponent
1801+
ensureZProfilePathIncludes(directory: directory)
1802+
return
1803+
}
1804+
}
1805+
}
1806+
1807+
@discardableResult
1808+
func removeInstalledCLICommand() -> Bool {
1809+
uninstallCLICommandIfNeeded()
1810+
}
1811+
1812+
private func cliCommandTargets() -> [String] {
1813+
let homePath = FileManager.default.homeDirectoryForCurrentUser.path
1814+
return [
1815+
"/opt/homebrew/bin/launchnext",
1816+
"/usr/local/bin/launchnext",
1817+
"\(homePath)/.local/bin/launchnext",
1818+
"\(homePath)/bin/launchnext"
1819+
]
1820+
}
1821+
1822+
private func installCLIShim(at shimPath: String, executablePath: String) -> Bool {
1823+
let fileManager = FileManager.default
1824+
let directoryPath = (shimPath as NSString).deletingLastPathComponent
1825+
1826+
if !fileManager.fileExists(atPath: directoryPath) {
1827+
do {
1828+
try fileManager.createDirectory(atPath: directoryPath, withIntermediateDirectories: true)
1829+
} catch {
1830+
return false
1831+
}
1832+
}
1833+
1834+
guard fileManager.isWritableFile(atPath: directoryPath) else {
1835+
return false
1836+
}
1837+
1838+
if fileManager.fileExists(atPath: shimPath) {
1839+
if let destination = try? fileManager.destinationOfSymbolicLink(atPath: shimPath),
1840+
destination == executablePath {
1841+
return true
1842+
}
1843+
if let existing = try? String(contentsOfFile: shimPath, encoding: .utf8),
1844+
existing.contains(Self.cliShimMarker) {
1845+
// Managed shim, safe to replace.
1846+
} else {
1847+
return false
1848+
}
1849+
}
1850+
1851+
let escapedExecutable = executablePath.replacingOccurrences(of: "\"", with: "\\\"")
1852+
let script = """
1853+
#!/bin/zsh
1854+
\(Self.cliShimMarker)
1855+
1856+
if [[ "$1" == "--help" || "$1" == "-h" || "$1" == "help" ]]; then
1857+
cat <<'EOF'
1858+
LaunchNext CLI
1859+
1860+
Usage:
1861+
launchnext --help
1862+
launchnext --gui
1863+
launchnext --tui
1864+
launchnext --cli help
1865+
launchnext --cli list
1866+
launchnext --cli snapshot
1867+
launchnext --cli search --query "safari"
1868+
launchnext --cli move --source normal-app --path "/Applications/Thaw.app" --to folder-append --target-folder-id <folder-id>
1869+
1870+
Notes:
1871+
- Keep `--cli --help` and `--cli help` for full in-app CLI help.
1872+
- LaunchNext GUI must be running for list/snapshot/search/move.
1873+
- "Command line interface" must be ON in General settings.
1874+
EOF
1875+
exit 0
1876+
fi
1877+
1878+
exec "\(escapedExecutable)" "$@"
1879+
"""
1880+
1881+
do {
1882+
try script.write(toFile: shimPath, atomically: true, encoding: .utf8)
1883+
try fileManager.setAttributes([.posixPermissions: NSNumber(value: Int(0o755))], ofItemAtPath: shimPath)
1884+
return true
1885+
} catch {
1886+
return false
1887+
}
1888+
}
1889+
1890+
@discardableResult
1891+
private func uninstallCLICommandIfNeeded() -> Bool {
1892+
var removedAny = false
1893+
for path in cliCommandTargets() {
1894+
let directory = (path as NSString).deletingLastPathComponent
1895+
if uninstallCLIShim(at: path) { removedAny = true }
1896+
if removeCLIPathSnippetFromZProfile(directory: directory) { removedAny = true }
1897+
}
1898+
return removedAny
1899+
}
1900+
1901+
private func uninstallCLIShim(at shimPath: String) -> Bool {
1902+
let fileManager = FileManager.default
1903+
guard fileManager.fileExists(atPath: shimPath) else { return false }
1904+
1905+
let isManagedShim: Bool = {
1906+
if let existing = try? String(contentsOfFile: shimPath, encoding: .utf8),
1907+
existing.contains(Self.cliShimMarker) {
1908+
return true
1909+
}
1910+
if let destination = try? fileManager.destinationOfSymbolicLink(atPath: shimPath),
1911+
destination.contains("/LaunchNext.app/Contents/MacOS/LaunchNext") {
1912+
return true
1913+
}
1914+
return false
1915+
}()
1916+
1917+
guard isManagedShim else { return false }
1918+
do {
1919+
try fileManager.removeItem(atPath: shimPath)
1920+
return true
1921+
} catch {
1922+
return false
1923+
}
1924+
}
1925+
1926+
private func ensureZProfilePathIncludes(directory: String) {
1927+
guard directory.hasPrefix(FileManager.default.homeDirectoryForCurrentUser.path) else { return }
1928+
1929+
let zprofileURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".zprofile")
1930+
let snippet = cliPathSnippet(directory: directory)
1931+
1932+
if let existing = try? String(contentsOf: zprofileURL, encoding: .utf8) {
1933+
if existing.contains(":\(directory):") || existing.contains("export PATH=\"\(directory):$PATH\"") {
1934+
return
1935+
}
1936+
try? (existing + snippet).write(to: zprofileURL, atomically: true, encoding: .utf8)
1937+
} else {
1938+
try? snippet.write(to: zprofileURL, atomically: true, encoding: .utf8)
1939+
}
1940+
}
1941+
1942+
@discardableResult
1943+
private func removeCLIPathSnippetFromZProfile(directory: String) -> Bool {
1944+
let homePath = FileManager.default.homeDirectoryForCurrentUser.path
1945+
guard directory.hasPrefix(homePath) else { return false }
1946+
1947+
let zprofileURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".zprofile")
1948+
guard let existing = try? String(contentsOf: zprofileURL, encoding: .utf8) else { return false }
1949+
1950+
var updated = existing
1951+
updated = updated.replacingOccurrences(of: cliPathSnippet(directory: directory), with: "")
1952+
updated = updated.replacingOccurrences(of: legacyCLIPathSnippet(directory: directory), with: "")
1953+
1954+
guard updated != existing else { return false }
1955+
do {
1956+
try updated.write(to: zprofileURL, atomically: true, encoding: .utf8)
1957+
return true
1958+
} catch {
1959+
return false
1960+
}
1961+
}
1962+
1963+
private func cliPathSnippet(directory: String) -> String {
1964+
"""
1965+
1966+
\(Self.cliPathSnippetHeader)
1967+
if [[ ":$PATH:" != *":\(directory):"* ]]; then
1968+
export PATH="\(directory):$PATH"
1969+
fi
1970+
\(Self.cliPathSnippetFooter)
1971+
"""
1972+
}
1973+
1974+
private func legacyCLIPathSnippet(directory: String) -> String {
1975+
"""
1976+
1977+
# LaunchNext CLI
1978+
if [[ ":$PATH:" != *":\(directory):"* ]]; then
1979+
export PATH="\(directory):$PATH"
1980+
fi
1981+
"""
1982+
}
1983+
17681984
private static func loadCustomTitles() -> [String: String] {
17691985
guard let raw = UserDefaults.standard.dictionary(forKey: AppStore.customTitlesKey) else {
17701986
return [:]

0 commit comments

Comments
 (0)