diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c6f04ff93..72d3500744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Settings > Editor grouped into Display, Gutter and Editing. - Editor Font and Data Grid Font moved to Settings > Editor and Settings > Data, and kept per Mac. - Editor font size range of 10 to 24 points everywhere, including zoom. +- tablepro command ships inside the app. Settings > General shows the Terminal line that links it; no administrator prompt. - MCP server runs only while Enable MCP Server is on in Settings > MCP. Clients and pairing no longer start it while it is off. - Edit > Data shown only in data file windows. - Delta updates from the last five releases, about 6 MB instead of the full 34 MB download. diff --git a/TablePro/CLI/tablepro b/TablePro/CLI/tablepro new file mode 100755 index 0000000000..b9a2441dd8 --- /dev/null +++ b/TablePro/CLI/tablepro @@ -0,0 +1,2 @@ +#!/bin/sh +exec open -b com.TablePro "$@" diff --git a/TablePro/Core/Services/Infrastructure/CommandLineShim.swift b/TablePro/Core/Services/Infrastructure/CommandLineShim.swift new file mode 100644 index 0000000000..1c930cee14 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/CommandLineShim.swift @@ -0,0 +1,57 @@ +// +// CommandLineShim.swift +// TablePro +// + +import Foundation + +internal enum CommandLineShimStatus: Equatable, Sendable { + case linked + case notLinked + case otherFile +} + +/// The `tablepro` shim ships in the bundle and the person links it onto their PATH with a command +/// they run themselves, so the app never writes outside its own container or asks for admin rights. +internal struct CommandLineShim: Sendable { + internal static let linkPath = "/usr/local/bin/tablepro" + + internal let shimPath: String + internal let linkPath: String + + internal init(bundle: Bundle = .main, linkPath: String = CommandLineShim.linkPath) { + let sharedSupport = bundle.sharedSupportURL ?? bundle.bundleURL.appendingPathComponent("Contents/SharedSupport") + self.init(shimPath: sharedSupport.appendingPathComponent("bin/tablepro").path, linkPath: linkPath) + } + + internal init(shimPath: String, linkPath: String) { + self.shimPath = shimPath + self.linkPath = linkPath + } + + internal func status(fileManager: FileManager = .default) -> CommandLineShimStatus { + if let destination = try? fileManager.destinationOfSymbolicLink(atPath: linkPath) { + let resolved = URL(fileURLWithPath: destination, relativeTo: URL(fileURLWithPath: linkPath)) + return resolved.standardizedFileURL.path == URL(fileURLWithPath: shimPath).standardizedFileURL.path + ? .linked + : .otherFile + } + return fileManager.fileExists(atPath: linkPath) ? .otherFile : .notLinked + } + + /// `rm -f` first: `ln -sf` onto a link to a directory writes inside that directory, and `rm` + /// refuses a real directory, so the line stops instead of touching one. + internal var linkCommand: String { + let directory = (linkPath as NSString).deletingLastPathComponent + let link = Self.quote(linkPath) + return "sudo mkdir -p \(Self.quote(directory)) && sudo rm -f \(link) && sudo ln -s \(Self.quote(shimPath)) \(link)" + } + + internal var unlinkCommand: String { + "sudo rm \(Self.quote(linkPath))" + } + + internal static func quote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} diff --git a/TablePro/Core/Services/Infrastructure/CommandLineToolInstaller.swift b/TablePro/Core/Services/Infrastructure/CommandLineToolInstaller.swift deleted file mode 100644 index 2fc06fd575..0000000000 --- a/TablePro/Core/Services/Infrastructure/CommandLineToolInstaller.swift +++ /dev/null @@ -1,164 +0,0 @@ -// -// CommandLineToolInstaller.swift -// TablePro -// - -import Foundation -import os - -internal enum CommandLineToolStatus: Equatable, Sendable { - case notInstalled - case installed - case conflict -} - -internal enum CommandLineToolError: Error, LocalizedError, Equatable { - case conflict(String) - case cancelled - case writeFailed(String) - - internal var errorDescription: String? { - switch self { - case .conflict(let path): - return String(format: String(localized: "A different file already exists at %@."), path) - case .cancelled: - return String(localized: "Cancelled by user.") - case .writeFailed(let reason): - return reason - } - } -} - -@MainActor -internal protocol CommandLineToolInstalling { - var toolPath: String { get } - var status: CommandLineToolStatus { get } - var manualInstallCommand: String { get } - var manualUninstallCommand: String { get } - func install() throws - func uninstall() throws -} - -@MainActor -internal final class CommandLineToolInstaller: CommandLineToolInstalling { - internal static let shared = CommandLineToolInstaller() - - nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "CommandLineToolInstaller") - private static let toolName = "tablepro" - private static let marker = "# TablePro command line tool" - private static let scriptContents = """ - #!/bin/sh - \(marker) - exec open -b com.TablePro "$@" - - """ - - private let directory: String - private let fileManager: FileManager - private let privilegedShell: PrivilegedShellRunning - - internal init( - directory: String = "/usr/local/bin", - fileManager: FileManager = .default, - privilegedShell: PrivilegedShellRunning? = nil - ) { - self.directory = directory - self.fileManager = fileManager - self.privilegedShell = privilegedShell ?? OSAScriptPrivilegedShell() - } - - internal var toolPath: String { - (directory as NSString).appendingPathComponent(Self.toolName) - } - - internal var status: CommandLineToolStatus { - guard fileManager.fileExists(atPath: toolPath) else { return .notInstalled } - guard let contents = try? String(contentsOfFile: toolPath, encoding: .utf8), - contents.contains(Self.marker) - else { return .conflict } - return .installed - } - - internal var installCommand: String { - let script = Self.scriptContents.replacingOccurrences(of: "\n", with: "\\n") - let quotedDirectory = OSAScriptPrivilegedShell.quote(directory) - let quotedPath = OSAScriptPrivilegedShell.quote(toolPath) - return "mkdir -p \(quotedDirectory)" - + " && printf \(OSAScriptPrivilegedShell.quote(script)) > \(quotedPath)" - + " && chmod 755 \(quotedPath)" - } - - internal var uninstallCommand: String { - "rm -f \(OSAScriptPrivilegedShell.quote(toolPath))" - } - - internal var manualInstallCommand: String { - "sudo sh -c \(OSAScriptPrivilegedShell.quote(installCommand))" - } - - internal var manualUninstallCommand: String { - "sudo sh -c \(OSAScriptPrivilegedShell.quote(uninstallCommand))" - } - - internal func install() throws { - guard status != .conflict else { throw CommandLineToolError.conflict(toolPath) } - - if canWriteDirectly, writeShimDirectly() { - Self.logger.info("Installed command line tool at \(self.toolPath, privacy: .private(mask: .hash))") - return - } - - try runPrivileged(installCommand) - guard status == .installed else { - throw CommandLineToolError.writeFailed( - String(format: String(localized: "Could not write %@."), toolPath) - ) - } - Self.logger.info("Installed command line tool with administrator rights") - } - - internal func uninstall() throws { - switch status { - case .notInstalled: - return - case .conflict: - throw CommandLineToolError.conflict(toolPath) - case .installed: - if (try? fileManager.removeItem(atPath: toolPath)) != nil { - Self.logger.info("Removed command line tool at \(self.toolPath, privacy: .private(mask: .hash))") - return - } - try runPrivileged(uninstallCommand) - guard status == .notInstalled else { - throw CommandLineToolError.writeFailed( - String(format: String(localized: "Could not remove %@."), toolPath) - ) - } - } - } - - private var canWriteDirectly: Bool { - fileManager.fileExists(atPath: directory) && fileManager.isWritableFile(atPath: directory) - } - - private func writeShimDirectly() -> Bool { - do { - try Self.scriptContents.write(toFile: toolPath, atomically: true, encoding: .utf8) - try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: toolPath) - return true - } catch { - Self.logger.debug("Direct write failed, escalating: \(error.publicLogShape, privacy: .public)") - return false - } - } - - private func runPrivileged(_ command: String) throws { - do { - try privilegedShell.run(command) - } catch PrivilegedShellError.cancelled { - throw CommandLineToolError.cancelled - } catch { - throw CommandLineToolError.writeFailed(error.localizedDescription) - } - } -} diff --git a/TablePro/Core/Services/Infrastructure/PrivilegedShell.swift b/TablePro/Core/Services/Infrastructure/PrivilegedShell.swift deleted file mode 100644 index fcd82fb113..0000000000 --- a/TablePro/Core/Services/Infrastructure/PrivilegedShell.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// PrivilegedShell.swift -// TablePro -// - -import Foundation -import os - -internal enum PrivilegedShellError: Error, LocalizedError, Equatable { - case cancelled - case failed(String) - - internal var errorDescription: String? { - switch self { - case .cancelled: - return String(localized: "Cancelled by user.") - case .failed(let reason): - return reason - } - } -} - -@MainActor -internal protocol PrivilegedShellRunning { - func run(_ command: String) throws -} - -@MainActor -internal struct OSAScriptPrivilegedShell: PrivilegedShellRunning { - nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "PrivilegedShell") - - internal static func quote(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" - } - - internal static func appleScript(for command: String) -> String { - let escaped = command - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - return "do shell script \"\(escaped)\" with administrator privileges" - } - - internal func run(_ command: String) throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") - process.arguments = ["-e", Self.appleScript(for: command)] - - let errorPipe = Pipe() - process.standardError = errorPipe - process.standardOutput = Pipe() - - do { - try process.run() - } catch { - throw PrivilegedShellError.failed(error.localizedDescription) - } - - let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - guard process.terminationStatus != 0 else { return } - - let message = (String(data: errorData, encoding: .utf8) ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - if message.contains("-128") || message.localizedCaseInsensitiveContains("cancel") { - throw PrivilegedShellError.cancelled - } - Self.logger.error("Privileged command failed: \(message, privacy: .private)") - throw PrivilegedShellError.failed(message) - } -} diff --git a/TablePro/Views/Settings/Sections/CommandLineToolSection.swift b/TablePro/Views/Settings/Sections/CommandLineToolSection.swift index abfc0cc8f0..b13ec1fd10 100644 --- a/TablePro/Views/Settings/Sections/CommandLineToolSection.swift +++ b/TablePro/Views/Settings/Sections/CommandLineToolSection.swift @@ -7,48 +7,31 @@ import AppKit import SwiftUI struct CommandLineToolSection: View { - private let installer: CommandLineToolInstalling + private let tool: CommandLineShim - @State private var status: CommandLineToolStatus = .notInstalled - @State private var manualCommand: String? - @State private var errorMessage: String? + @State private var status: CommandLineShimStatus = .notLinked - init(installer: CommandLineToolInstalling = CommandLineToolInstaller.shared) { - self.installer = installer + init(tool: CommandLineShim = CommandLineShim()) { + self.tool = tool } var body: some View { Section { LabeledContent { - switch status { - case .notInstalled: - Button("Install") { install() } - case .installed: - Button("Uninstall") { uninstall() } - case .conflict: - Button("Install") { install() } - .disabled(true) - } + Text(statusText) + .foregroundStyle(.secondary) } label: { Text("tablepro command") - Text(installer.toolPath) + Text(tool.linkPath) .font(.system(.caption, design: .monospaced)) } - if let manualCommand { - VStack(alignment: .leading, spacing: 6) { - Text("TablePro could not do this for you. Run this in Terminal instead:") - .font(.caption) - .foregroundStyle(.secondary) - - CopyableCodeBlock(text: manualCommand) - } - } - - if let errorMessage { - Text(errorMessage) + VStack(alignment: .leading, spacing: 6) { + Text(instruction) .font(.caption) - .foregroundStyle(.red) + .foregroundStyle(.secondary) + + CopyableCodeBlock(text: status == .linked ? tool.unlinkCommand : tool.linkCommand) } } header: { Text("Command Line") @@ -61,44 +44,29 @@ struct CommandLineToolSection: View { } } - private func syncStatus() { - let updated = installer.status - guard updated != status else { return } - status = updated - manualCommand = nil - errorMessage = nil + private var statusText: String { + switch status { + case .linked: + String(localized: "Linked") + case .notLinked: + String(localized: "Not linked") + case .otherFile: + String(localized: "Another file is there") + } } - private func install() { - do { - try installer.install() - manualCommand = nil - errorMessage = nil - } catch CommandLineToolError.cancelled { - manualCommand = nil - errorMessage = nil - } catch CommandLineToolError.conflict(let path) { - manualCommand = nil - errorMessage = CommandLineToolError.conflict(path).localizedDescription - } catch { - manualCommand = installer.manualInstallCommand - errorMessage = error.localizedDescription + private var instruction: String { + switch status { + case .linked: + String(localized: "To remove it, run this in Terminal:") + case .notLinked: + String(localized: "To add it to your PATH, run this in Terminal:") + case .otherFile: + String(localized: "To replace that file with a link to this app, run this in Terminal:") } - status = installer.status } - private func uninstall() { - do { - try installer.uninstall() - manualCommand = nil - errorMessage = nil - } catch CommandLineToolError.cancelled { - manualCommand = nil - errorMessage = nil - } catch { - manualCommand = installer.manualUninstallCommand - errorMessage = error.localizedDescription - } - status = installer.status + private func syncStatus() { + status = tool.status() } } diff --git a/TableProTests/Core/Services/Infrastructure/CommandLineShimTests.swift b/TableProTests/Core/Services/Infrastructure/CommandLineShimTests.swift new file mode 100644 index 0000000000..98e65e0c25 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/CommandLineShimTests.swift @@ -0,0 +1,130 @@ +import Foundation +@testable import TablePro +import Testing + +struct CommandLineShimTests { + private func makeDirectory() throws -> String { + let path = NSTemporaryDirectory().appending("CommandLineShimTests.\(UUID().uuidString)") + try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) + return path + } + + private func tool(in directory: String) throws -> CommandLineShim { + let shim = (directory as NSString).appendingPathComponent("Tablé Pro's.app/bin/tablepro") + try FileManager.default.createDirectory( + atPath: (shim as NSString).deletingLastPathComponent, + withIntermediateDirectories: true + ) + FileManager.default.createFile(atPath: shim, contents: Data("#!/bin/sh\n".utf8)) + return CommandLineShim( + shimPath: shim, + linkPath: (directory as NSString).appendingPathComponent("local/bin/tablepro") + ) + } + + private func runWithoutSudo(_ command: String) throws -> Int32 { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", command.replacingOccurrences(of: "sudo ", with: "")] + try process.run() + process.waitUntilExit() + return process.terminationStatus + } + + @Test("The app bundle carries an executable shim that opens TablePro by bundle id") + func bundleCarriesTheShim() throws { + let shim = CommandLineShim().shimPath + #expect(shim.hasSuffix("/Contents/SharedSupport/bin/tablepro")) + #expect(FileManager.default.isExecutableFile(atPath: shim)) + let contents = try String(contentsOfFile: shim, encoding: .utf8) + #expect(contents.contains("exec open -b com.TablePro \"$@\"")) + } + + @Test("Nothing at the link path reads as not linked") + func emptyPathIsNotLinked() throws { + let tool = try tool(in: try makeDirectory()) + #expect(tool.status() == .notLinked) + } + + @Test("The printed link command links the bundled shim, quoting every path") + func linkCommandLinksTheShim() throws { + let tool = try tool(in: try makeDirectory()) + + #expect(try runWithoutSudo(tool.linkCommand) == 0) + + #expect(tool.status() == .linked) + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: tool.linkPath) == tool.shimPath) + } + + @Test("The printed unlink command removes the link") + func unlinkCommandRemovesTheLink() throws { + let tool = try tool(in: try makeDirectory()) + #expect(try runWithoutSudo(tool.linkCommand) == 0) + + #expect(try runWithoutSudo(tool.unlinkCommand) == 0) + + #expect(tool.status() == .notLinked) + } + + @Test("A relative link to the shim counts as linked") + func relativeLinkIsLinked() throws { + let directory = try makeDirectory() + let tool = try tool(in: directory) + let linkDirectory = (tool.linkPath as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: linkDirectory, withIntermediateDirectories: true) + + try FileManager.default.createSymbolicLink( + atPath: tool.linkPath, + withDestinationPath: "../../Tablé Pro's.app/bin/tablepro" + ) + + #expect(tool.status() == .linked) + } + + @Test("A file or a link that is not this app's shim is reported as another file") + func foreignFileIsReported() throws { + let tool = try tool(in: try makeDirectory()) + let linkDirectory = (tool.linkPath as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: linkDirectory, withIntermediateDirectories: true) + + FileManager.default.createFile(atPath: tool.linkPath, contents: Data("#!/bin/sh\n".utf8)) + #expect(tool.status() == .otherFile) + + try FileManager.default.removeItem(atPath: tool.linkPath) + try FileManager.default.createSymbolicLink(atPath: tool.linkPath, withDestinationPath: "/usr/bin/true") + #expect(tool.status() == .otherFile) + } + + @Test("Relinking over a link to a directory replaces the link and leaves the directory alone") + func relinkOverADirectoryLink() throws { + let directory = try makeDirectory() + let tool = try tool(in: directory) + let elsewhere = (directory as NSString).appendingPathComponent("elsewhere") + try FileManager.default.createDirectory(atPath: elsewhere, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + atPath: (tool.linkPath as NSString).deletingLastPathComponent, + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink(atPath: tool.linkPath, withDestinationPath: elsewhere) + + #expect(try runWithoutSudo(tool.linkCommand) == 0) + + #expect(tool.status() == .linked) + #expect(try FileManager.default.contentsOfDirectory(atPath: elsewhere).isEmpty) + } + + @Test("The link command stops at a real directory instead of writing into it") + func linkCommandStopsAtADirectory() throws { + let tool = try tool(in: try makeDirectory()) + try FileManager.default.createDirectory(atPath: tool.linkPath, withIntermediateDirectories: true) + + #expect(try runWithoutSudo(tool.linkCommand) != 0) + + #expect(try FileManager.default.contentsOfDirectory(atPath: tool.linkPath).isEmpty) + } + + @Test("A single quote in a path cannot break out of the quoting") + func quotingEscapesSingleQuotes() { + #expect(CommandLineShim.quote("/Apps/Dat's.app") == "'/Apps/Dat'\\''s.app'") + } +} diff --git a/TableProTests/Core/Services/Infrastructure/CommandLineToolInstallerTests.swift b/TableProTests/Core/Services/Infrastructure/CommandLineToolInstallerTests.swift deleted file mode 100644 index 44a3794948..0000000000 --- a/TableProTests/Core/Services/Infrastructure/CommandLineToolInstallerTests.swift +++ /dev/null @@ -1,189 +0,0 @@ -import Foundation -@testable import TablePro -import Testing - -@MainActor -private final class ExecutingShell: PrivilegedShellRunning { - private(set) var commands: [String] = [] - - func run(_ command: String) throws { - commands.append(command) - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = ["-c", command] - try process.run() - process.waitUntilExit() - guard process.terminationStatus == 0 else { - throw PrivilegedShellError.failed("exit \(process.terminationStatus)") - } - } -} - -@MainActor -private final class CancellingShell: PrivilegedShellRunning { - private(set) var callCount = 0 - - func run(_ command: String) throws { - callCount += 1 - throw PrivilegedShellError.cancelled - } -} - -@MainActor -struct CommandLineToolInstallerTests { - private func makeDirectory(named name: String = UUID().uuidString) throws -> String { - let path = NSTemporaryDirectory().appending("CommandLineToolInstallerTests.\(name)") - try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) - return path - } - - @Test("Reports not installed on a clean directory") - func notInstalledByDefault() throws { - let installer = CommandLineToolInstaller(directory: try makeDirectory()) - #expect(installer.status == .notInstalled) - } - - @Test("Install writes an executable shim that opens TablePro by bundle id") - func installWritesExecutableShim() throws { - let directory = try makeDirectory() - let installer = CommandLineToolInstaller(directory: directory) - - try installer.install() - - #expect(installer.status == .installed) - let contents = try String(contentsOfFile: installer.toolPath, encoding: .utf8) - #expect(contents.contains("exec open -b com.TablePro")) - - let attributes = try FileManager.default.attributesOfItem(atPath: installer.toolPath) - let permissions = try #require(attributes[.posixPermissions] as? NSNumber) - #expect(permissions.int16Value == 0o755) - } - - @Test("A writable directory never asks for an administrator password") - func writableDirectoryDoesNotEscalate() throws { - let shell = CancellingShell() - let installer = CommandLineToolInstaller(directory: try makeDirectory(), privilegedShell: shell) - - try installer.install() - - #expect(installer.status == .installed) - #expect(shell.callCount == 0) - } - - @Test("Uninstall removes the shim") - func uninstallRemovesShim() throws { - let installer = CommandLineToolInstaller(directory: try makeDirectory()) - try installer.install() - - try installer.uninstall() - - #expect(installer.status == .notInstalled) - #expect(FileManager.default.fileExists(atPath: installer.toolPath) == false) - } - - @Test("Uninstalling when nothing is installed is a no-op") - func uninstallWithoutInstallIsNoOp() throws { - let installer = CommandLineToolInstaller(directory: try makeDirectory()) - try installer.uninstall() - #expect(installer.status == .notInstalled) - } - - @Test("A foreign file at the same path is reported as a conflict and never overwritten") - func foreignFileConflicts() throws { - let installer = CommandLineToolInstaller(directory: try makeDirectory()) - try "#!/bin/sh\necho not ours\n".write(toFile: installer.toolPath, atomically: true, encoding: .utf8) - - #expect(installer.status == .conflict) - #expect(throws: CommandLineToolError.conflict(installer.toolPath)) { - try installer.install() - } - - let contents = try String(contentsOfFile: installer.toolPath, encoding: .utf8) - #expect(contents.contains("not ours")) - } - - @Test("A foreign file is never removed by uninstall") - func foreignFileSurvivesUninstall() throws { - let installer = CommandLineToolInstaller(directory: try makeDirectory()) - try "echo not ours\n".write(toFile: installer.toolPath, atomically: true, encoding: .utf8) - - #expect(throws: CommandLineToolError.conflict(installer.toolPath)) { - try installer.uninstall() - } - #expect(FileManager.default.fileExists(atPath: installer.toolPath)) - } - - @Test("A missing directory escalates to an administrator prompt") - func missingDirectoryEscalates() throws { - let missing = NSTemporaryDirectory().appending("missing.\(UUID().uuidString)/bin") - let shell = ExecutingShell() - let installer = CommandLineToolInstaller(directory: missing, privilegedShell: shell) - - try installer.install() - - #expect(shell.commands.count == 1) - #expect(installer.status == .installed) - } - - @Test("Cancelling the password prompt cancels the install and writes nothing") - func cancellingThePromptCancelsInstall() throws { - let missing = NSTemporaryDirectory().appending("missing.\(UUID().uuidString)/bin") - let shell = CancellingShell() - let installer = CommandLineToolInstaller(directory: missing, privilegedShell: shell) - - #expect(throws: CommandLineToolError.cancelled) { - try installer.install() - } - #expect(shell.callCount == 1) - #expect(installer.status == .notInstalled) - } - - @Test("The privileged install command builds a working shim, even in a path with spaces and quotes") - func privilegedCommandSurvivesHostileDirectoryNames() throws { - let directory = try makeDirectory(named: "we ird's dir") - let installer = CommandLineToolInstaller(directory: directory) - let shell = ExecutingShell() - - try shell.run(installer.installCommand) - - #expect(installer.status == .installed) - let contents = try String(contentsOfFile: installer.toolPath, encoding: .utf8) - #expect(contents.hasPrefix("#!/bin/sh\n")) - #expect(contents.contains("exec open -b com.TablePro \"$@\"")) - - try shell.run(installer.uninstallCommand) - #expect(installer.status == .notInstalled) - } - - @Test("A directory name cannot inject a second command into the privileged shell") - func directoryNameCannotInjectCommands() throws { - let canary = NSTemporaryDirectory().appending("canary.\(UUID().uuidString)") - let hostile = try makeDirectory(named: "x'; touch \(canary); echo '") - let installer = CommandLineToolInstaller(directory: hostile) - let shell = ExecutingShell() - - try shell.run(installer.installCommand) - - #expect(FileManager.default.fileExists(atPath: canary) == false) - #expect(installer.status == .installed) - } - - @Test("The AppleScript wrapper escapes quotes and backslashes") - func appleScriptEscaping() { - let script = OSAScriptPrivilegedShell.appleScript(for: #"printf 'a"b\c' > 'x'"#) - - #expect(script.hasPrefix("do shell script \"")) - #expect(script.hasSuffix("\" with administrator privileges")) - #expect(script.contains(#"a\"b\\c"#)) - } - - @Test("Both manual commands run the same thing the app would run") - func manualCommandsMirrorTheAppCommands() throws { - let installer = CommandLineToolInstaller(directory: try makeDirectory()) - - #expect(installer.manualInstallCommand.hasPrefix("sudo sh -c ")) - #expect(installer.manualInstallCommand.contains("open -b com.TablePro")) - #expect(installer.manualUninstallCommand.hasPrefix("sudo sh -c ")) - #expect(installer.manualUninstallCommand.contains(installer.toolPath)) - } -} diff --git a/project.yml b/project.yml index 0a1cbf5bf0..3e00bfe6a5 100644 --- a/project.yml +++ b/project.yml @@ -160,9 +160,17 @@ targets: # Copied whole below. A plain glob flattens it into Resources/ and the # inventory then cannot find its texts/ directory. - Resources/ThirdPartyLicenses + # The tablepro shim, copied below to Contents/SharedSupport/bin. Contents/MacOS + # cannot hold it: the volume is case-insensitive and TablePro already lives there. + - CLI/tablepro - path: TablePro/Resources/ThirdPartyLicenses type: folder buildPhase: resources + - path: TablePro/CLI/tablepro + buildPhase: + copyFiles: + destination: sharedSupport + subpath: bin # The Redis plugin's own readings of a database index and of redis-cli quoting, so the # app classifies, quotes and resolves a Redis statement exactly as the driver will. - Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 939c9c762f..61c2b32294 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -441,6 +441,13 @@ build_for_arch() { exit 1 fi + SHIM_PATH="$BUILD_DIR/$OUTPUT_NAME/Contents/SharedSupport/bin/tablepro" + if [ ! -x "$SHIM_PATH" ]; then + echo "❌ FATAL: tablepro shim missing from $SHIM_PATH" + echo "Check the copy-files phase for TablePro/CLI/tablepro on the TablePro target." + exit 1 + fi + # Get size SIZE=$(ls -lh "$BINARY_PATH" 2>/dev/null | awk '{print $5}') if [ -z "$SIZE" ]; then