Переглянути джерело

Add status bar icons, desktop webloc shortcuts, and sandbox save entitlement

- Show one menu bar NSStatusItem per app added via Add to StatusBar; left-click
  opens in-app browser, right-click removes. Icons scaled to 16pt for system bar.
- Sync icons via statusBarShortcutIDsChanged notification and UserDefaults.
- Add to desktop saves a .webloc via NSSavePanel; add user-selected read-write
  entitlement for sandboxed save panels.
- Move PersistedCustomApp to LauncherApp; add status bar persistence helpers.
- Simplify main tray icon context menu to Open My Apps and Quit.

Made-with: Cursor
huzaifahayat12 3 місяців тому
батько
коміт
b5dc21fd73

+ 45 - 1
google_apps/AppDelegate.swift

@@ -14,6 +14,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
     func applicationDidFinishLaunching(_ aNotification: Notification) {
         configureMainWindowIfNeeded()
         setupStatusBarItem()
+        StatusBarAppIconsController.shared.start()
     }
 
     private func setupStatusBarItem() {
@@ -23,11 +24,54 @@ class AppDelegate: NSObject, NSApplicationDelegate {
         guard let button = item.button else { return }
         button.image = NSImage(systemSymbolName: "square.grid.2x2.fill", accessibilityDescription: "My Apps")
         button.image?.isTemplate = true
-        button.action = #selector(openMainWindowFromStatusBar)
+        button.action = #selector(statusBarButtonClicked(_:))
         button.target = self
         button.sendAction(on: [.leftMouseUp, .rightMouseUp])
     }
 
+    @objc
+    private func statusBarButtonClicked(_ sender: Any?) {
+        guard let event = NSApp.currentEvent else {
+            openMainWindowFromStatusBar()
+            return
+        }
+        if event.type == .rightMouseUp {
+            presentStatusBarShortcutsMenu()
+        } else {
+            openMainWindowFromStatusBar()
+        }
+    }
+
+    private func presentStatusBarShortcutsMenu() {
+        guard let button = statusItem?.button else { return }
+
+        let menu = NSMenu()
+        let openItem = NSMenuItem(
+            title: "Open My Apps",
+            action: #selector(openMainWindowFromStatusBar),
+            keyEquivalent: ""
+        )
+        openItem.target = self
+        menu.addItem(openItem)
+
+        menu.addItem(.separator())
+        let quitItem = NSMenuItem(
+            title: "Quit",
+            action: #selector(terminateApp),
+            keyEquivalent: "q"
+        )
+        quitItem.target = self
+        menu.addItem(quitItem)
+
+        let location = NSPoint(x: 0, y: button.bounds.height)
+        menu.popUp(positioning: nil, at: location, in: button)
+    }
+
+    @objc
+    private func terminateApp() {
+        NSApp.terminate(nil)
+    }
+
     @objc
     private func openMainWindowFromStatusBar() {
         configureMainWindowIfNeeded()

+ 37 - 21
google_apps/AppTileView.swift

@@ -42,7 +42,11 @@ struct AppTileView: View {
                 Menu {
                     Button(isPinned ? "Unpin" : "Pin") { togglePin() }
                     Divider()
-                    Button("Add to StatusBar") { addToStatusBar() }
+                    if isInStatusBar {
+                        Button("Remove from StatusBar") { removeFromStatusBar() }
+                    } else {
+                        Button("Add to StatusBar") { addToStatusBar() }
+                    }
                     Button("Add to desktop") { addToDesktop() }
                     Button("Add Widget") { addWidget() }
                     Divider()
@@ -74,7 +78,11 @@ struct AppTileView: View {
             if !app.isCreateNew {
                 Button(isPinned ? "Unpin" : "Pin") { togglePin() }
                 Divider()
-                Button("Add to StatusBar") { addToStatusBar() }
+                if isInStatusBar {
+                    Button("Remove from StatusBar") { removeFromStatusBar() }
+                } else {
+                    Button("Add to StatusBar") { addToStatusBar() }
+                }
                 Button("Add to desktop") { addToDesktop() }
                 Button("Add Widget") { addWidget() }
                 Divider()
@@ -130,10 +138,18 @@ struct AppTileView: View {
         pinnedIDs.contains(app.id)
     }
 
+    private var isInStatusBar: Bool {
+        statusBarIDs.contains(app.id)
+    }
+
     private var pinnedIDs: Set<UUID> {
         decodeUUIDSet(from: pinnedTileIDsData)
     }
 
+    private var statusBarIDs: Set<UUID> {
+        decodeUUIDSet(from: statusBarAppIDsData)
+    }
+
     private func togglePin() {
         var updated = pinnedIDs
         if updated.contains(app.id) {
@@ -146,9 +162,23 @@ struct AppTileView: View {
 
     private func addToStatusBar() {
         var updated = decodeUUIDSet(from: statusBarAppIDsData)
-        updated.insert(app.id)
+        guard updated.insert(app.id).inserted else {
+            showAlert(title: "Already added", message: "“\(app.name)” is already in the menu bar.")
+            return
+        }
         statusBarAppIDsData = encodeUUIDSet(updated)
-        showAlert(title: "Added", message: "Added “\(app.name)” to Status Bar shortcuts.")
+        LauncherApp.notifyStatusBarShortcutIdentifiersChanged()
+        showAlert(
+            title: "Added to Status Bar",
+            message: "“\(app.name)” now has its own icon in the menu bar. Click it to open, or right‑click to remove."
+        )
+    }
+
+    private func removeFromStatusBar() {
+        var updated = decodeUUIDSet(from: statusBarAppIDsData)
+        guard updated.remove(app.id) != nil else { return }
+        statusBarAppIDsData = encodeUUIDSet(updated)
+        LauncherApp.notifyStatusBarShortcutIdentifiersChanged()
     }
 
     private func addWidget() {
@@ -163,25 +193,11 @@ struct AppTileView: View {
     }
 
     private func addToDesktop() {
-        let widgetVariants = WidgetTemplates.variants(for: app)
-        guard let variant = widgetVariants.first else {
-            showAlert(title: "Unavailable", message: "No desktop widget is available for this app.")
+        guard let webURL = app.webURL else {
+            showAlert(title: "Unavailable", message: "Desktop shortcuts need a web address.")
             return
         }
-
-        var updated = decodeUUIDSet(from: widgetAppIDsData)
-        updated.insert(app.id)
-        widgetAppIDsData = encodeUUIDSet(updated)
-
-        let instance = WidgetInstance(
-            appID: app.id,
-            variantID: variant.id,
-            size: variant.size,
-            origin: nil
-        )
-        DesktopWidgetWindowManager.shared.show(instance: instance, appProvider: { id in
-            id == app.id ? app : nil
-        })
+        DesktopWeblocShortcutPresenter.beginSavePanel(forAppNamed: app.name, webURL: webURL)
     }
 
     private func decodeUUIDSet(from dataString: String) -> Set<UUID> {

+ 49 - 0
google_apps/DesktopWeblocShortcut.swift

@@ -0,0 +1,49 @@
+import AppKit
+import Foundation
+import UniformTypeIdentifiers
+
+enum WeblocFileWriter {
+    static func writeWebloc(at fileURL: URL, destinationURL: URL) throws {
+        let plist: [String: Any] = ["URL": destinationURL.absoluteString]
+        let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
+        try data.write(to: fileURL, options: .atomic)
+    }
+}
+
+enum DesktopWeblocShortcutPresenter {
+    static func beginSavePanel(forAppNamed name: String, webURL: URL) {
+        let panel = NSSavePanel()
+        panel.title = "Add to desktop"
+        panel.message = "Choose where to save the web shortcut."
+        panel.nameFieldStringValue = sanitizedFileName(name) + ".webloc"
+        panel.canCreateDirectories = false
+        if let weblocType = UTType(filenameExtension: "webloc") {
+            panel.allowedContentTypes = [weblocType]
+        }
+        if let desktop = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first {
+            panel.directoryURL = desktop
+        }
+        panel.begin { response in
+            guard response == .OK, let dest = panel.url else { return }
+            do {
+                try WeblocFileWriter.writeWebloc(at: dest, destinationURL: webURL)
+            } catch {
+                DispatchQueue.main.async {
+                    let alert = NSAlert()
+                    alert.messageText = "Couldn’t save shortcut"
+                    alert.informativeText = error.localizedDescription
+                    alert.alertStyle = .warning
+                    alert.runModal()
+                }
+            }
+        }
+    }
+
+    private static func sanitizedFileName(_ name: String) -> String {
+        let trimmed = name
+            .replacingOccurrences(of: "/", with: "-")
+            .replacingOccurrences(of: ":", with: "-")
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+        return trimmed.isEmpty ? "Shortcut" : trimmed
+    }
+}

+ 73 - 0
google_apps/LauncherApp.swift

@@ -1,5 +1,10 @@
 import Foundation
 
+extension Notification.Name {
+    /// Posted when `statusBarAppIDsData` changes so extra menu bar icons can refresh.
+    static let statusBarShortcutIDsChanged = Notification.Name("google_apps.statusBarShortcutIDsChanged")
+}
+
 struct LauncherApp: Identifiable, Hashable {
     let id: UUID
     let name: String
@@ -28,6 +33,12 @@ struct LauncherApp: Identifiable, Hashable {
     }
 }
 
+struct PersistedCustomApp: Codable {
+    let id: UUID
+    let name: String
+    let urlString: String
+}
+
 extension LauncherApp {
     static let createNewTile = LauncherApp(
         id: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!,
@@ -74,4 +85,66 @@ extension LauncherApp {
         LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000026")!, name: "Chrome Remote Desktop", assetIconName: "icon_chrome_remote_desktop", fallbackSymbolName: "desktopcomputer", description: "Access computers remotely.", isCreateNew: false, webURL: URL(string: "https://remotedesktop.google.com")),
         LauncherApp(id: UUID(uuidString: "F0000000-0000-0000-0000-000000000027")!, name: "Gemini", assetIconName: "icon_gemini", fallbackSymbolName: "sparkles", description: "Google AI assistant.", isCreateNew: false, webURL: URL(string: "https://gemini.google.com")),
     ]
+
+    static func customAppsFromStorageJSON(_ json: String) -> [LauncherApp] {
+        guard !json.isEmpty, let data = json.data(using: .utf8) else { return [] }
+        do {
+            let persisted = try JSONDecoder().decode([PersistedCustomApp].self, from: data)
+            return persisted.compactMap { item in
+                guard let parsedURL = URL(string: item.urlString) else { return nil }
+                return LauncherApp(
+                    id: item.id,
+                    name: item.name,
+                    assetIconName: "",
+                    fallbackSymbolName: "globe",
+                    description: "Custom app shortcut.",
+                    isCreateNew: false,
+                    webURL: parsedURL
+                )
+            }
+        } catch {
+            return []
+        }
+    }
+
+    static func allLaunchableAppsFromUserDefaults() -> [LauncherApp] {
+        let customJSON = UserDefaults.standard.string(forKey: "customLauncherAppsData") ?? ""
+        return sampleApps + customAppsFromStorageJSON(customJSON)
+    }
+
+    static func statusBarShortcutIDSet() -> Set<UUID> {
+        let raw = UserDefaults.standard.string(forKey: "statusBarAppIDsData") ?? ""
+        guard !raw.isEmpty, let data = raw.data(using: .utf8) else { return [] }
+        do {
+            let strings = try JSONDecoder().decode([String].self, from: data)
+            return Set(strings.compactMap(UUID.init(uuidString:)))
+        } catch {
+            return []
+        }
+    }
+
+    static func statusBarShortcutsOrdered() -> [LauncherApp] {
+        let ids = statusBarShortcutIDSet()
+        return allLaunchableAppsFromUserDefaults()
+            .filter { ids.contains($0.id) }
+            .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
+    }
+
+    /// Writes `statusBarAppIDsData` in the same JSON format as SwiftUI `@AppStorage`.
+    static func persistStatusBarShortcutIDs(_ ids: Set<UUID>) {
+        do {
+            let strings = ids.map(\.uuidString).sorted()
+            let data = try JSONEncoder().encode(strings)
+            UserDefaults.standard.set(String(decoding: data, as: UTF8.self), forKey: "statusBarAppIDsData")
+            LauncherApp.notifyStatusBarShortcutIdentifiersChanged()
+        } catch {
+            // Ignore persistence failures; menu bar state may be slightly stale until next change.
+        }
+    }
+
+    static func notifyStatusBarShortcutIdentifiersChanged() {
+        DispatchQueue.main.async {
+            NotificationCenter.default.post(name: .statusBarShortcutIDsChanged, object: nil)
+        }
+    }
 }

+ 51 - 28
google_apps/LauncherRootView.swift

@@ -333,8 +333,10 @@ struct LauncherRootView: View {
         pinnedTileIDsData = encodeUUIDSetGlobal(pinnedIDs)
 
         var statusBarIDs = decodeUUIDSet(from: statusBarAppIDsData)
-        statusBarIDs.remove(id)
-        statusBarAppIDsData = encodeUUIDSetGlobal(statusBarIDs)
+        if statusBarIDs.remove(id) != nil {
+            statusBarAppIDsData = encodeUUIDSetGlobal(statusBarIDs)
+            LauncherApp.notifyStatusBarShortcutIdentifiersChanged()
+        }
 
         var widgetIDs = decodeUUIDSet(from: widgetAppIDsData)
         widgetIDs.remove(id)
@@ -400,12 +402,6 @@ struct LauncherRootView: View {
     }
 }
 
-private struct PersistedCustomApp: Codable {
-    let id: UUID
-    let name: String
-    let urlString: String
-}
-
 private struct TileReorderDropDelegate: DropDelegate {
     let destinationAppID: UUID
     @Binding var draggedAppID: UUID?
@@ -553,6 +549,14 @@ private struct PanelAppRowView: View {
         pinnedIDs.contains(app.id)
     }
 
+    private var statusBarIDs: Set<UUID> {
+        decodeUUIDSetGlobal(from: statusBarAppIDsData)
+    }
+
+    private var isInStatusBar: Bool {
+        statusBarIDs.contains(app.id)
+    }
+
     var body: some View {
         ZStack(alignment: .topTrailing) {
             Button(action: { onAppTap(app) }) {
@@ -593,7 +597,11 @@ private struct PanelAppRowView: View {
                 Menu {
                     Button(isPinned ? "Unpin" : "Pin") { togglePin() }
                     Divider()
-                    Button("Add to StatusBar") { addToStatusBar() }
+                    if isInStatusBar {
+                        Button("Remove from StatusBar") { removeFromStatusBar() }
+                    } else {
+                        Button("Add to StatusBar") { addToStatusBar() }
+                    }
                     Button("Add to desktop") { addToDesktop() }
                     Button("Add Widget") { addWidget() }
                     Divider()
@@ -624,6 +632,21 @@ private struct PanelAppRowView: View {
         .onHover { hovering in
             isHovering = hovering
         }
+        .contextMenu {
+            if !app.isCreateNew {
+                Button(isPinned ? "Unpin" : "Pin") { togglePin() }
+                Divider()
+                if isInStatusBar {
+                    Button("Remove from StatusBar") { removeFromStatusBar() }
+                } else {
+                    Button("Add to StatusBar") { addToStatusBar() }
+                }
+                Button("Add to desktop") { addToDesktop() }
+                Button("Add Widget") { addWidget() }
+                Divider()
+                Button("Remove from View") { onHideApp(app.id) }
+            }
+        }
         .padding(.vertical, 3)
         .onDrag {
             draggedAppID = app.id
@@ -653,9 +676,23 @@ private struct PanelAppRowView: View {
 
     private func addToStatusBar() {
         var updated = decodeUUIDSetGlobal(from: statusBarAppIDsData)
-        updated.insert(app.id)
+        guard updated.insert(app.id).inserted else {
+            showAlert(title: "Already added", message: "“\(app.name)” is already in the menu bar.")
+            return
+        }
+        statusBarAppIDsData = encodeUUIDSetGlobal(updated)
+        LauncherApp.notifyStatusBarShortcutIdentifiersChanged()
+        showAlert(
+            title: "Added to Status Bar",
+            message: "“\(app.name)” now has its own icon in the menu bar. Click it to open, or right‑click to remove."
+        )
+    }
+
+    private func removeFromStatusBar() {
+        var updated = decodeUUIDSetGlobal(from: statusBarAppIDsData)
+        guard updated.remove(app.id) != nil else { return }
         statusBarAppIDsData = encodeUUIDSetGlobal(updated)
-        showAlert(title: "Added", message: "Added “\(app.name)” to Status Bar shortcuts.")
+        LauncherApp.notifyStatusBarShortcutIdentifiersChanged()
     }
 
     private func addWidget() {
@@ -670,25 +707,11 @@ private struct PanelAppRowView: View {
     }
 
     private func addToDesktop() {
-        let variants = WidgetTemplates.variants(for: app)
-        guard let variant = variants.first else {
-            showAlert(title: "Unavailable", message: "No desktop widget is available for this app.")
+        guard let webURL = app.webURL else {
+            showAlert(title: "Unavailable", message: "Desktop shortcuts need a web address.")
             return
         }
-
-        var updated = decodeUUIDSetGlobal(from: widgetAppIDsData)
-        updated.insert(app.id)
-        widgetAppIDsData = encodeUUIDSetGlobal(updated)
-
-        let instance = WidgetInstance(
-            appID: app.id,
-            variantID: variant.id,
-            size: variant.size,
-            origin: nil
-        )
-        DesktopWidgetWindowManager.shared.show(instance: instance, appProvider: { id in
-            id == app.id ? app : nil
-        })
+        DesktopWeblocShortcutPresenter.beginSavePanel(forAppNamed: app.name, webURL: webURL)
     }
 
     private func showAlert(title: String, message: String) {

+ 150 - 0
google_apps/StatusBarAppIconsController.swift

@@ -0,0 +1,150 @@
+import AppKit
+import Foundation
+
+/// One menu bar icon per app in `statusBarAppIDsData` (left-click opens in-app browser, right-click to remove).
+final class StatusBarAppIconsController: NSObject {
+    static let shared = StatusBarAppIconsController()
+
+    /// Logical points; matches typical template icons (Wi‑Fi, Control Center, etc.).
+    private static let menuBarIconSide: CGFloat = 16
+
+    private var appStatusItems: [UUID: NSStatusItem] = [:]
+    private var lastOrderedIDs: [UUID] = []
+
+    private override init() {
+        super.init()
+    }
+
+    func start() {
+        NotificationCenter.default.addObserver(
+            self,
+            selector: #selector(statusBarShortcutsMayHaveChanged),
+            name: .statusBarShortcutIDsChanged,
+            object: nil
+        )
+        DispatchQueue.main.async { [weak self] in
+            self?.synchronize()
+        }
+    }
+
+    @objc private func statusBarShortcutsMayHaveChanged() {
+        synchronize()
+    }
+
+    private func synchronize() {
+        let apps = LauncherApp.statusBarShortcutsOrdered()
+        let orderedIDs = apps.map(\.id)
+        guard orderedIDs != lastOrderedIDs else { return }
+        lastOrderedIDs = orderedIDs
+
+        for (_, item) in appStatusItems {
+            NSStatusBar.system.removeStatusItem(item)
+        }
+        appStatusItems.removeAll()
+
+        for app in apps {
+            let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
+            appStatusItems[app.id] = item
+
+            guard let button = item.button else { continue }
+            button.identifier = NSUserInterfaceItemIdentifier(app.id.uuidString)
+            button.toolTip = app.name
+            button.target = self
+            button.action = #selector(appIconClicked(_:))
+            button.sendAction(on: [.leftMouseUp, .rightMouseUp])
+            button.imageScaling = .scaleProportionallyDown
+            button.imagePosition = .imageOnly
+
+            button.image = Self.statusBarImage(for: app)
+        }
+    }
+
+    /// Renders at a fixed menu bar size so full‑resolution assets don’t fill the entire status item.
+    private static func statusBarImage(for app: LauncherApp) -> NSImage? {
+        if !app.assetIconName.isEmpty, let source = NSImage(named: app.assetIconName) {
+            return scaledStatusBarImage(from: source, asTemplate: false)
+        }
+
+        guard let baseSymbol = NSImage(
+            systemSymbolName: app.fallbackSymbolName,
+            accessibilityDescription: app.name
+        ) else { return nil }
+        let config = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium)
+        let symbol = baseSymbol.withSymbolConfiguration(config) ?? baseSymbol
+        symbol.isTemplate = true
+        return scaledStatusBarImage(from: symbol, asTemplate: true)
+    }
+
+    private static func scaledStatusBarImage(from source: NSImage, asTemplate: Bool) -> NSImage {
+        let side = menuBarIconSide
+        let target = NSSize(width: side, height: side)
+        let srcSize = source.size
+        guard srcSize.width > 0, srcSize.height > 0 else {
+            source.isTemplate = asTemplate
+            return source
+        }
+
+        let image = NSImage(size: target, flipped: false) { _ in
+            let scale = min(side / srcSize.width, side / srcSize.height)
+            let w = srcSize.width * scale
+            let h = srcSize.height * scale
+            let x = (side - w) / 2
+            let y = (side - h) / 2
+            let dest = NSRect(x: x, y: y, width: w, height: h)
+            let srcRect = NSRect(origin: .zero, size: srcSize)
+            source.draw(
+                in: dest,
+                from: srcRect,
+                operation: .sourceOver,
+                fraction: 1,
+                respectFlipped: true,
+                hints: [.interpolation: NSImageInterpolation.high]
+            )
+            return true
+        }
+        image.isTemplate = asTemplate
+        return image
+    }
+
+    @objc private func appIconClicked(_ sender: NSButton) {
+        guard let raw = sender.identifier?.rawValue,
+              let id = UUID(uuidString: raw) else { return }
+
+        guard let event = NSApp.currentEvent else {
+            openInAppBrowser(appID: id)
+            return
+        }
+
+        if event.type == .rightMouseUp {
+            let menu = NSMenu()
+            let removeItem = NSMenuItem(
+                title: "Remove from Status Bar",
+                action: #selector(removeFromMenuBar(_:)),
+                keyEquivalent: ""
+            )
+            removeItem.target = self
+            removeItem.representedObject = raw as NSString
+            menu.addItem(removeItem)
+            let origin = NSPoint(x: 0, y: sender.bounds.height)
+            menu.popUp(positioning: nil, at: origin, in: sender)
+        } else {
+            openInAppBrowser(appID: id)
+        }
+    }
+
+    @objc private func removeFromMenuBar(_ sender: NSMenuItem) {
+        guard let raw = sender.representedObject as? String,
+              let id = UUID(uuidString: raw) else { return }
+        var ids = LauncherApp.statusBarShortcutIDSet()
+        ids.remove(id)
+        LauncherApp.persistStatusBarShortcutIDs(ids)
+    }
+
+    private func openInAppBrowser(appID: UUID) {
+        guard let app = LauncherApp.allLaunchableAppsFromUserDefaults().first(where: { $0.id == appID }),
+              let url = app.webURL else { return }
+        NSApp.setActivationPolicy(.regular)
+        NSApp.activate(ignoringOtherApps: true)
+        InAppBrowserWindowManager.shared.open(url: url, title: app.name)
+    }
+}

+ 2 - 0
google_apps/google_apps.entitlements

@@ -8,5 +8,7 @@
 	<true/>
 	<key>com.apple.security.files.downloads.read-write</key>
 	<true/>
+	<key>com.apple.security.files.user-selected.read-write</key>
+	<true/>
 </dict>
 </plist>