diff --git a/CHANGELOG.md b/CHANGELOG.md index 3987317..d8de1e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- The app has an Install Command Line Tool… item in the RemoveMacAI menu. It asks first, then links `/usr/local/bin/removemacai` to the app, so the app from the zip gets the `removemacai` command too. It never replaces a file there that isn't a link (#60). - `removemacai background` with an action other than `off` or `on`, such as the typo `of`, now fails and says which actions it takes, instead of listing the items and exiting 0 (#82). - The app is translated into Hindi and Urdu (#101). Urdu keeps the names of macOS screens and buttons in English, since macOS has no Urdu interface. - Block Music launches is now called Stop Music from opening, even by hand, and its warning says Music won't open at all until you undo it. The old name read as if only AirPods were blocked (#67). diff --git a/Localization/ru.lproj/Localizable.strings b/Localization/ru.lproj/Localizable.strings index c4b0e49..e441c3f 100644 --- a/Localization/ru.lproj/Localizable.strings +++ b/Localization/ru.lproj/Localizable.strings @@ -3,6 +3,7 @@ "RemoveMacAI" = "RemoveMacAI"; "About RemoveMacAI" = "О программе RemoveMacAI"; +"Install Command Line Tool…" = "Установить инструмент командной строки…"; "Reload" = "Обновить"; "How RemoveMacAI Works" = "Как работает RemoveMacAI"; "RemoveMacAI on GitHub" = "RemoveMacAI на GitHub"; @@ -411,3 +412,15 @@ "RemoveMacAI needs your password to change background items for all users." = "RemoveMacAI требуется ваш пароль, чтобы изменить фоновые объекты для всех пользователей."; "RemoveMacAI needs your password to move items only an administrator can change." = "RemoveMacAI требуется ваш пароль, чтобы переместить объекты, которые может изменять только администратор."; "Turn off everything except 1 kept feature" = "Выключить всё, кроме 1 оставленной функции"; + +"The removemacai command is already installed." = "Команда removemacai уже установлена."; +"Open Terminal and run removemacai status." = "Откройте Терминал и выполните removemacai status."; +"Something else is already at %@." = "По пути %@ уже что-то есть."; +"RemoveMacAI left it as it is. Remove it first, then try again." = "RemoveMacAI ничего не изменил. Сначала удалите это, затем попробуйте снова."; +"Install the removemacai command?" = "Установить команду removemacai?"; +"This links %@ to this app, so you can run removemacai in Terminal. If you move or delete the app, the command stops working. macOS asks for your password." = "По пути %@ будет создана ссылка на это приложение, чтобы removemacai можно было запускать в Терминале. Если переместить или удалить приложение, команда перестанет работать. macOS запросит пароль."; +"Install" = "Установить"; +"RemoveMacAI needs your password to install the removemacai command." = "RemoveMacAI требуется ваш пароль, чтобы установить команду removemacai."; +"The removemacai command is installed." = "Команда removemacai установлена."; +"Open a new Terminal window and run removemacai status." = "Откройте новое окно Терминала и выполните removemacai status."; +"The removemacai command wasn't installed." = "Команда removemacai не установлена."; diff --git a/README.md b/README.md index fac0049..4ede9df 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ This runs the command-line version from a temporary folder and installs nothing. brew install omlahore/tap/removemacai ``` -The app and the command line are the same program and share the same undo history. +The app and the command line are the same program and share the same undo history. If you installed the app from the zip, choose RemoveMacAI > Install Command Line Tool… to get the `removemacai` command too. ## What it does diff --git a/Sources/removemacai/App/RemoveMacAIApp.swift b/Sources/removemacai/App/RemoveMacAIApp.swift index 9722bf6..c96f956 100644 --- a/Sources/removemacai/App/RemoveMacAIApp.swift +++ b/Sources/removemacai/App/RemoveMacAIApp.swift @@ -17,6 +17,9 @@ struct RemoveMacAIApp: App { CommandGroup(replacing: .appInfo) { Button("About RemoveMacAI") { AppDelegate.showAbout() } } + CommandGroup(after: .appSettings) { + Button("Install Command Line Tool…") { AppDelegate.installCommandLineTool() } + } CommandGroup(after: .toolbar) { Button("Reload") { model.reload(resetChoices: true) }.keyboardShortcut("r") } @@ -54,6 +57,45 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } + /// Links the app's binary into /usr/local/bin, so the app from the zip gets + /// the removemacai command the installer and Homebrew give. Asks first, and + /// never replaces a file there that isn't a link. + static func installCommandLineTool() { + guard let binary = Bundle.main.executablePath else { return } + let path = CommandLineTool.path + let fm = FileManager.default + if let existing = try? fm.destinationOfSymbolicLink(atPath: path) { + if existing == binary { + return message(String(localized: "The removemacai command is already installed."), + String(localized: "Open Terminal and run removemacai status.")) + } + } else if fm.fileExists(atPath: path) { + return message(String(localized: "Something else is already at \(path)."), + String(localized: "RemoveMacAI left it as it is. Remove it first, then try again.")) + } + let confirm = NSAlert() + confirm.messageText = String(localized: "Install the removemacai command?") + confirm.informativeText = String(localized: "This links \(path) to this app, so you can run removemacai in Terminal. If you move or delete the app, the command stops working. macOS asks for your password.") + confirm.addButton(withTitle: String(localized: "Install")) + confirm.addButton(withTitle: String(localized: "Cancel")) + guard confirm.runModal() == .alertFirstButtonReturn else { return } + let result = CommandLineTool.link( + to: binary, prompt: String(localized: "RemoveMacAI needs your password to install the removemacai command.")) + if result.ok { + message(String(localized: "The removemacai command is installed."), + String(localized: "Open a new Terminal window and run removemacai status.")) + } else { + message(String(localized: "The removemacai command wasn't installed."), localizedMessage(Shell.adminError(result))) + } + } + + private static func message(_ title: String, _ text: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = text + alert.runModal() + } + static func showAbout() { let credits = NSMutableAttributedString( string: String(localized: "Debloats macOS and can undo every change.\n\nThe Apple Intelligence part builds on pared by 4evy, which first mapped Apple's asset service and model sets."), diff --git a/Sources/removemacai/CommandLineTool.swift b/Sources/removemacai/CommandLineTool.swift new file mode 100644 index 0000000..ca3f6a5 --- /dev/null +++ b/Sources/removemacai/CommandLineTool.swift @@ -0,0 +1,23 @@ +import Foundation + +/// The removemacai command for the app from the zip, which the installer and +/// Homebrew put on the PATH but the app doesn't. +enum CommandLineTool { + static let path = "/usr/local/bin/removemacai" + + /// Links path to the binary after one password prompt. + static func link(to binary: String, prompt: String) -> Shell.Result { + Shell.admin(commands(linking: binary, at: path), prompt: prompt) + } + + /// Checks again as root, since ln -f would delete a file that appeared after the app looked. + static func commands(linking binary: String, at path: String) -> [String] { + let folder = (path as NSString).deletingLastPathComponent + let p = Shell.quote(path) + return [ + "mkdir -p \(Shell.quote(folder))", + "if [ -e \(p) ] && [ ! -L \(p) ]; then echo \(Shell.quote("\(path) isn't a link, so it was left alone")) >&2; exit 1; fi; " + + "ln -sfn \(Shell.quote(binary)) \(p)", + ] + } +} diff --git a/Sources/removemacai/SelfTest.swift b/Sources/removemacai/SelfTest.swift index 36712fc..2d7d925 100644 --- a/Sources/removemacai/SelfTest.swift +++ b/Sources/removemacai/SelfTest.swift @@ -291,6 +291,19 @@ func selfTest() -> Bool { check(!ranAll.ok && ranAll.output.contains("second"), "one failed administrator step doesn't skip the rest") let exited = Shell.run("/bin/sh", ["-c", Shell.adminScript(["exit 3", "echo after-exit"])]) check(!exited.ok && exited.output.contains("after-exit"), "a step that exits doesn't stop the rest") + let toolFolder = FileManager.default.temporaryDirectory.appendingPathComponent("removemacai-cli-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: toolFolder) } + let toolLink = toolFolder.appendingPathComponent("bin/removemacai").path + let binary = "/Applications/Remove Mac AI's.app/Contents/MacOS/RemoveMacAI" + let linked = Shell.run("/bin/sh", ["-c", Shell.adminScript(CommandLineTool.commands(linking: binary, at: toolLink))]) + let relinked = Shell.run("/bin/sh", ["-c", Shell.adminScript(CommandLineTool.commands(linking: binary, at: toolLink))]) + check(linked.ok && relinked.ok && (try? FileManager.default.destinationOfSymbolicLink(atPath: toolLink)) == binary, + "the command line tool links to the app, even from a path with spaces and quotes") + let toolFile = toolFolder.appendingPathComponent("bin/other").path + FileManager.default.createFile(atPath: toolFile, contents: Data("keep".utf8)) + let refused = Shell.run("/bin/sh", ["-c", Shell.adminScript(CommandLineTool.commands(linking: binary, at: toolFile))]) + check(!refused.ok && refused.output.contains("isn't a link") && (try? String(contentsOfFile: toolFile, encoding: .utf8)) == "keep", + "the command line tool never replaces a file that isn't a link") check(Shell.run("/bin/sh", ["-c", Shell.adminScript(["true", "true"])]).ok, "administrator steps succeed together") check(Term.pad("abc", 5) == "abc " && Term.pad("abcdef", 5) == "abcdef ", "columns never run together") check(Term.colorEnabled(env: [:], tty: true) && !Term.colorEnabled(env: [:], tty: false),