From 15a2aa92f7b64ac172729413d264226ea7aba99c Mon Sep 17 00:00:00 2001 From: TZGyn Date: Mon, 26 Oct 2026 20:46:02 +0800 Subject: [PATCH] Open on startup by default and block duplicate workspace names - LoginItem registers on the first bundled launch, so the app opens on startup without the user finding the toggle; a user who turns it off is never overridden. A never-registered bundle reports SMAppService .notFound rather than .notRegistered, so the guard only treats enabled/approval as done. --diag now prints the raw status. - New Workspace refuses a name that already exists (case-insensitive) and says so inline; Return cannot bypass it. The composer's '# Name' still switches to the existing workspace, which is the quick-capture gesture. --- AGENTS.md | 11 +++++++++-- Sources/Prompt/App.swift | 27 +++++++++++++++++++++++++++ Sources/Prompt/PanelView.swift | 24 +++++++++++++++++++++--- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7aec921..718245d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,8 +13,10 @@ clicking an item copies it back to the clipboard. `NSStatusItem Preferred Position Prompt`). Left click toggles the panel, right click shows the menu (accessibility state, launch at login, open data folder, quit). Contains the single-instance guard and `LoginItem` - (`SMAppService`). The sync panel also carries a Launch at Login toggle (the - app's only settings surface). Also sets `NSApp.mainMenu` with Hide Panel (Cmd+W) and Quit + (`SMAppService`): the app auto-registers as a login item on the first + bundled launch (once, tracked by `loginItem.autoRegistered` in UserDefaults) + so it opens on startup by default; the Launch at Login toggle in the sync + panel (the app's only settings surface) is the opt-out. Also sets `NSApp.mainMenu` with Hide Panel (Cmd+W) and Quit (Cmd+Q) — an accessory app shows no menu bar, but the main menu still services key equivalents, which is the only way to quit from the keyboard while the panel has focus. `applicationShouldHandleReopen` shows the panel so @@ -239,6 +241,11 @@ brew upgrade --cask prompt populated when the command fires. - `captureSelection()` blocks up to 0.5s polling the pasteboard — keep it off the main thread (the hotkey handler already dispatches to a global queue). +- **`SMAppService.mainApp.status` is `.notFound` for a bundle BTM has never + seen**, not `.notRegistered`, and `register()` still works from that state. + Only treat `.enabled` and `.requiresApproval` as "already done"; checking for + `.notRegistered` silently skips the registration. `Prompt --diag` prints the + raw status. - The double-Shift tap needs Accessibility; without it the event tap silently fails and only the Carbon fallbacks work. The panel banner and the status menu both surface the permission state, and `AppDelegate` shows a launch diff --git a/Sources/Prompt/App.swift b/Sources/Prompt/App.swift index f8ec1b9..c8e22f9 100644 --- a/Sources/Prompt/App.swift +++ b/Sources/Prompt/App.swift @@ -61,6 +61,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { setupStatusItem() promptForAccessibilityIfNeeded() + LoginItem.autoRegisterOnce() // Let SwiftUI finish installing its main menu, then augment it. DispatchQueue.main.async { self.installMenuItems() } @@ -140,6 +141,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @MainActor private func writeDiagnostics() { var lines = ["Prompt diagnostics", "accessibility: \(AXIsProcessTrusted())"] + lines.append("login item status: \(SMAppService.mainApp.status.rawValue)") if let window = statusItem?.button?.window { lines.append("status item frame: \(window.frame)") if let screen = window.screen ?? NSScreen.main { @@ -266,6 +268,31 @@ enum LoginItem { SMAppService.mainApp.status == .enabled } + /// Registers the app as a login item on first launch so Prompt opens on + /// startup without the user hunting for the toggle. Runs once (tracked in + /// UserDefaults) so a user who disables it is never overridden, and skips + /// `swift run`, where there is no bundle to register. + static func autoRegisterOnce() { + let key = "loginItem.autoRegistered" + guard Bundle.main.bundleIdentifier != nil, + !UserDefaults.standard.bool(forKey: key) else { return } + // A never-registered bundle reports `.notFound` on current macOS + // (BTM has no record), not `.notRegistered`, so only skip when there + // is nothing left to do. + switch SMAppService.mainApp.status { + case .enabled, .requiresApproval: + UserDefaults.standard.set(true, forKey: key) + default: + do { + try SMAppService.mainApp.register() + UserDefaults.standard.set(true, forKey: key) + NSLog("Prompt: registered as a login item (%d)", SMAppService.mainApp.status.rawValue) + } catch { + NSLog("Prompt: login item registration failed: %@", error.localizedDescription) + } + } + } + static func toggle() -> Bool { let service = SMAppService.mainApp do { diff --git a/Sources/Prompt/PanelView.swift b/Sources/Prompt/PanelView.swift index 28d4f99..06d370d 100644 --- a/Sources/Prompt/PanelView.swift +++ b/Sources/Prompt/PanelView.swift @@ -616,6 +616,19 @@ struct PanelView: View { // MARK: - New workspace + private var trimmedWorkspaceName: String { + newWorkspaceName.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Case-insensitive, like the store's dedupe. The modal blocks creation + /// with a hint instead of silently switching to the existing workspace. + private var workspaceNameExists: Bool { + let name = trimmedWorkspaceName + return !name.isEmpty && store.sections.contains { + $0.name.caseInsensitiveCompare(name) == .orderedSame + } + } + @ViewBuilder private var newWorkspaceModal: some View { if showNewWorkspace { @@ -630,12 +643,17 @@ struct PanelView: View { .focused($focusedField, equals: .newWorkspace) .onSubmit(createWorkspace) .onExitCommand { dismissNewWorkspace() } + if workspaceNameExists { + Text("A workspace named “\(trimmedWorkspaceName)” already exists.") + .font(.caption) + .foregroundStyle(.red) + } HStack { Spacer() Button("Cancel") { dismissNewWorkspace() } Button("Create") { createWorkspace() } .keyboardShortcut(.defaultAction) - .disabled(newWorkspaceName.trimmingCharacters(in: .whitespaces).isEmpty) + .disabled(trimmedWorkspaceName.isEmpty || workspaceNameExists) } } .padding(16) @@ -649,8 +667,8 @@ struct PanelView: View { } private func createWorkspace() { - let name = newWorkspaceName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty else { return } + let name = trimmedWorkspaceName + guard !name.isEmpty, !workspaceNameExists else { return } store.createSection(named: name) dismissNewWorkspace() }