@@ -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