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.
+