From 680571aee962969c96f06774fc024d5f8e1b5346 Mon Sep 17 00:00:00 2001 From: hewigovens <360470+hewigovens@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:36:00 +0900 Subject: [PATCH 1/2] Dev workflow: auto-update installed CLI, debug Beta badge, just run symlink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - On launch, silently refresh an already-installed ~/.local/bin CLI when it differs from the app's bundled CLI (byte comparison, so any change is detected); no-op when the CLI isn't installed. - Show a Dock 'βeta' badge in DEBUG builds (follows ../jayjay's DebugBadge). - 'just run' symlinks ./GhostTile.app to the debug build it launches; .gitignore ignores the symlink as well as the build directory. --- .gitignore | 6 ++-- Sources/GhostTileApp/App/DebugBadge.swift | 34 +++++++++++++++++++ Sources/GhostTileApp/App/GhostTileApp.swift | 5 +++ .../Services/CLI/CLIInstaller.swift | 11 ++++++ .../GhostTileApp/Services/CLI/CLIPaths.swift | 22 +++++++++--- justfile | 7 ++-- 6 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 Sources/GhostTileApp/App/DebugBadge.swift diff --git a/.gitignore b/.gitignore index 370e34c..5c112e5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,9 @@ GhostTile.xcodeproj/ xcuserdata/ DerivedData/ -# App bundle output -GhostTile.app/ -GhostTile Dev.app/ +# App bundle output (real dir from `just build`, or symlink from `just run`) +GhostTile.app +GhostTile Dev.app dist/ # Generated during build (copied from docs/imgs/icon.svg) diff --git a/Sources/GhostTileApp/App/DebugBadge.swift b/Sources/GhostTileApp/App/DebugBadge.swift new file mode 100644 index 0000000..1815fd8 --- /dev/null +++ b/Sources/GhostTileApp/App/DebugBadge.swift @@ -0,0 +1,34 @@ +import AppKit + +enum DebugBadge { + static func apply() { + #if DEBUG + let tile = NSApplication.shared.dockTile + let iconView = NSImageView(frame: NSRect(x: 0, y: 0, width: tile.size.width, height: tile.size.height)) + iconView.image = NSApplication.shared.applicationIconImage + + let badge = NSTextField(labelWithString: "βeta") + badge.font = NSFont.boldSystemFont(ofSize: 24) + badge.textColor = .white + badge.backgroundColor = .systemOrange + badge.isBezeled = false + badge.alignment = .center + badge.sizeToFit() + badge.frame = NSRect( + x: tile.size.width - badge.frame.width - 4, + y: 2, + width: badge.frame.width + 8, + height: badge.frame.height + 2 + ) + badge.wantsLayer = true + badge.layer?.cornerRadius = badge.frame.height / 2 + badge.layer?.masksToBounds = true + + let container = NSView(frame: iconView.frame) + container.addSubview(iconView) + container.addSubview(badge) + tile.contentView = container + tile.display() + #endif + } +} diff --git a/Sources/GhostTileApp/App/GhostTileApp.swift b/Sources/GhostTileApp/App/GhostTileApp.swift index 640f86b..4abb5f8 100644 --- a/Sources/GhostTileApp/App/GhostTileApp.swift +++ b/Sources/GhostTileApp/App/GhostTileApp.swift @@ -9,6 +9,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_: Notification) { intentListener.start() + DebugBadge.apply() + // Keep an already-installed CLI in sync with the app (user-owned ~/.local/bin, no prompt). + DispatchQueue.global(qos: .utility).async { + CLIInstaller.updateIfInstalledAndStale() + } } func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { diff --git a/Sources/GhostTileApp/Services/CLI/CLIInstaller.swift b/Sources/GhostTileApp/Services/CLI/CLIInstaller.swift index eaaea08..45bd77c 100644 --- a/Sources/GhostTileApp/Services/CLI/CLIInstaller.swift +++ b/Sources/GhostTileApp/Services/CLI/CLIInstaller.swift @@ -27,6 +27,17 @@ enum CLIInstaller { } } + /// Silently refresh an already-installed CLI when it differs from the bundled one; no-op if not installed. + static func updateIfInstalledAndStale() { + guard CLIPaths.isInstalled, !CLIPaths.installedIsCurrent else { return } + do { + try install() + Log.info("Silently updated installed CLI to \(BuildInfo.cliDisplayVersion)") + } catch { + Log.error("Silent CLI update failed: \(error)") + } + } + static func uninstall() throws { do { try removeInstalledFiles() diff --git a/Sources/GhostTileApp/Services/CLI/CLIPaths.swift b/Sources/GhostTileApp/Services/CLI/CLIPaths.swift index 4691886..3bc1fe6 100644 --- a/Sources/GhostTileApp/Services/CLI/CLIPaths.swift +++ b/Sources/GhostTileApp/Services/CLI/CLIPaths.swift @@ -43,17 +43,31 @@ enum CLIPaths { installPairExists(cli: installedCLI, dylib: installedDylib) } - /// Cached `--version` check; a stale installed CLI can't parse new flags like `--accept-warnings`. + /// Cached byte comparison vs the bundled CLI/dylib, so any change is detected without a version bump. static var installedIsCurrent: Bool { cachedInstalledIsCurrent } private static let cachedInstalledIsCurrent: Bool = { - guard isInstalled else { return false } - let installedVersion = try? AppManager.run(installedCLI, ["--version"]) - return installedVersion == BuildInfo.cliDisplayVersion + guard isInstalled, + let cli = bundledCLI, + let dylib = bundledDylib + else { return false } + return filesAreIdentical(installedCLI, cli) && filesAreIdentical(installedDylib, dylib) }() + private static func filesAreIdentical(_ lhs: String, _ rhs: String) -> Bool { + let fileManager = FileManager.default + guard let lhsSize = try? fileManager.attributesOfItem(atPath: lhs)[.size] as? Int, + let rhsSize = try? fileManager.attributesOfItem(atPath: rhs)[.size] as? Int, + lhsSize == rhsSize + else { return false } + guard let lhsData = try? Data(contentsOf: URL(fileURLWithPath: lhs), options: .mappedIfSafe), + let rhsData = try? Data(contentsOf: URL(fileURLWithPath: rhs), options: .mappedIfSafe) + else { return false } + return lhsData == rhsData + } + private static func bundledResource(named name: String) -> String? { let path = BundledResources.resourcePath(named: name) return FileManager.default.fileExists(atPath: path) ? path : nil diff --git a/justfile b/justfile index f8a5be3..2bd1fa6 100644 --- a/justfile +++ b/justfile @@ -79,7 +79,7 @@ resign-all: sudo .build/release/ghosttile prepare --force "$app_path" done <<< "$app_paths" -# Build a Debug GhostTile.app and open it from DerivedData. +# Build a Debug GhostTile.app, point ./GhostTile.app at it, and open it. run: kill build-cli #!/usr/bin/env bash set -euo pipefail @@ -88,7 +88,10 @@ run: kill build-cli xcodegen generate --spec project.yml --project . xcodebuild -project GhostTile.xcodeproj -scheme GhostTileApp -configuration Debug build 2>&1 | xcbeautify app_path="$(xcodebuild -project GhostTile.xcodeproj -scheme GhostTileApp -configuration Debug -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/GhostTile.app" - open "$app_path" + rm -rf "{{app}}" + ln -s "$app_path" "{{app}}" + echo "Linked {{app}} -> $app_path" + open "{{app}}" # Build the dev variant and open it. run-dev: build-dev From 50106a049742007bf4b851f9e95bac55afa779fb Mon Sep 17 00:00:00 2001 From: hewigovens <360470+hewigovens@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:36:00 +0900 Subject: [PATCH 2/2] Handle updated and incompatible managed apps gracefully When an app update strips GhostTile's Mach-O patch, detect it and offer a one-click re-add across the GUI, status bar, and CLI; refresh the backup so a later restore can't overwrite the updated binary, and discard stale backups / orphaned dylibs instead of restoring the wrong version. Block App Store apps that declare com.apple.security.application-groups (macOS Tahoe's AMFI kills them once re-signed) with an explicit 'Manage Anyway' override that strips the identity/sandbox entitlements and runs the app unsandboxed. Risk-tinted prompts: blue Continue Anyway (warnings), orange Manage Anyway (unsandbox), red Remove (destructive); system-extension apps stay a hard block. Also: extract GhosthidePatch (file-identity cache + mmap), show the managed app version, bundle prepare flags into PrepareOptions, and run the sudo command in one Terminal window with a close hint. Bump version to 2.0.9 (26). --- Resources/Info.plist | 4 +- .../MainWindow/MainWindowView+Layout.swift | 6 +- .../MainWindow/ManagedAppCard.swift | 29 ++-- .../GhostTileApp/Overview/OverviewCard.swift | 61 ++++----- .../AppActions/AppActionHandler.swift | 128 +++++++++++++++--- .../Services/AppActions/AppOperations.swift | 75 +++++++--- .../AppActions/HideAppOperationResult.swift | 1 + .../GhostTileApp/Shared/AlertPresenter.swift | 25 +++- .../GhostTileApp/Shared/AppViewModel.swift | 8 ++ .../Shared/ManagedAppActions.swift | 13 ++ .../GhostTileApp/Shared/ManagedAppItem.swift | 85 ++++++++++-- .../Shared/SudoCommandSheet.swift | 4 +- .../StatusBar/StatusBarController.swift | 5 + .../StatusBar/StatusBarMenuBuilder.swift | 25 +++- Sources/GhostTileCore/AppCompatibility.swift | 34 +++-- Sources/GhostTileCore/AppManager.swift | 35 ++++- .../GhostTileCore/AppPreparationManager.swift | 46 +++++-- Sources/GhostTileCore/AppRestoreManager.swift | 23 +++- Sources/GhostTileCore/AppVersion.swift | 55 ++++++++ Sources/GhostTileCore/BuildInfo.swift | 8 +- Sources/GhostTileCore/GhostTileConfig.swift | 7 +- Sources/GhostTileCore/GhosthidePatch.swift | 44 ++++++ Sources/GhostTileCore/MachOEditor.swift | 3 +- Sources/GhostTileCore/ManagedAppRecord.swift | 49 ++++++- .../GhostTileCore/ManagedAppStateReader.swift | 14 +- Sources/GhostTileCore/PrepareOptions.swift | 17 +++ Sources/ghosttile/CLIHelpers.swift | 14 +- Sources/ghosttile/ManagePrepareCommands.swift | 41 +++++- Sources/ghosttile/QueryCommands.swift | 11 +- Sources/ghosttile/RestoreCommand.swift | 16 ++- .../AppCompatibilityTests.swift | 25 +++- Tests/GhostTileCoreTests/AppMock.swift | 60 ++++++++ .../AppRestoreManagerTests.swift | 88 ++++++++++++ Tests/GhostTileCoreTests/ConfigTests.swift | 11 +- .../GhostTileCoreTests/MachOEditorTests.swift | 10 +- Tests/GhostTileCoreTests/TestSupport.swift | 5 + VERSION | 4 +- project.yml | 4 +- releases/2.0.9.html | 6 + 39 files changed, 906 insertions(+), 193 deletions(-) create mode 100644 Sources/GhostTileCore/AppVersion.swift create mode 100644 Sources/GhostTileCore/GhosthidePatch.swift create mode 100644 Sources/GhostTileCore/PrepareOptions.swift create mode 100644 Tests/GhostTileCoreTests/AppMock.swift create mode 100644 Tests/GhostTileCoreTests/AppRestoreManagerTests.swift create mode 100644 releases/2.0.9.html diff --git a/Resources/Info.plist b/Resources/Info.plist index a402d04..caabe04 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.0.8 + 2.0.9 CFBundleVersion - 25 + 26 LSMinimumSystemVersion 15.0 LSUIElement diff --git a/Sources/GhostTileApp/MainWindow/MainWindowView+Layout.swift b/Sources/GhostTileApp/MainWindow/MainWindowView+Layout.swift index d5831ae..16cafec 100644 --- a/Sources/GhostTileApp/MainWindow/MainWindowView+Layout.swift +++ b/Sources/GhostTileApp/MainWindow/MainWindowView+Layout.swift @@ -74,11 +74,7 @@ extension MainWindowView { isLoading: appViewModel.loading.contains(app.id), actions: appViewModel, onPrimaryAction: { - if app.isRunning { - appViewModel.setDockVisibility(app, hidden: !app.isHiddenFromDock) - } else { - appViewModel.activateManagedApp(app) - } + appViewModel.perform(app.primaryAction, on: app) } ) } diff --git a/Sources/GhostTileApp/MainWindow/ManagedAppCard.swift b/Sources/GhostTileApp/MainWindow/ManagedAppCard.swift index e1f6428..65c05dd 100644 --- a/Sources/GhostTileApp/MainWindow/ManagedAppCard.swift +++ b/Sources/GhostTileApp/MainWindow/ManagedAppCard.swift @@ -57,22 +57,6 @@ struct ManagedAppCard: View { app.statusColor } - private var primaryActionTitle: String { - if !app.isRunning { - return "Launch" - } - - return app.isHiddenFromDock ? "Show" : "Hide" - } - - private var primaryActionIcon: String { - if !app.isRunning { - return "play.fill" - } - - return app.isHiddenFromDock ? "eye" : "eye.slash" - } - var body: some View { VStack(alignment: .leading, spacing: 12) { ZStack(alignment: .topLeading) { @@ -98,6 +82,17 @@ struct ManagedAppCard: View { .font(.system(size: 20, weight: .bold, design: .rounded)) .lineLimit(1) .minimumScaleFactor(0.78) + if app.requiresReAdd { + Text("Re-add required") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(statusColor) + .lineLimit(1) + } else if let versionText = app.versionText { + Text("v\(versionText)") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } } } } @@ -117,7 +112,7 @@ struct ManagedAppCard: View { Spacer() } else { Button(action: onPrimaryAction) { - Label(primaryActionTitle, systemImage: primaryActionIcon) + Label(app.primaryAction.title, systemImage: app.primaryAction.systemImage) } .buttonStyle(.borderedProminent) .controlSize(.small) diff --git a/Sources/GhostTileApp/Overview/OverviewCard.swift b/Sources/GhostTileApp/Overview/OverviewCard.swift index e5b42df..a4f6c74 100644 --- a/Sources/GhostTileApp/Overview/OverviewCard.swift +++ b/Sources/GhostTileApp/Overview/OverviewCard.swift @@ -94,12 +94,11 @@ struct OverviewCard: View { .onHover { hovering = $0 } .animation(.easeOut(duration: 0.14), value: hovering) .contextMenu { - if app.isRunning { - if app.isHiddenFromDock { - Button("Show in Dock", action: { actions.show(app) }) - } else { - Button("Hide from Dock", action: { actions.hide(app) }) - } + switch app.primaryAction { + case .reAdd, .showInDock, .hideFromDock: + Button(app.primaryAction.menuTitle) { actions.perform(app.primaryAction, on: app) } + case .launch: + EmptyView() } Button("Reveal in Finder", action: { actions.reveal(app) }) Divider() @@ -153,35 +152,33 @@ struct OverviewCard: View { private var actionButtons: some View { HStack(spacing: 6) { - if app.isRunning { - Button { - app.isHiddenFromDock ? actions.show(app) : actions.hide(app) - } label: { - Image(systemName: app.isHiddenFromDock ? "eye" : "eye.slash") - .font(.system(size: 11, weight: .semibold)) - } - .buttonStyle(.borderless) - .foregroundStyle(.secondary) - } + primaryActionButton - Button { - actions.reveal(app) - } label: { - Image(systemName: "folder") - .font(.system(size: 11, weight: .semibold)) - } - .buttonStyle(.borderless) - .foregroundStyle(.secondary) - - Button { - actions.remove(app) - } label: { - Image(systemName: "trash") - .font(.system(size: 11, weight: .semibold)) + iconButton("folder") { actions.reveal(app) } + iconButton("trash") { actions.remove(app) } + } + } + + /// Primary button shows only for actionable states; a not-running app (.launch) shows just reveal/remove. + @ViewBuilder + private var primaryActionButton: some View { + switch app.primaryAction { + case .reAdd, .showInDock, .hideFromDock: + iconButton(app.primaryAction.systemImage) { + actions.perform(app.primaryAction, on: app) } - .buttonStyle(.borderless) - .foregroundStyle(.secondary) + case .launch: + EmptyView() + } + } + + private func iconButton(_ systemImage: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: 11, weight: .semibold)) } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) } private var statusText: String { diff --git a/Sources/GhostTileApp/Services/AppActions/AppActionHandler.swift b/Sources/GhostTileApp/Services/AppActions/AppActionHandler.swift index e94a0f9..f61b261 100644 --- a/Sources/GhostTileApp/Services/AppActions/AppActionHandler.swift +++ b/Sources/GhostTileApp/Services/AppActions/AppActionHandler.swift @@ -13,10 +13,18 @@ final class AppActionHandler { } func hideRunningApp(_ app: ManagedAppItem) { - hideRunningApp(app, acceptWarnings: false) + manageApp(app) } - private func hideRunningApp(_ app: ManagedAppItem, acceptWarnings: Bool) { + func readdManagedApp(_ app: ManagedAppItem) { + manageApp(app, forcePrepare: true, options: PrepareOptions(refreshBackup: true)) + } + + private func manageApp( + _ app: ManagedAppItem, + forcePrepare: Bool = false, + options: PrepareOptions = .init() + ) { guard let viewModel, !loading.contains(app.id) else { return } if app.isSIPProtected { @@ -29,17 +37,28 @@ final class AppActionHandler { let info = app.appInfo let cli = viewModel.cliPath performAsync(for: app.id) { - try AppOperations.hideApp(info, cliPath: cli, acceptWarnings: acceptWarnings) + try AppOperations.hideApp(info, cliPath: cli, forcePrepare: forcePrepare, options: options) } onResult: { [weak self, weak viewModel] result in switch result { case .hidden: viewModel?.recordSponsorUse() case let .requiresSudo(command): viewModel?.sudoCommand = command - self?.loading.remove(info.bundleId) case let .requiresWarningConfirmation(warnings): Task { @MainActor [weak self] in - self?.confirmCompatibilityWarnings(for: app, warnings: warnings) + self?.confirmCompatibilityWarnings(for: app, warnings: warnings) { [weak self] in + var accepted = options + accepted.acceptWarnings = true + self?.manageApp(app, forcePrepare: forcePrepare, options: accepted) + } + } + case let .requiresUnsandboxConfirmation(reason): + Task { @MainActor [weak self] in + self?.confirmUnsandbox(for: app, reason: reason) { [weak self] in + var unsandboxed = options + unsandboxed.unsandbox = true + self?.manageApp(app, forcePrepare: forcePrepare, options: unsandboxed) + } } } } onError: { [weak self, weak viewModel] error in @@ -79,21 +98,45 @@ final class AppActionHandler { private func confirmCompatibilityWarnings( for app: ManagedAppItem, - warnings: [AppCompatibility.Warning] + warnings: [AppCompatibility.Warning], + onConfirm: @escaping @MainActor () -> Void + ) { + let confirmed = AlertPresenter.confirm( + "Hide \(app.name)?", + body: "May disable: \(warnings.map(\.impact).joined(separator: ", ")).", + style: .warning, + confirmButton: "Continue Anyway", + tint: .standard + ) + if confirmed { + onConfirm() + } + } + + private func confirmUnsandbox( + for app: ManagedAppItem, + reason: String, + onConfirm: @escaping @MainActor () -> Void ) { let confirmed = AlertPresenter.confirm( - "Hide-from-Dock may disable some \(app.name) features", - body: warnings.map { "• \($0.impact)" }.joined(separator: "\n"), + "Run \(app.name) unsandboxed?", + body: reason, style: .warning, - confirmButton: "Continue Anyway" + confirmButton: "Manage Anyway", + cancelButton: "Cancel", + tint: .caution ) if confirmed { - hideRunningApp(app, acceptWarnings: true) + onConfirm() } } func setDockVisibility(_ app: ManagedAppItem, hidden: Bool) { guard let viewModel, app.isRunning else { return } + if app.requiresReAdd { + viewModel.showError(message: "\(app.name) was updated. Re-add it before changing Dock visibility.") + return + } viewModel.dockVisibilityController.send(bundleId: app.id, hidden: hidden) viewModel.scheduleRefresh(after: 0.5) viewModel.recordSponsorUse() @@ -138,25 +181,43 @@ final class AppActionHandler { func removeApp(_ app: ManagedAppItem) { guard let viewModel, !loading.contains(app.id) else { return } + guard canRemove(app) else { return } + guard ensureAppManagementPermission() else { return } let info = app.appInfo let wasRunning = app.isRunning let cli = viewModel.cliPath - let refreshDelay: TimeInterval = wasRunning ? Self.postOperationRefreshDelay : 0 + let refreshDelay: TimeInterval = wasRunning && !app.requiresReAdd ? Self.postOperationRefreshDelay : 0 performAsync(for: app.id, refreshDelay: refreshDelay) { try AppOperations.removeApp(info, wasRunning: wasRunning) } onResult: { [weak viewModel] _ in viewModel?.recordSponsorUse() - } onError: { [weak viewModel] _ in - viewModel?.sudoCommand = ShellCommand.format( - executable: cli, - arguments: ["restore", info.bundleId], - requiresSudo: true - ) + } onError: { [weak self, weak viewModel] error in + if let gtError = error as? GhostTileError, case .appManagementDenied = gtError { + self?.presentAppManagementPermissionAlert() + } else { + viewModel?.sudoCommand = ShellCommand.format( + executable: cli, + arguments: ["restore", info.bundleId], + requiresSudo: true + ) + } } } + private func canRemove(_ app: ManagedAppItem) -> Bool { + guard app.requiresReAdd else { return true } + return AlertPresenter.confirm( + "Remove \(app.name)?", + body: "It was updated; the old backup won't be restored.", + style: .warning, + confirmButton: "Remove", + cancelButton: "Keep", + tint: .destructive + ) + } + func hideByURL(_ url: URL) { guard let viewModel, let bundle = Bundle(url: url), @@ -164,9 +225,38 @@ final class AppActionHandler { let execURL = bundle.executableURL else { return } - if Config.load().hidden[bundleId] != nil { return } - let appPath = url.path + if let hiddenApp = Config.load().hidden[bundleId] { + let needsReAdd = !GhosthidePatch.isApplied(to: execURL.path) + if let existing = viewModel.apps.first(where: { $0.id == bundleId }) { + if existing.requiresReAdd || needsReAdd { + readdManagedApp(existing) + } + return + } + + guard needsReAdd else { + return + } + + let icon = NSWorkspace.shared.icon(forFile: appPath) + let record = ManagedAppRecord( + bundleId: bundleId, + name: hiddenApp.name, + appPath: appPath, + binaryPath: execURL.path, + managed: true, + running: false, + hiddenFromDock: true, + pid: nil, + isSIPProtected: AppManager.isSIPProtected(appPath), + categoryIdentifier: bundle.infoDictionary?["LSApplicationCategoryType"] as? String, + requiresReAdd: true + ) + readdManagedApp(ManagedAppItem(record: record, icon: icon, category: .other)) + return + } + if AppManager.isSIPProtected(appPath) || AppManager.isAppleFirstParty(appPath) { viewModel .showError( diff --git a/Sources/GhostTileApp/Services/AppActions/AppOperations.swift b/Sources/GhostTileApp/Services/AppActions/AppOperations.swift index aff713b..acfb43f 100644 --- a/Sources/GhostTileApp/Services/AppActions/AppOperations.swift +++ b/Sources/GhostTileApp/Services/AppActions/AppOperations.swift @@ -4,7 +4,8 @@ enum AppOperations { static func hideApp( _ app: AppInfo, cliPath: String, - acceptWarnings: Bool = false + forcePrepare: Bool = false, + options: PrepareOptions = .init() ) throws -> HideAppOperationResult { Log.info("Hiding app: \(app.name) (\(app.bundleId))") @@ -12,29 +13,16 @@ enum AppOperations { throw GhostTileError("\(app.name) is an Apple system app and cannot be hidden.") } - switch try AppManager.assessCompatibility(app) { - case .compatible: - break - case let .unsupported(reason): - throw GhostTileError(reason) - case let .warnings(warnings): - if !acceptWarnings { - return .requiresWarningConfirmation(warnings) - } + if let blocked = try compatibilityGate(app, options: options) { + return blocked } - if try AppManager.needsSudo(app) { - var arguments = ["manage", app.bundleId] - if acceptWarnings { arguments.append("--accept-warnings") } - return .requiresSudo(command: ShellCommand.format( - executable: cliPath, - arguments: arguments, - requiresSudo: true - )) + if try AppManager.needsSudo(app, forcePrepare: forcePrepare) { + return sudoCommand(for: app, cliPath: cliPath, forcePrepare: forcePrepare, options: options) } - if try AppManager.needsPreparation(app) { - try AppManager.prepare(app, cliPath: cliPath, acceptWarnings: acceptWarnings) + if try forcePrepare || AppManager.needsPreparation(app) { + try AppManager.prepare(app, cliPath: cliPath, options: options) } try AppManager.quit(app.bundleId) @@ -44,12 +32,57 @@ enum AppOperations { return .hidden } + /// nil = good to proceed; non-nil = a confirmation the caller must surface; throws if unsupported. + private static func compatibilityGate( + _ app: AppInfo, + options: PrepareOptions + ) throws -> HideAppOperationResult? { + switch try AppManager.assessCompatibility(app) { + case .compatible: + return nil + case let .unsupported(reason): + throw GhostTileError(reason) + case let .requiresUnsandbox(reason): + return options.unsandbox ? nil : .requiresUnsandboxConfirmation(reason: reason) + case let .warnings(warnings): + return options.acceptWarnings ? nil : .requiresWarningConfirmation(warnings) + } + } + + private static func sudoCommand( + for app: AppInfo, + cliPath: String, + forcePrepare: Bool, + options: PrepareOptions + ) -> HideAppOperationResult { + // Pass the path, not the bundle ID: the CLI resolver can't find a non-running app by ID. + var arguments = ["manage", app.appPath] + if forcePrepare { arguments.append("--force-prepare") } + if options.acceptWarnings { arguments.append("--accept-warnings") } + if options.unsandbox { arguments.append("--unsandbox") } + return .requiresSudo(command: ShellCommand.format( + executable: cliPath, + arguments: arguments, + requiresSudo: true + )) + } + static func removeApp(_ app: AppInfo, wasRunning: Bool) throws { + guard GhosthidePatch.isApplied(to: app.binaryPath) else { + AppManager.discardInjection(app.bundleId, appPath: app.appPath) + try Config.removeHidden(app.bundleId) + return + } + if wasRunning { try AppManager.quit(app.bundleId) } - try AppManager.restoreBinary(app.bundleId, binaryPath: app.binaryPath, appPath: app.appPath) + try AppManager.restoreBinary( + app.bundleId, + binaryPath: app.binaryPath, + appPath: app.appPath + ) try Config.removeHidden(app.bundleId) if wasRunning { diff --git a/Sources/GhostTileApp/Services/AppActions/HideAppOperationResult.swift b/Sources/GhostTileApp/Services/AppActions/HideAppOperationResult.swift index b8f5cf0..e186750 100644 --- a/Sources/GhostTileApp/Services/AppActions/HideAppOperationResult.swift +++ b/Sources/GhostTileApp/Services/AppActions/HideAppOperationResult.swift @@ -4,4 +4,5 @@ enum HideAppOperationResult { case hidden case requiresSudo(command: String) case requiresWarningConfirmation([AppCompatibility.Warning]) + case requiresUnsandboxConfirmation(reason: String) } diff --git a/Sources/GhostTileApp/Shared/AlertPresenter.swift b/Sources/GhostTileApp/Shared/AlertPresenter.swift index 4ba1d62..090bc55 100644 --- a/Sources/GhostTileApp/Shared/AlertPresenter.swift +++ b/Sources/GhostTileApp/Shared/AlertPresenter.swift @@ -2,6 +2,11 @@ import AppKit @MainActor enum AlertPresenter { + /// Risk tint for the confirm button: standard (blue), caution (orange), destructive (red). + enum Tint { + case standard, caution, destructive + } + /// Show a modal NSAlert with a confirm + cancel button. Returns true if the user picked confirm. @discardableResult static func confirm( @@ -9,14 +14,28 @@ enum AlertPresenter { body: String, style: NSAlert.Style = .informational, confirmButton: String, - cancelButton: String = "Cancel" + cancelButton: String = "Cancel", + tint: Tint = .standard ) -> Bool { let alert = NSAlert() alert.messageText = title alert.informativeText = body alert.alertStyle = style - alert.addButton(withTitle: confirmButton) - alert.addButton(withTitle: cancelButton) + let confirm = alert.addButton(withTitle: confirmButton) + let cancel = alert.addButton(withTitle: cancelButton) + switch tint { + case .standard: + break + case .caution: + confirm.bezelColor = .systemOrange + case .destructive: + confirm.hasDestructiveAction = true + } + if tint != .standard { + // Make Cancel the default (Enter) for risky actions — safer, and lets the confirm tint show. + confirm.keyEquivalent = "" + cancel.keyEquivalent = "\r" + } return alert.runModal() == .alertFirstButtonReturn } } diff --git a/Sources/GhostTileApp/Shared/AppViewModel.swift b/Sources/GhostTileApp/Shared/AppViewModel.swift index 7e5282e..28924f7 100644 --- a/Sources/GhostTileApp/Shared/AppViewModel.swift +++ b/Sources/GhostTileApp/Shared/AppViewModel.swift @@ -198,6 +198,14 @@ class AppViewModel: ObservableObject, ManagedAppActions { actionHandler.removeApp(app) } + func readd(_ app: ManagedAppItem) { + actionHandler.readdManagedApp(app) + } + + func activate(_ app: ManagedAppItem) { + actionHandler.activateManagedApp(app) + } + // MARK: - Additional Actions func hideRunningApp(_ app: ManagedAppItem) { diff --git a/Sources/GhostTileApp/Shared/ManagedAppActions.swift b/Sources/GhostTileApp/Shared/ManagedAppActions.swift index ead454d..1d15554 100644 --- a/Sources/GhostTileApp/Shared/ManagedAppActions.swift +++ b/Sources/GhostTileApp/Shared/ManagedAppActions.swift @@ -5,4 +5,17 @@ protocol ManagedAppActions: AnyObject { func hide(_ app: ManagedAppItem) func reveal(_ app: ManagedAppItem) func remove(_ app: ManagedAppItem) + func readd(_ app: ManagedAppItem) + func activate(_ app: ManagedAppItem) +} + +extension ManagedAppActions { + func perform(_ action: ManagedAppItem.PrimaryAction, on app: ManagedAppItem) { + switch action { + case .reAdd: readd(app) + case .showInDock: show(app) + case .hideFromDock: hide(app) + case .launch: activate(app) + } + } } diff --git a/Sources/GhostTileApp/Shared/ManagedAppItem.swift b/Sources/GhostTileApp/Shared/ManagedAppItem.swift index 2dfccc5..53cf141 100644 --- a/Sources/GhostTileApp/Shared/ManagedAppItem.swift +++ b/Sources/GhostTileApp/Shared/ManagedAppItem.swift @@ -40,18 +40,37 @@ struct ManagedAppItem: Identifiable { record.hiddenFromDock } + var requiresReAdd: Bool { + record.requiresReAdd + } + + var displayState: ManagedAppRecord.DisplayState { + record.displayState + } + + var versionText: String? { + record.version?.displayString + } + var appInfo: AppInfo { - AppInfo(bundleId: id, name: name, appPath: appPath, binaryPath: binaryPath) + AppInfo( + bundleId: id, + name: name, + appPath: appPath, + binaryPath: binaryPath + ) } var statusText: String { - if !isRunning { return "Not Running" } - return isHiddenFromDock ? "Hidden" : "Visible" + displayState.label } var statusColor: Color { - if !isRunning { return .secondary } - return isHiddenFromDock ? .orange : .green + switch displayState { + case .requiresReAdd: .red + case let .running(_, hiddenFromDock): hiddenFromDock ? .orange : .green + case .notRunning: .secondary + } } func menuItem(icon: NSImage) -> NSMenuItem { @@ -72,17 +91,17 @@ struct ManagedAppItem: Identifiable { showAction: Selector, activateAction: Selector ) -> [NSMenuItem] { - let hide = NSMenuItem(title: "Hide from Dock", action: hideAction, keyEquivalent: "") + let hide = NSMenuItem(title: PrimaryAction.hideFromDock.menuTitle, action: hideAction, keyEquivalent: "") hide.target = target hide.representedObject = id - hide.image = NSImage(systemSymbolName: "eye.slash", accessibilityDescription: nil) - hide.isEnabled = isRunning && !isHiddenFromDock + hide.image = NSImage(systemSymbolName: PrimaryAction.hideFromDock.systemImage, accessibilityDescription: nil) + hide.isEnabled = isRunning && !isHiddenFromDock && !requiresReAdd - let show = NSMenuItem(title: "Show in Dock", action: showAction, keyEquivalent: "") + let show = NSMenuItem(title: PrimaryAction.showInDock.menuTitle, action: showAction, keyEquivalent: "") show.target = target show.representedObject = id - show.image = NSImage(systemSymbolName: "eye", accessibilityDescription: nil) - show.isEnabled = isRunning && isHiddenFromDock + show.image = NSImage(systemSymbolName: PrimaryAction.showInDock.systemImage, accessibilityDescription: nil) + show.isEnabled = isRunning && isHiddenFromDock && !requiresReAdd let activate = NSMenuItem(title: isRunning ? "Activate" : "Launch", action: activateAction, keyEquivalent: "") activate.target = target @@ -103,6 +122,50 @@ struct ManagedAppItem: Identifiable { } } +extension ManagedAppItem { + /// The primary action a card surfaces for an app's state; centralizes the branching the cards used to repeat. + enum PrimaryAction { + case reAdd + case showInDock + case hideFromDock + case launch + + var title: String { + switch self { + case .reAdd: "Re-add" + case .showInDock: "Show" + case .hideFromDock: "Hide" + case .launch: "Launch" + } + } + + var systemImage: String { + switch self { + case .reAdd: "arrow.triangle.2.circlepath" + case .showInDock: "eye" + case .hideFromDock: "eye.slash" + case .launch: "play.fill" + } + } + + /// Full label for menu items (context menus, status bar submenu). + var menuTitle: String { + switch self { + case .reAdd: "Re-add to GhostTile" + case .showInDock: "Show in Dock" + case .hideFromDock: "Hide from Dock" + case .launch: "Launch" + } + } + } + + var primaryAction: PrimaryAction { + if requiresReAdd { return .reAdd } + if !isRunning { return .launch } + return isHiddenFromDock ? .showInDock : .hideFromDock + } +} + extension [ManagedAppItem] { func filtered(by query: String) -> [ManagedAppItem] { guard !query.isEmpty else { return self } diff --git a/Sources/GhostTileApp/Shared/SudoCommandSheet.swift b/Sources/GhostTileApp/Shared/SudoCommandSheet.swift index 48b42d7..0d1fddf 100644 --- a/Sources/GhostTileApp/Shared/SudoCommandSheet.swift +++ b/Sources/GhostTileApp/Shared/SudoCommandSheet.swift @@ -57,7 +57,9 @@ struct SudoCommandSheet: View { } private func openInTerminal(_ cmd: String) { - let escaped = cmd.escapedForAppleScript + // `do script` before `activate` avoids an empty launch window; we don't auto-close since Terminal's window control is unreliable. + let hint = "echo ''; echo 'GhostTile finished. You can quit Terminal (Cmd-Q).'" + let escaped = "\(cmd); \(hint); exit".escapedForAppleScript let script = """ tell application "Terminal" do script "\(escaped)" diff --git a/Sources/GhostTileApp/StatusBar/StatusBarController.swift b/Sources/GhostTileApp/StatusBar/StatusBarController.swift index eafd777..561595c 100644 --- a/Sources/GhostTileApp/StatusBar/StatusBarController.swift +++ b/Sources/GhostTileApp/StatusBar/StatusBarController.swift @@ -81,6 +81,11 @@ class StatusBarController: NSObject, NSMenuDelegate { viewModel.removeApp(app) } + @objc func readdManagedApp(_ sender: NSMenuItem) { + guard let app = managedApp(from: sender) else { return } + viewModel.readd(app) + } + func managedApp(from sender: NSMenuItem) -> ManagedAppItem? { guard let bundleId = sender.representedObject as? String else { return nil } return viewModel.managedApp(bundleId: bundleId) diff --git a/Sources/GhostTileApp/StatusBar/StatusBarMenuBuilder.swift b/Sources/GhostTileApp/StatusBar/StatusBarMenuBuilder.swift index 9b0ad3c..3d6d876 100644 --- a/Sources/GhostTileApp/StatusBar/StatusBarMenuBuilder.swift +++ b/Sources/GhostTileApp/StatusBar/StatusBarMenuBuilder.swift @@ -54,14 +54,31 @@ struct StatusBarMenuBuilder { private func managedAppSubmenu(for app: ManagedAppItem) -> NSMenu { let submenu = NSMenu(title: app.name) - let stateText = app.isRunning - ? (app.isHiddenFromDock ? "Running Hidden" : "Running Visible") - : "Not Running" - let stateItem = NSMenuItem(title: stateText, action: nil, keyEquivalent: "") + let stateItem = NSMenuItem(title: app.displayState.detailedLabel, action: nil, keyEquivalent: "") stateItem.isEnabled = false submenu.addItem(stateItem) + + if let versionText = app.versionText { + let versionItem = NSMenuItem(title: "Version \(versionText)", action: nil, keyEquivalent: "") + versionItem.isEnabled = false + submenu.addItem(versionItem) + } + submenu.addItem(.separator()) + if app.requiresReAdd { + let readd = makeItem( + ManagedAppItem.PrimaryAction.reAdd.menuTitle, + action: #selector(StatusBarController.readdManagedApp(_:)) + ) + readd.representedObject = app.id + readd.image = NSImage( + systemSymbolName: ManagedAppItem.PrimaryAction.reAdd.systemImage, + accessibilityDescription: nil + ) + submenu.addItem(readd) + } + for item in app.visibilityMenuItems( target: controller, hideAction: #selector(StatusBarController.hideManagedApp(_:)), diff --git a/Sources/GhostTileCore/AppCompatibility.swift b/Sources/GhostTileCore/AppCompatibility.swift index 6e98dab..08820c2 100644 --- a/Sources/GhostTileCore/AppCompatibility.swift +++ b/Sources/GhostTileCore/AppCompatibility.swift @@ -4,6 +4,8 @@ public enum AppCompatibility: Sendable, Equatable { case compatible case warnings([Warning]) case unsupported(reason: String) + /// Blocked by default, but the user can manage it anyway by running it unsandboxed. + case requiresUnsandbox(reason: String) public struct Warning: Sendable, Equatable { public let entitlement: String @@ -24,8 +26,14 @@ public enum AppCompatibility: Sendable, Equatable { if let key = HardFailEntitlement.firstMatch(in: entitlements) { return .unsupported( + reason: "\(app.name) needs '\(key)', which breaks when modified." + ) + } + + if entitlements[appGroupsKey] != nil { + return .requiresUnsandbox( reason: - "\(app.name) declares '\(key)', which only works under its original signature. Modifying it would break that capability." + "\(app.name) uses app groups, so GhostTile must run it without the sandbox — less protection, and it may not see its existing data. Prefer a non-App-Store build if one exists." ) } @@ -34,17 +42,28 @@ public enum AppCompatibility: Sendable, Equatable { return warnings.isEmpty ? .compatible : .warnings(warnings) } - /// Keys that trigger AMFI launch kill under ad-hoc resign — must be stripped before codesign. - public static func entitlementsToStrip() -> Set { - Set(WarnEntitlement.tccKeys.map(\.key)) + static let appGroupsKey = "com.apple.security.application-groups" + + /// Identity + sandbox entitlements stripped only for the manage-anyway path (clears AMFI + sandbox). + private static let unsandboxKeys: Set = [ + appGroupsKey, + "com.apple.application-identifier", + "com.apple.developer.team-identifier", + "com.apple.security.app-sandbox", + ] + + /// Keys that trigger AMFI launch kill under ad-hoc resign; `unsandbox` also strips identity/sandbox keys for manage-anyway. + public static func entitlementsToStrip(unsandbox: Bool = false) -> Set { + var keys = Set(WarnEntitlement.tccKeys.map(\.key)) + if unsandbox { keys.formUnion(unsandboxKeys) } + return keys } private static func bundleStructureBlocker(for app: AppInfo) -> String? { let sysExtDir = (app.appPath as NSString) .appendingPathComponent("Contents/Library/SystemExtensions") if FileManager.default.fileExists(atPath: sysExtDir) { - return - "\(app.name) bundles a system extension. Modifying it would prevent the system extension from loading and break the hardware or service it provides." + return "\(app.name) bundles a system extension and can't be modified safely." } return nil } @@ -97,10 +116,9 @@ public enum AppCompatibility: Sendable, Equatable { ("com.apple.security.automation.apple-events", "AppleScript / cross-app automation"), ] - /// Team-id-bound keys — preserved in the binary but warned about, since they silently fail without a team id. + /// Team-id-bound keys — preserved but warned about, since they silently fail without a team id (application-groups is a separate blocker). static let teamIdBoundKeys: [(key: String, impact: String)] = [ ("com.apple.security.app-sandbox", "App Sandbox"), - ("com.apple.security.application-groups", "Shared app group containers"), ("keychain-access-groups", "Shared keychain access"), ] diff --git a/Sources/GhostTileCore/AppManager.swift b/Sources/GhostTileCore/AppManager.swift index ea7e308..1b8e056 100644 --- a/Sources/GhostTileCore/AppManager.swift +++ b/Sources/GhostTileCore/AppManager.swift @@ -7,7 +7,12 @@ public struct AppInfo { public let appPath: String public let binaryPath: String - public init(bundleId: String, name: String, appPath: String, binaryPath: String) { + public init( + bundleId: String, + name: String, + appPath: String, + binaryPath: String + ) { self.bundleId = bundleId self.name = name self.appPath = appPath @@ -44,12 +49,16 @@ public enum AppManager { .needsPreparation(app) } - public static func needsSudo(_ app: AppInfo) throws -> Bool { - try AppPreparationManager.needsSudo(app) + public static func needsSudo(_ app: AppInfo, forcePrepare: Bool = false) throws -> Bool { + try AppPreparationManager.needsSudo(app, forcePrepare: forcePrepare) } - public static func prepare(_ app: AppInfo, cliPath: String = "ghosttile", acceptWarnings: Bool = false) throws { - try AppPreparationManager.prepare(app, cliPath: cliPath, acceptWarnings: acceptWarnings) + public static func prepare( + _ app: AppInfo, + cliPath: String = "ghosttile", + options: PrepareOptions = .init() + ) throws { + try AppPreparationManager.prepare(app, cliPath: cliPath, options: options) } public static func extractEntitlements(_ binaryPath: String) throws -> [String: Any] { @@ -62,8 +71,20 @@ public enum AppManager { } /// Restore - public static func restoreBinary(_ bundleId: String, binaryPath: String, appPath: String) throws { - try AppRestoreManager.restoreBinary(bundleId, binaryPath: binaryPath, appPath: appPath) + public static func restoreBinary( + _ bundleId: String, + binaryPath: String, + appPath: String + ) throws { + try AppRestoreManager.restoreBinary( + bundleId, + binaryPath: binaryPath, + appPath: appPath + ) + } + + public static func discardInjection(_ bundleId: String, appPath: String) { + AppRestoreManager.discardInjection(bundleId, appPath: appPath) } /// Running app lookup diff --git a/Sources/GhostTileCore/AppPreparationManager.swift b/Sources/GhostTileCore/AppPreparationManager.swift index 59c97ea..77d7ca1 100644 --- a/Sources/GhostTileCore/AppPreparationManager.swift +++ b/Sources/GhostTileCore/AppPreparationManager.swift @@ -27,29 +27,47 @@ enum AppPreparationManager { return needs } - static func needsSudo(_ app: AppInfo) throws -> Bool { - guard try needsPreparation(app) else { return false } + static func needsSudo(_ app: AppInfo, forcePrepare: Bool = false) throws -> Bool { + guard try forcePrepare || needsPreparation(app) else { return false } return !FileManager.default.isWritableFile(atPath: app.binaryPath) } - static func backupBinary(_ app: AppInfo) throws { + static func backupBinary(_ app: AppInfo, refreshExisting: Bool = false) throws { let directory = FileOperations.backupPath(for: app.bundleId) let destination = "\(directory)/binary" + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + if FileManager.default.fileExists(atPath: destination) { - Log.info("Backup already exists for \(app.name), skipping") + guard refreshExisting else { + Log.info("Backup already exists for \(app.name), skipping") + return + } + + let temporaryBackup = "\(directory)/binary.\(UUID().uuidString).tmp" + defer { try? FileManager.default.removeItem(atPath: temporaryBackup) } + try FileManager.default.copyItem(atPath: app.binaryPath, toPath: temporaryBackup) + // Atomic swap so a failure can't leave the destination missing. + _ = try FileManager.default.replaceItemAt( + URL(fileURLWithPath: destination), + withItemAt: URL(fileURLWithPath: temporaryBackup) + ) + Log.info("Refreshed binary backup for \(app.name) at \(destination)") return } - try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) try FileManager.default.copyItem(atPath: app.binaryPath, toPath: destination) Log.info("Backed up binary for \(app.name) to \(destination)") } - static func prepare(_ app: AppInfo, cliPath: String = "ghosttile", acceptWarnings: Bool = false) throws { + static func prepare( + _ app: AppInfo, + cliPath: String = "ghosttile", + options: PrepareOptions = .init() + ) throws { Log.info("Preparing \(app.name) (\(app.bundleId)) at \(app.appPath)") - try backupBinary(app) + try backupBinary(app, refreshExisting: options.refreshBackup) let helperSourcePath = try Dylib.ensureDylib() let helperInstallPath = Dylib.bundleInstallPath(forAppPath: app.appPath) @@ -57,9 +75,9 @@ enum AppPreparationManager { try FileOperations.createDirectory(atPath: helperDir) try FileOperations.replaceFile(from: helperSourcePath, to: helperInstallPath) - // Preserve original entitlements; strip TCC keys (AMFI launch kill) and add CS overrides. + // Strip TCC keys (AMFI launch kill) plus, when unsandboxing, the identity/sandbox keys. var entitlements = try extractEntitlements(app.binaryPath) - for key in AppCompatibility.entitlementsToStrip() { + for key in AppCompatibility.entitlementsToStrip(unsandbox: options.unsandbox) { entitlements.removeValue(forKey: key) } entitlements["com.apple.security.cs.allow-dyld-environment-variables"] = true @@ -90,15 +108,17 @@ enum AppPreparationManager { try FileOperations.codesign(arguments: ["--force", "--sign", "-", helperInstallPath]) + // Unsandboxing needs nested code re-signed too (--deep), else Apple-signed frameworks won't load. + var bundleArguments = ["--force", "--sign", "-", "--preserve-metadata=entitlements", app.appPath] + if options.unsandbox { bundleArguments.insert("--deep", at: 1) } + do { - try FileOperations.codesign(arguments: [ - "--force", "--sign", "-", "--preserve-metadata=entitlements", app.appPath, - ]) + try FileOperations.codesign(arguments: bundleArguments) Log.info("Re-signed bundle for \(app.name)") } catch { Log.error("Failed to re-sign bundle for \(app.name): \(error)") var arguments = ["manage", app.bundleId] - if acceptWarnings { arguments.append("--accept-warnings") } + if options.acceptWarnings { arguments.append("--accept-warnings") } throw GhostTileError( "\(app.name) requires a manual step. Run in Terminal: \(ShellCommand.format(executable: cliPath, arguments: arguments, requiresSudo: true))" ) diff --git a/Sources/GhostTileCore/AppRestoreManager.swift b/Sources/GhostTileCore/AppRestoreManager.swift index f8d6989..400baa5 100644 --- a/Sources/GhostTileCore/AppRestoreManager.swift +++ b/Sources/GhostTileCore/AppRestoreManager.swift @@ -1,13 +1,23 @@ import Foundation enum AppRestoreManager { - static func restoreBinary(_ bundleId: String, binaryPath: String, appPath: String) throws { + static func restoreBinary( + _ bundleId: String, + binaryPath: String, + appPath: String + ) throws { let source = "\(FileOperations.backupPath(for: bundleId))/binary" guard FileManager.default.fileExists(atPath: source) else { Log.info("No backup found for \(bundleId), skipping restore") return } + guard GhosthidePatch.isApplied(to: binaryPath) else { + Log.info("No GhostTile patch found for \(bundleId), skipping restore") + discardInjection(bundleId, appPath: appPath) + return + } + Log.info("Restoring original binary for \(bundleId)") try FileOperations.replaceFile(from: source, to: binaryPath) @@ -24,4 +34,15 @@ enum AppRestoreManager { try? FileManager.default.removeItem(atPath: FileOperations.backupPath(for: bundleId)) Log.info("Removed backup for \(bundleId)") } + + static func discardBackup(_ bundleId: String) { + try? FileManager.default.removeItem(atPath: FileOperations.backupPath(for: bundleId)) + Log.info("Discarded backup for \(bundleId)") + } + + /// Forget an updated app: remove the orphaned dylib and drop the backup (no re-sign). + static func discardInjection(_ bundleId: String, appPath: String) { + try? FileOperations.removeFile(atPath: Dylib.bundleInstallPath(forAppPath: appPath)) + discardBackup(bundleId) + } } diff --git a/Sources/GhostTileCore/AppVersion.swift b/Sources/GhostTileCore/AppVersion.swift new file mode 100644 index 0000000..0cc68b1 --- /dev/null +++ b/Sources/GhostTileCore/AppVersion.swift @@ -0,0 +1,55 @@ +import Foundation + +public struct AppVersion: Codable, Equatable, Sendable { + public let shortVersion: String? + public let build: String? + + public init(shortVersion: String? = nil, build: String? = nil) { + self.shortVersion = Self.normalized(shortVersion) + self.build = Self.normalized(build) + } + + public init?(bundle: Bundle) { + self.init( + shortVersion: Self.infoValue( + bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") + ), + build: Self.infoValue(bundle.object(forInfoDictionaryKey: "CFBundleVersion")) + ) + + if shortVersion == nil, build == nil { + return nil + } + } + + public var displayString: String { + switch (shortVersion, build) { + case let (shortVersion?, build?): + "\(shortVersion) (\(build))" + case let (shortVersion?, nil): + shortVersion + case let (nil, build?): + "build \(build)" + case (nil, nil): + "unknown" + } + } + + private static func infoValue(_ value: Any?) -> String? { + if let string = value as? String { + return normalized(string) + } + + if let number = value as? NSNumber { + return normalized(number.stringValue) + } + + return nil + } + + private static func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Sources/GhostTileCore/BuildInfo.swift b/Sources/GhostTileCore/BuildInfo.swift index 7a59414..c60c628 100644 --- a/Sources/GhostTileCore/BuildInfo.swift +++ b/Sources/GhostTileCore/BuildInfo.swift @@ -1,10 +1,10 @@ public enum BuildInfo { - public static let version = "2.0.8" - public static let build = "25" + public static let version = "2.0.9" + public static let build = "26" public static let displayVersion = "\(version) (\(build))" - public static let cliVersion = "2.0.1" - public static let cliBuild = "18" + public static let cliVersion = "2.0.3" + public static let cliBuild = "20" public static let cliDisplayVersion = "\(cliVersion) (\(cliBuild))" } diff --git a/Sources/GhostTileCore/GhostTileConfig.swift b/Sources/GhostTileCore/GhostTileConfig.swift index 5e84268..4413779 100644 --- a/Sources/GhostTileCore/GhostTileConfig.swift +++ b/Sources/GhostTileCore/GhostTileConfig.swift @@ -6,7 +6,12 @@ public struct HiddenApp: Codable { public let binaryPath: String public var prepared: Bool - public init(name: String, appPath: String, binaryPath: String, prepared: Bool) { + public init( + name: String, + appPath: String, + binaryPath: String, + prepared: Bool + ) { self.name = name self.appPath = appPath self.binaryPath = binaryPath diff --git a/Sources/GhostTileCore/GhosthidePatch.swift b/Sources/GhostTileCore/GhosthidePatch.swift new file mode 100644 index 0000000..cce8b21 --- /dev/null +++ b/Sources/GhostTileCore/GhosthidePatch.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Whether a binary has GhostTile's ghosthide load command, cached by file identity (size + mtime). +public enum GhosthidePatch { + private struct Entry { + let size: Int + let modified: TimeInterval + let isApplied: Bool + } + + private static let lock = NSLock() + private static var entries: [String: Entry] = [:] + + public static func isApplied(to binaryPath: String) -> Bool { + let identity = fileIdentity(binaryPath) + + if let identity { + lock.lock() + let cached = entries[binaryPath] + lock.unlock() + if let cached, cached.size == identity.size, cached.modified == identity.modified { + return cached.isApplied + } + } + + let isApplied = (try? MachOEditor.hasGhosthideLoadCommand(in: binaryPath)) ?? false + + if let identity { + lock.lock() + entries[binaryPath] = Entry(size: identity.size, modified: identity.modified, isApplied: isApplied) + lock.unlock() + } + + return isApplied + } + + private static func fileIdentity(_ path: String) -> (size: Int, modified: TimeInterval)? { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let size = attributes[.size] as? Int, + let modified = attributes[.modificationDate] as? Date + else { return nil } + return (size, modified.timeIntervalSinceReferenceDate) + } +} diff --git a/Sources/GhostTileCore/MachOEditor.swift b/Sources/GhostTileCore/MachOEditor.swift index 79fba33..5751afd 100644 --- a/Sources/GhostTileCore/MachOEditor.swift +++ b/Sources/GhostTileCore/MachOEditor.swift @@ -23,7 +23,8 @@ enum MachOEditor { } static func hasGhosthideLoadCommand(in binaryPath: String) throws -> Bool { - let data = try Data(contentsOf: URL(fileURLWithPath: binaryPath)) + // Map, don't read: the check only touches header pages, avoiding a full load of large binaries. + let data = try Data(contentsOf: URL(fileURLWithPath: binaryPath), options: .mappedIfSafe) return try slices(in: data).contains { try sliceHasGhosthideLoadCommand(data, slice: $0) } } diff --git a/Sources/GhostTileCore/ManagedAppRecord.swift b/Sources/GhostTileCore/ManagedAppRecord.swift index c484473..a2f5672 100644 --- a/Sources/GhostTileCore/ManagedAppRecord.swift +++ b/Sources/GhostTileCore/ManagedAppRecord.swift @@ -12,11 +12,54 @@ public struct ManagedAppRecord: Identifiable, Encodable, Sendable { public let pid: pid_t? public let isSIPProtected: Bool public let categoryIdentifier: String? + public let requiresReAdd: Bool + public let version: AppVersion? public var id: String { bundleId } + /// Single source of truth for a managed app's presented state and its labels (CLI, status bar, UI). + public enum DisplayState: Equatable { + case requiresReAdd + case running(pid: pid_t?, hiddenFromDock: Bool) + case notRunning + + /// Compact status label for cards and pills. + public var label: String { + switch self { + case .requiresReAdd: "Updated" + case let .running(_, hiddenFromDock): hiddenFromDock ? "Hidden" : "Visible" + case .notRunning: "Not Running" + } + } + + /// Verbose status label for menus. + public var detailedLabel: String { + switch self { + case .requiresReAdd: "Updated - Re-add Required" + case let .running(_, hiddenFromDock): hiddenFromDock ? "Running Hidden" : "Running Visible" + case .notRunning: "Not Running" + } + } + + /// Lowercase status text for CLI output. + public var cliStatus: String { + switch self { + case .requiresReAdd: "updated, re-add required" + case let .running(pid, hiddenFromDock): + "\(pid.map { "pid \($0)" } ?? "running"), \(hiddenFromDock ? "hidden" : "visible")" + case .notRunning: "not running" + } + } + } + + public var displayState: DisplayState { + if requiresReAdd { return .requiresReAdd } + guard running else { return .notRunning } + return .running(pid: pid, hiddenFromDock: hiddenFromDock) + } + public init( bundleId: String, name: String, @@ -27,7 +70,9 @@ public struct ManagedAppRecord: Identifiable, Encodable, Sendable { hiddenFromDock: Bool, pid: pid_t?, isSIPProtected: Bool, - categoryIdentifier: String? + categoryIdentifier: String?, + requiresReAdd: Bool = false, + version: AppVersion? = nil ) { self.bundleId = bundleId self.name = name @@ -39,5 +84,7 @@ public struct ManagedAppRecord: Identifiable, Encodable, Sendable { self.pid = pid self.isSIPProtected = isSIPProtected self.categoryIdentifier = categoryIdentifier + self.requiresReAdd = requiresReAdd + self.version = version } } diff --git a/Sources/GhostTileCore/ManagedAppStateReader.swift b/Sources/GhostTileCore/ManagedAppStateReader.swift index 8b201e4..332d34f 100644 --- a/Sources/GhostTileCore/ManagedAppStateReader.swift +++ b/Sources/GhostTileCore/ManagedAppStateReader.swift @@ -22,35 +22,41 @@ public enum ManagedAppStateReader { else { return nil } let appPath = bundleURL.path + let isManaged = config.hidden[bundleId] != nil return ManagedAppRecord( bundleId: bundleId, name: app.localizedName ?? bundleId, appPath: appPath, binaryPath: executableURL.path, - managed: config.hidden[bundleId] != nil, + managed: isManaged, running: true, hiddenFromDock: app.activationPolicy == .accessory, pid: app.processIdentifier, isSIPProtected: AppManager.isSIPProtected(appPath), - categoryIdentifier: bundle.infoDictionary?["LSApplicationCategoryType"] as? String + categoryIdentifier: bundle.infoDictionary?["LSApplicationCategoryType"] as? String, + requiresReAdd: isManaged && !GhosthidePatch.isApplied(to: executableURL.path), + version: AppVersion(bundle: bundle) ) } for (bundleId, hiddenApp) in config.hidden where !runningIds.contains(bundleId) { let bundleURL = URL(fileURLWithPath: hiddenApp.appPath) let bundle = Bundle(url: bundleURL) + let binaryPath = bundle?.executableURL?.path ?? hiddenApp.binaryPath records.append( ManagedAppRecord( bundleId: bundleId, name: hiddenApp.name, appPath: hiddenApp.appPath, - binaryPath: hiddenApp.binaryPath, + binaryPath: binaryPath, managed: true, running: false, hiddenFromDock: true, pid: nil, isSIPProtected: false, - categoryIdentifier: bundle?.infoDictionary?["LSApplicationCategoryType"] as? String + categoryIdentifier: bundle?.infoDictionary?["LSApplicationCategoryType"] as? String, + requiresReAdd: !GhosthidePatch.isApplied(to: binaryPath), + version: bundle.flatMap(AppVersion.init(bundle:)) ) ) } diff --git a/Sources/GhostTileCore/PrepareOptions.swift b/Sources/GhostTileCore/PrepareOptions.swift new file mode 100644 index 0000000..0f40440 --- /dev/null +++ b/Sources/GhostTileCore/PrepareOptions.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Flags controlling how an app is prepared (patched + re-signed). +public struct PrepareOptions: Sendable, Equatable { + /// Proceed despite compatibility warnings (TCC features that may break). + public var acceptWarnings: Bool + /// Overwrite an existing backup with the current binary (re-adding an updated app). + public var refreshBackup: Bool + /// Strip identity/sandbox entitlements and run unsandboxed (app-group apps on Tahoe). + public var unsandbox: Bool + + public init(acceptWarnings: Bool = false, refreshBackup: Bool = false, unsandbox: Bool = false) { + self.acceptWarnings = acceptWarnings + self.refreshBackup = refreshBackup + self.unsandbox = unsandbox + } +} diff --git a/Sources/ghosttile/CLIHelpers.swift b/Sources/ghosttile/CLIHelpers.swift index 9222736..740ed01 100644 --- a/Sources/ghosttile/CLIHelpers.swift +++ b/Sources/ghosttile/CLIHelpers.swift @@ -39,19 +39,25 @@ func validateNotSIPProtected(_ app: AppInfo) throws { } } -func validateCompatibility(_ app: AppInfo, acceptWarnings: Bool) throws { +func validateCompatibility(_ app: AppInfo, options: PrepareOptions) throws { switch try AppManager.assessCompatibility(app) { case .compatible: return case let .unsupported(reason): throw GhostTileError(reason) + case let .requiresUnsandbox(reason): + guard options.unsandbox else { + throw GhostTileError("\(reason)\n\nRe-run with --unsandbox to manage it anyway (runs unsandboxed).") + } + FileHandle.standardError.write(Data("Managing \(app.name) unsandboxed (--unsandbox).\n".utf8)) + return case let .warnings(warnings): let stderr = FileHandle.standardError stderr.write(Data("Compatibility warnings for \(app.name):\n".utf8)) for warning in warnings { stderr.write(Data(" • \(warning.impact) (\(warning.entitlement))\n".utf8)) } - if acceptWarnings { + if options.acceptWarnings { stderr.write(Data("Continuing because --accept-warnings was set.\n".utf8)) return } @@ -69,11 +75,11 @@ func validateCompatibility(_ app: AppInfo, acceptWarnings: Bool) throws { } } -func prepareIfNeeded(_ app: AppInfo, force: Bool, acceptWarnings: Bool = false) throws { +func prepareIfNeeded(_ app: AppInfo, force: Bool, options: PrepareOptions = .init()) throws { let shouldPrepare = try force || AppManager.needsPreparation(app) guard shouldPrepare else { return } print("Preparing \(app.name)...") - try AppManager.prepare(app, acceptWarnings: acceptWarnings) + try AppManager.prepare(app, options: options) } func addToConfig(_ app: AppInfo) throws { diff --git a/Sources/ghosttile/ManagePrepareCommands.swift b/Sources/ghosttile/ManagePrepareCommands.swift index 8a6c2ea..1941a02 100644 --- a/Sources/ghosttile/ManagePrepareCommands.swift +++ b/Sources/ghosttile/ManagePrepareCommands.swift @@ -12,21 +12,37 @@ extension GhostTile { name: .long, help: "Proceed despite compatibility warnings about features that may break after preparation." ) var acceptWarnings = false + @Flag( + name: .long, + help: "Manage an app-group app anyway by running it unsandboxed (loses sandbox protection)." + ) var unsandbox = false @Argument(help: "Bundle ID, app name, or app bundle path.") var app: String func run() throws { let resolved = try AppManager.resolve(app) + let hiddenApp = Config.load().hidden[resolved.bundleId] + let requiresReAdd = hiddenApp != nil && !GhosthidePatch.isApplied(to: resolved.binaryPath) - if Config.load().hidden[resolved.bundleId] != nil { - if AppManager.runningApps(resolved.bundleId).first?.activationPolicy == .accessory, !forcePrepare { + if hiddenApp != nil { + let alreadyHidden = AppManager.runningApps(resolved.bundleId).first?.activationPolicy == .accessory + if requiresReAdd { + print( + "\(resolved.name) was updated since GhostTile prepared it. Re-preparing this version..." + ) + } else if alreadyHidden, !forcePrepare { print("\(resolved.name) is already managed and hidden.") return } } + let options = PrepareOptions( + acceptWarnings: acceptWarnings, + refreshBackup: requiresReAdd, + unsandbox: unsandbox + ) try validateNotSIPProtected(resolved) - try validateCompatibility(resolved, acceptWarnings: acceptWarnings) - try prepareIfNeeded(resolved, force: forcePrepare, acceptWarnings: acceptWarnings) + try validateCompatibility(resolved, options: options) + try prepareIfNeeded(resolved, force: forcePrepare || requiresReAdd, options: options) print("Restarting \(resolved.name)...") try AppManager.quit(resolved.bundleId) @@ -58,6 +74,10 @@ extension GhostTile { name: .long, help: "Proceed despite compatibility warnings about features that may break after preparation." ) var acceptWarnings = false + @Flag( + name: .long, + help: "Manage an app-group app anyway by running it unsandboxed (loses sandbox protection)." + ) var unsandbox = false @Argument(help: "Bundle ID, app name, or app bundle path.") var app: String func run() throws { @@ -70,8 +90,17 @@ extension GhostTile { return } - try validateCompatibility(resolved, acceptWarnings: acceptWarnings) - try prepareIfNeeded(resolved, force: force, acceptWarnings: acceptWarnings) + // Re-preparing an updated managed app must refresh the stale pre-update backup. + let hiddenApp = Config.load().hidden[resolved.bundleId] + let requiresReAdd = hiddenApp != nil && !GhosthidePatch.isApplied(to: resolved.binaryPath) + let options = PrepareOptions( + acceptWarnings: acceptWarnings, + refreshBackup: requiresReAdd, + unsandbox: unsandbox + ) + + try validateCompatibility(resolved, options: options) + try prepareIfNeeded(resolved, force: force, options: options) print("\(resolved.name) prepared. No relaunch performed.") } } diff --git a/Sources/ghosttile/QueryCommands.swift b/Sources/ghosttile/QueryCommands.swift index 71a125a..0ed9a03 100644 --- a/Sources/ghosttile/QueryCommands.swift +++ b/Sources/ghosttile/QueryCommands.swift @@ -25,7 +25,10 @@ extension GhostTile { for record in records { let name = record.name.padding(toLength: maxName + 2, withPad: " ", startingAt: 0) - let tag = record.managed ? " [managed]" : "" + let tag: String = switch record.displayState { + case .requiresReAdd: " [managed, updated]" + case .running, .notRunning: record.managed ? " [managed]" : "" + } print(" \(name)\(record.bundleId)\(tag)") } } @@ -52,11 +55,7 @@ extension GhostTile { } for record in records { - let status: String = if let pid = record.pid { - record.hiddenFromDock ? "pid \(pid), hidden" : "pid \(pid), visible" - } else { - "not running" - } + let status = record.displayState.cliStatus let name = record.name.padding(toLength: 20, withPad: " ", startingAt: 0) print(" \(name) \(record.bundleId) [\(status)]") } diff --git a/Sources/ghosttile/RestoreCommand.swift b/Sources/ghosttile/RestoreCommand.swift index 0a926fc..0f6ba0f 100644 --- a/Sources/ghosttile/RestoreCommand.swift +++ b/Sources/ghosttile/RestoreCommand.swift @@ -11,15 +11,27 @@ extension GhostTile { func run() throws { let (bundleId, hiddenApp) = try resolveManaged(app) - let wasRunning = isRunning(bundleId) + guard GhosthidePatch.isApplied(to: hiddenApp.binaryPath) else { + print("\(hiddenApp.name) no longer has a GhostTile patch; removing from GhostTile...") + AppManager.discardInjection(bundleId, appPath: hiddenApp.appPath) + try Config.removeHidden(bundleId) + print("\(hiddenApp.name) removed from GhostTile.") + return + } + + let wasRunning = isRunning(bundleId) if wasRunning { print("Quitting \(hiddenApp.name)...") try AppManager.quit(bundleId) } print("Restoring \(hiddenApp.name)...") - try AppManager.restoreBinary(bundleId, binaryPath: hiddenApp.binaryPath, appPath: hiddenApp.appPath) + try AppManager.restoreBinary( + bundleId, + binaryPath: hiddenApp.binaryPath, + appPath: hiddenApp.appPath + ) try Config.removeHidden(bundleId) if wasRunning { diff --git a/Tests/GhostTileCoreTests/AppCompatibilityTests.swift b/Tests/GhostTileCoreTests/AppCompatibilityTests.swift index 39666da..20c8a81 100644 --- a/Tests/GhostTileCoreTests/AppCompatibilityTests.swift +++ b/Tests/GhostTileCoreTests/AppCompatibilityTests.swift @@ -53,12 +53,27 @@ final class AppCompatibilityTests { #expect(!strip.contains("keychain-access-groups")) } - @Test func teamIdBoundEntitlementProducesWarning() throws { + @Test func unsandboxStripAlsoCoversIdentityAndSandbox() { + let strip = AppCompatibility.entitlementsToStrip(unsandbox: true) + #expect(strip.contains("com.apple.security.device.camera")) + #expect(strip.contains("com.apple.security.application-groups")) + #expect(strip.contains("com.apple.application-identifier")) + #expect(strip.contains("com.apple.developer.team-identifier")) + #expect(strip.contains("com.apple.security.app-sandbox")) + } + + @Test func applicationGroupsRequiresUnsandbox() throws { let app = try buildApp(entitlements: ["com.apple.security.application-groups": ["TEAMID.example.group"]]) - try expectWarning( - AppCompatibility.assess(app), - entitlement: "com.apple.security.application-groups" - ) + guard case let .requiresUnsandbox(reason) = try AppCompatibility.assess(app) else { + try Issue.record("Expected .requiresUnsandbox, got \(AppCompatibility.assess(app))") + return + } + #expect(reason.contains("app group")) + } + + @Test func appSandboxAloneProducesWarning() throws { + let app = try buildApp(entitlements: ["com.apple.security.app-sandbox": true]) + try expectWarning(AppCompatibility.assess(app), entitlement: "com.apple.security.app-sandbox") } // MARK: - Helpers diff --git a/Tests/GhostTileCoreTests/AppMock.swift b/Tests/GhostTileCoreTests/AppMock.swift new file mode 100644 index 0000000..e9ea9ca --- /dev/null +++ b/Tests/GhostTileCoreTests/AppMock.swift @@ -0,0 +1,60 @@ +import Foundation +@testable import GhostTileCore + +/// A minimal mock `.app` bundle for tests: a compiled stub Mach-O inside a real bundle layout. +struct AppMock { + let bundleId: String + let appPath: String + let binaryPath: String + + static func make( + in directory: URL, + bundleId: String = "dev.hewig.ghosttile.mock.\(UUID().uuidString)", + executableName: String = "Mock", + shortVersion: String = "1.0", + build: String = "10" + ) throws -> AppMock { + let appURL = directory.appendingPathComponent("\(bundleId).app") + let contentsURL = appURL.appendingPathComponent("Contents") + let macOSURL = contentsURL.appendingPathComponent("MacOS") + try FileManager.default.createDirectory(at: macOSURL, withIntermediateDirectories: true) + + let binaryURL = macOSURL.appendingPathComponent(executableName) + try compileStubBinary(at: binaryURL.path) + + let plist = """ + + + + + CFBundleIdentifier + \(bundleId) + CFBundleExecutable + \(executableName) + CFBundlePackageType + APPL + CFBundleShortVersionString + \(shortVersion) + CFBundleVersion + \(build) + + + """ + try plist.write(to: contentsURL.appendingPathComponent("Info.plist"), atomically: true, encoding: .utf8) + + return AppMock(bundleId: bundleId, appPath: appURL.path, binaryPath: binaryURL.path) + } + + /// Compile a bare minimal Mach-O executable at `binaryPath` (no bundle). + static func compileStubBinary(at binaryPath: String) throws { + let sourcePath = "\(binaryPath)-\(UUID().uuidString).c" + defer { try? FileManager.default.removeItem(atPath: sourcePath) } + try "int main(){return 0;}".write(toFile: sourcePath, atomically: true, encoding: .utf8) + + let clang = try ShellRunner.run("/usr/bin/xcrun", arguments: ["--find", "clang"]) + try ShellRunner.run(clang, arguments: [ + "-o", binaryPath, sourcePath, + "-mmacosx-version-min=15.0", + ]) + } +} diff --git a/Tests/GhostTileCoreTests/AppRestoreManagerTests.swift b/Tests/GhostTileCoreTests/AppRestoreManagerTests.swift new file mode 100644 index 0000000..a19641b --- /dev/null +++ b/Tests/GhostTileCoreTests/AppRestoreManagerTests.swift @@ -0,0 +1,88 @@ +import Foundation +@testable import GhostTileCore +import Testing + +@Suite("AppRestoreManager", .serialized) +final class AppRestoreManagerTests { + private let tempDir: TestTempDirectory + + init() throws { + ConfigTestIsolation.semaphore.wait() + do { + tempDir = try TestTempDirectory(prefix: "ghosttile-restore-tests") + Config.configDirOverride = tempDir.url.appendingPathComponent("config").path + } catch { + ConfigTestIsolation.semaphore.signal() + throw error + } + } + + deinit { + Config.configDirOverride = nil + ConfigTestIsolation.semaphore.signal() + } + + @Test func restoreForgetsWhenCurrentBinaryIsNotPatched() throws { + let app = try makeApp() + let cleanData = try Data(contentsOf: URL(fileURLWithPath: app.binaryPath)) + try writeBackup(bundleId: app.bundleId, sourcePath: app.binaryPath) + + try AppRestoreManager.restoreBinary( + app.bundleId, + binaryPath: app.binaryPath, + appPath: app.appPath + ) + + #expect(try Data(contentsOf: URL(fileURLWithPath: app.binaryPath)) == cleanData) + #expect(!FileManager.default.fileExists(atPath: FileOperations.backupPath(for: app.bundleId))) + } + + @Test func restoreRemovesOrphanedDylibWhenBinaryIsNotPatched() throws { + let app = try makeApp() + try writeBackup(bundleId: app.bundleId, sourcePath: app.binaryPath) + let dylibPath = Dylib.bundleInstallPath(forAppPath: app.appPath) + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: dylibPath).deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("stub".utf8).write(to: URL(fileURLWithPath: dylibPath)) + + try AppRestoreManager.restoreBinary( + app.bundleId, + binaryPath: app.binaryPath, + appPath: app.appPath + ) + + #expect(!FileManager.default.fileExists(atPath: dylibPath)) + #expect(!FileManager.default.fileExists(atPath: FileOperations.backupPath(for: app.bundleId))) + } + + @Test func restoreReplacesPatchedBinary() throws { + let app = try makeApp() + try writeBackup(bundleId: app.bundleId, sourcePath: app.binaryPath) + try MachOEditor.insertGhosthideLoadCommand(in: app.binaryPath) + #expect(try MachOEditor.hasGhosthideLoadCommand(in: app.binaryPath)) + + try AppRestoreManager.restoreBinary( + app.bundleId, + binaryPath: app.binaryPath, + appPath: app.appPath + ) + + #expect(try !MachOEditor.hasGhosthideLoadCommand(in: app.binaryPath)) + #expect(!FileManager.default.fileExists(atPath: FileOperations.backupPath(for: app.bundleId))) + } + + private func makeApp() throws -> AppMock { + try AppMock.make(in: tempDir.url) + } + + private func writeBackup(bundleId: String, sourcePath: String) throws { + let backupURL = URL(fileURLWithPath: FileOperations.backupPath(for: bundleId)) + try FileManager.default.createDirectory(at: backupURL, withIntermediateDirectories: true) + try FileManager.default.copyItem( + atPath: sourcePath, + toPath: backupURL.appendingPathComponent("binary").path + ) + } +} diff --git a/Tests/GhostTileCoreTests/ConfigTests.swift b/Tests/GhostTileCoreTests/ConfigTests.swift index db5b8f6..1b57733 100644 --- a/Tests/GhostTileCoreTests/ConfigTests.swift +++ b/Tests/GhostTileCoreTests/ConfigTests.swift @@ -7,12 +7,19 @@ final class ConfigTests { private let tempDir: TestTempDirectory init() throws { - tempDir = try TestTempDirectory(prefix: "ghosttile-config-tests") - Config.configDirOverride = tempDir.path + ConfigTestIsolation.semaphore.wait() + do { + tempDir = try TestTempDirectory(prefix: "ghosttile-config-tests") + Config.configDirOverride = tempDir.path + } catch { + ConfigTestIsolation.semaphore.signal() + throw error + } } deinit { Config.configDirOverride = nil + ConfigTestIsolation.semaphore.signal() } @Test func loadReturnsDefaultWhenNoFile() { diff --git a/Tests/GhostTileCoreTests/MachOEditorTests.swift b/Tests/GhostTileCoreTests/MachOEditorTests.swift index d5c4000..203ae30 100644 --- a/Tests/GhostTileCoreTests/MachOEditorTests.swift +++ b/Tests/GhostTileCoreTests/MachOEditorTests.swift @@ -76,16 +76,8 @@ final class MachOEditorTests { // MARK: - Helpers private func compileMinimalBinary() throws -> String { - let sourcePath = tempDir.url.appendingPathComponent("main.c").path let binaryPath = tempDir.url.appendingPathComponent("main").path - try "int main(){return 0;}".write(toFile: sourcePath, atomically: true, encoding: .utf8) - - let xcrun = try ShellRunner.run("/usr/bin/xcrun", arguments: ["--find", "clang"]) - try ShellRunner.run(xcrun, arguments: [ - "-o", binaryPath, sourcePath, - "-mmacosx-version-min=15.0", - ]) - + try AppMock.compileStubBinary(at: binaryPath) return binaryPath } diff --git a/Tests/GhostTileCoreTests/TestSupport.swift b/Tests/GhostTileCoreTests/TestSupport.swift index 8226b13..08b35a0 100644 --- a/Tests/GhostTileCoreTests/TestSupport.swift +++ b/Tests/GhostTileCoreTests/TestSupport.swift @@ -19,3 +19,8 @@ final class TestTempDirectory { url.path } } + +/// Serializes config-mutating suites; a semaphore (not NSLock) since init/deinit may run on separate threads. +enum ConfigTestIsolation { + static let semaphore = DispatchSemaphore(value: 1) +} diff --git a/VERSION b/VERSION index ef23616..b429c4a 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ -VERSION=2.0.8 -BUILD=25 +VERSION=2.0.9 +BUILD=26 diff --git a/project.yml b/project.yml index f772ec6..fa6260c 100644 --- a/project.yml +++ b/project.yml @@ -74,8 +74,8 @@ targets: properties: CFBundleDisplayName: GhostTile CFBundleIconName: app - CFBundleShortVersionString: 2.0.8 - CFBundleVersion: "25" + CFBundleShortVersionString: 2.0.9 + CFBundleVersion: "26" LSUIElement: false LSMinimumSystemVersion: "15.0" NSHighResolutionCapable: true diff --git a/releases/2.0.9.html b/releases/2.0.9.html new file mode 100644 index 0000000..514a090 --- /dev/null +++ b/releases/2.0.9.html @@ -0,0 +1,6 @@ +
    +
  • Apps updated after being managed are now handled gracefully: GhostTile detects when an update has stripped its patch, flags the app as needing a re-add, and offers a one-click Re-add instead of misbehaving.
  • +
  • Restore and remove no longer overwrite a newer app binary with a stale backup; the old backup is discarded instead.
  • +
  • Managed-app version is now shown in the main window cards and the menu bar submenu.
  • +
  • Faster, lighter managed-app refresh: patch checks are cached by file identity and memory-mapped, avoiding repeated full reads of large binaries on the main thread.
  • +