Skip to content

Commit ccc826b

Browse files
committed
Implement app uninstall functionality with LaunchServices private API
- Changed 'Move to Trash' menu item to 'Uninstall' in all languages - Created AppUninstallService using LaunchServices private API - Added confirmation dialog before uninstalling - Added error handling with fallback to trash if private API fails - Added internationalization for confirmation and error dialogs - Fixed compilation warnings in DockRightClickManager
1 parent f80972d commit ccc826b

7 files changed

Lines changed: 162 additions & 11 deletions

File tree

DockMasterPro/Features/DockOverlay/DockRightClickManager.swift

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,6 @@ final class DockRightClickManager: ObservableObject {
264264
}
265265
} else if let sid = sectionID {
266266
// 分区内应用:根据当前状态动态显示菜单
267-
let isPinnedInSection = item.itemPosition != .normal
268267
let isPinnedToDock = pinnedItems.contains { $0.bundleIdentifier == item.bundleIdentifier }
269268

270269
// 分区内固定菜单
@@ -339,18 +338,47 @@ final class DockRightClickManager: ObservableObject {
339338
}
340339
}
341340

342-
if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: item.bundleIdentifier)
343-
?? item.iconPath.map({ URL(fileURLWithPath: $0) }) {
341+
if NSWorkspace.shared.urlForApplication(withBundleIdentifier: item.bundleIdentifier) != nil
342+
|| item.iconPath != nil {
344343
menu.addItem(.separator())
345344
add(L(L10n.ContextMenu.uninstall), image: "trash") { [weak self] in
346345
let bundleID = item.bundleIdentifier
347-
// Remove from dock immediately (optimistic update)
346+
347+
// 显示确认对话框
348+
let alert = NSAlert()
349+
alert.messageText = L(L10n.ContextMenu.uninstallConfirmTitle)
350+
alert.informativeText = L(L10n.ContextMenu.uninstallConfirmMessage, item.displayName)
351+
alert.alertStyle = .warning
352+
alert.addButton(withTitle: L(L10n.ContextMenu.uninstallConfirmButton))
353+
alert.addButton(withTitle: L(L10n.Button.cancel))
354+
355+
// 提升 alert 窗口层级,确保显示在 Dock 上方
356+
alert.window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.dockWindow)) + 2)
357+
NSApp.activate(ignoringOtherApps: true)
358+
359+
guard alert.runModal() == .alertFirstButtonReturn else { return }
360+
361+
// 从 Dock 中立即移除(乐观更新)
348362
self?.onRemoveByBundleID?(bundleID)
349-
// Quit if running
363+
364+
// 退出应用(如果正在运行)
350365
NSRunningApplication.runningApplications(withBundleIdentifier: bundleID)
351366
.forEach { $0.terminate() }
352-
// Move to trash (fire and forget)
353-
NSWorkspace.shared.recycle([appURL]) { _, _ in }
367+
368+
// 执行卸载
369+
AppUninstallService.shared.uninstallApp(bundleIdentifier: bundleID) { success, error in
370+
DispatchQueue.main.async {
371+
if !success {
372+
let errorAlert = NSAlert()
373+
errorAlert.messageText = L(L10n.ContextMenu.uninstallFailedTitle)
374+
errorAlert.informativeText = error?.localizedDescription ?? L(L10n.ContextMenu.uninstallFailedMessage)
375+
errorAlert.alertStyle = .critical
376+
errorAlert.addButton(withTitle: L(L10n.Button.ok))
377+
errorAlert.window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.dockWindow)) + 2)
378+
errorAlert.runModal()
379+
}
380+
}
381+
}
354382
}
355383
}
356384

DockMasterPro/Resources/en.lproj/Localizable.strings

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,12 @@
464464
"context_menu.pin_to_section" = "Add to \"%@\"";
465465
"context_menu.remove_from_dock" = "Remove from Dock";
466466
"context_menu.quit_app" = "Quit";
467-
"context_menu.uninstall" = "Move to Trash";
467+
"context_menu.uninstall" = "Uninstall";
468+
"context_menu.uninstall_confirm_title" = "Uninstall Application";
469+
"context_menu.uninstall_confirm_message" = "Are you sure you want to uninstall \"%@\"? This action cannot be undone.";
470+
"context_menu.uninstall_confirm_button" = "Uninstall";
471+
"context_menu.uninstall_failed_title" = "Uninstall Failed";
472+
"context_menu.uninstall_failed_message" = "Failed to uninstall the application. Please try again or remove it manually.";
468473
"context_menu.add_section" = "Add Section";
469474
"context_menu.dock_preferences" = "Dock Preferences";
470475
"context_menu.hide_dock" = "Hide Dock";

DockMasterPro/Resources/ja.lproj/Localizable.strings

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,12 @@
444444
"context_menu.pin_to_section" = "「%@」に追加";
445445
"context_menu.remove_from_dock" = "Dockから削除";
446446
"context_menu.quit_app" = "終了";
447-
"context_menu.uninstall" = "ゴミ箱に移動";
447+
"context_menu.uninstall" = "アンインストール";
448+
"context_menu.uninstall_confirm_title" = "アプリケーションをアンインストール";
449+
"context_menu.uninstall_confirm_message" = "「%@」をアンインストールしてもよろしいですか?この操作は元に戻せません。";
450+
"context_menu.uninstall_confirm_button" = "アンインストール";
451+
"context_menu.uninstall_failed_title" = "アンインストール失敗";
452+
"context_menu.uninstall_failed_message" = "アプリケーションをアンインストールできませんでした。もう一度試すか、手動で削除してください。";
448453
"context_menu.add_section" = "セクションを追加";
449454
"context_menu.dock_preferences" = "Dock環境設定";
450455
"context_menu.hide_dock" = "Dockを隠す";

DockMasterPro/Resources/zh-Hans.lproj/Localizable.strings

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,12 @@
445445
"context_menu.pin_to_section" = "添加到「%@」";
446446
"context_menu.remove_from_dock" = "从 Dock 中移除";
447447
"context_menu.quit_app" = "退出";
448-
"context_menu.uninstall" = "移到废纸篓";
448+
"context_menu.uninstall" = "卸载";
449+
"context_menu.uninstall_confirm_title" = "卸载应用";
450+
"context_menu.uninstall_confirm_message" = "确定要卸载「%@」吗?此操作无法撤销。";
451+
"context_menu.uninstall_confirm_button" = "卸载";
452+
"context_menu.uninstall_failed_title" = "卸载失败";
453+
"context_menu.uninstall_failed_message" = "无法卸载该应用。请重试或手动删除。";
449454
"context_menu.add_section" = "添加分区";
450455
"context_menu.dock_preferences" = "Dock 偏好设置";
451456
"context_menu.hide_dock" = "隐藏 Dock";

DockMasterPro/Resources/zh-Hant.lproj/Localizable.strings

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,12 @@
444444
"context_menu.pin_to_section" = "加入「%@」";
445445
"context_menu.remove_from_dock" = "從 Dock 中移除";
446446
"context_menu.quit_app" = "退出";
447-
"context_menu.uninstall" = "移至垃圾桶";
447+
"context_menu.uninstall" = "卸載";
448+
"context_menu.uninstall_confirm_title" = "卸載應用程式";
449+
"context_menu.uninstall_confirm_message" = "確定要卸載「%@」嗎?此操作無法復原。";
450+
"context_menu.uninstall_confirm_button" = "卸載";
451+
"context_menu.uninstall_failed_title" = "卸載失敗";
452+
"context_menu.uninstall_failed_message" = "無法卸載該應用程式。請重試或手動刪除。";
448453
"context_menu.add_section" = "加入分區";
449454
"context_menu.dock_preferences" = "Dock 偏好設定";
450455
"context_menu.hide_dock" = "隱藏 Dock";
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import Foundation
2+
import AppKit
3+
4+
// MARK: - LaunchServices Private API
5+
6+
// LSApplicationProxy 私有类(用于获取应用信息)
7+
@objc protocol LSApplicationProxy {
8+
var bundleIdentifier: String { get }
9+
var bundleURL: URL { get }
10+
}
11+
12+
// LSApplicationWorkspace 私有类(用于卸载应用)
13+
@objc protocol LSApplicationWorkspace {
14+
static func defaultWorkspace() -> Self
15+
func uninstallApplication(_ bundleIdentifier: String, withOptions options: [String: Any]?) -> Bool
16+
func openApplicationWithBundleID(_ bundleIdentifier: String) -> Bool
17+
}
18+
19+
// MARK: - App Uninstall Service
20+
21+
/// 应用卸载服务,使用 LaunchServices 私有 API 实现真正的卸载功能
22+
final class AppUninstallService {
23+
24+
static let shared = AppUninstallService()
25+
26+
private init() {}
27+
28+
// MARK: - Public Methods
29+
30+
/// 卸载应用
31+
/// - Parameters:
32+
/// - bundleIdentifier: 应用的 Bundle ID
33+
/// - completion: 完成回调,返回是否成功
34+
func uninstallApp(bundleIdentifier: String, completion: @escaping (Bool, Error?) -> Void) {
35+
// 1. 先尝试使用私有 API 卸载
36+
if uninstallUsingPrivateAPI(bundleIdentifier: bundleIdentifier) {
37+
completion(true, nil)
38+
return
39+
}
40+
41+
// 2. 如果私有 API 失败,回退到移到废纸篓
42+
fallbackToTrash(bundleIdentifier: bundleIdentifier, completion: completion)
43+
}
44+
45+
// MARK: - Private Methods
46+
47+
/// 使用 LaunchServices 私有 API 卸载
48+
private func uninstallUsingPrivateAPI(bundleIdentifier: String) -> Bool {
49+
// 获取 LSApplicationWorkspace 类
50+
guard let workspaceClass = NSClassFromString("LSApplicationWorkspace") as? NSObject.Type else {
51+
return false
52+
}
53+
54+
// 获取 defaultWorkspace
55+
guard let workspace = workspaceClass.perform(NSSelectorFromString("defaultWorkspace"))?.takeUnretainedValue() else {
56+
return false
57+
}
58+
59+
// 调用 uninstallApplication:withOptions:
60+
let selector = NSSelectorFromString("uninstallApplication:withOptions:")
61+
guard workspace.responds(to: selector) else {
62+
return false
63+
}
64+
65+
// 创建方法签名
66+
guard let method = class_getInstanceMethod(type(of: workspace), selector) else {
67+
return false
68+
}
69+
70+
let implementation = method_getImplementation(method)
71+
typealias UninstallFunction = @convention(c) (AnyObject, Selector, NSString, NSDictionary?) -> Bool
72+
let uninstallFunc = unsafeBitCast(implementation, to: UninstallFunction.self)
73+
74+
// 执行卸载
75+
let options: NSDictionary? = nil
76+
let result = uninstallFunc(workspace as AnyObject, selector, bundleIdentifier as NSString, options)
77+
78+
return result
79+
}
80+
81+
/// 回退方案:移到废纸篓
82+
private func fallbackToTrash(bundleIdentifier: String, completion: @escaping (Bool, Error?) -> Void) {
83+
guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) else {
84+
completion(false, NSError(domain: "AppUninstallService", code: -1, userInfo: [
85+
NSLocalizedDescriptionKey: "Application not found"
86+
]))
87+
return
88+
}
89+
90+
NSWorkspace.shared.recycle([appURL]) { trashedURLs, error in
91+
if let error = error {
92+
completion(false, error)
93+
} else {
94+
completion(!trashedURLs.isEmpty, nil)
95+
}
96+
}
97+
}
98+
}

DockMasterPro/Support/L10n.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,11 @@ enum L10n {
577577
static let removeFromDock = "context_menu.remove_from_dock"
578578
static let quitApp = "context_menu.quit_app"
579579
static let uninstall = "context_menu.uninstall"
580+
static let uninstallConfirmTitle = "context_menu.uninstall_confirm_title"
581+
static let uninstallConfirmMessage = "context_menu.uninstall_confirm_message"
582+
static let uninstallConfirmButton = "context_menu.uninstall_confirm_button"
583+
static let uninstallFailedTitle = "context_menu.uninstall_failed_title"
584+
static let uninstallFailedMessage = "context_menu.uninstall_failed_message"
580585
static let addSection = "context_menu.add_section"
581586
static let dockPreferences = "context_menu.dock_preferences"
582587
static let hideDock = "context_menu.hide_dock"

0 commit comments

Comments
 (0)