diff --git a/README.md b/README.md index 26dee49..bbf0d2f 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,9 @@ Activation uses the native macOS hotkey API, without an Accessibility permission ## Launch at login -Add `/Applications/Conjure.app` in **System Settings → General → Login Items & Extensions → Open at Login**. Conjure recognizes the native [login-launch Apple event](https://developer.apple.com/documentation/coreservices/1556410-launch_apple_event_constants) and starts without showing a window or taking focus, while still registering its saved global shortcut. Opening it manually from Finder still shows the launcher, including when it is already running. +Open Settings with `⌘,` and enable **Launch at login**. Conjure uses macOS's native login-item service and reads its actual registration status rather than storing a separate preference. If macOS requires approval, Settings explains this and offers **Open Login Items Settings…**. Registration errors are shown beside the control, and the checkbox returns to the actual system state. Install Conjure in `/Applications` before enabling it; disable the toggle to unregister it. -If shortcut registration fails at login, the error is retained and shown in Settings when you next open Conjure manually. Normal manual launches continue to report startup errors immediately. Conjure does not manage the login-item setting itself. +Conjure recognizes the native [login-launch Apple event](https://developer.apple.com/documentation/coreservices/1556410-launch_apple_event_constants) and starts without showing a window or taking focus, while still registering its saved global shortcut. Opening it manually from Finder still shows the launcher, including when it is already running. If shortcut registration fails at login, the error is retained and shown in Settings when you next open Conjure manually. Normal manual launches continue to report startup errors immediately. ## Discovery and search @@ -79,6 +79,8 @@ The launcher keeps its Raycast-inspired 750 × 474-point, titlebar-free rounded The application icon uses the same orange `sparkles` SF Symbol on a self-contained, rounded dark-brown tile. `Scripts/icon.swift` renders every standard and Retina icon size, and `make build` packages the resulting `.icns` in the signed bundle for Finder and other system icon surfaces. Conjure remains an accessory app; adding an application icon does not add a Dock or app-switcher entry. +Settings shares the launcher's dark appearance, frosted HUD material, Sunset background glows, cream headings, and orange control accents. Native window controls and shortcut recording behavior remain available. + The brown tint is 35% opaque over an active, behind-window `NSVisualEffectView` using the native HUD material. This exposes macOS's blurred backdrop while retaining the warm palette and glows; the system material supplies an opaque fallback when Reduce Transparency is enabled. Both the panel and launch pill use native rounded alpha masks alongside layer clipping, keeping material inside its rounded outline and making the window shadow follow the same shape. AppKit still provides text editing, table navigation, scrolling, application icons, controls, menus, fonts, and visual-effect materials. The background uses cached native radial gradients that fade to transparent: static soft blobs, without animations, timers, image assets, blur filters, or third-party UI dependencies. Decorative drawing does not intercept clicks. Increased-contrast settings strengthen the selection outline, and text colors are tuned for readability across the glows and selection. diff --git a/Sources/Conjure/SettingsWindowController.swift b/Sources/Conjure/SettingsWindowController.swift index 7934a1c..4d82e61 100644 --- a/Sources/Conjure/SettingsWindowController.swift +++ b/Sources/Conjure/SettingsWindowController.swift @@ -1,5 +1,6 @@ import AppKit import ConjureKit +import ServiceManagement @MainActor final class SettingsWindowController: NSWindowController, NSWindowDelegate { @@ -8,16 +9,24 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { private let clearButton = NSButton(title: "Clear", target: nil, action: nil) private let statusLabel = NSTextField(wrappingLabelWithString: "") private let errorLabel = NSTextField(wrappingLabelWithString: "") + private let loginToggle = NSButton(checkboxWithTitle: "Launch at login", target: nil, action: nil) + private let loginStatus = NSTextField(wrappingLabelWithString: "") + private let loginSettings = NSButton(title: "Open Login Items Settings…", target: nil, action: nil) init(shortcut: GlobalShortcut) { self.shortcut = shortcut let window = ShortcutSettingsWindow( - contentRect: NSRect(x: 0, y: 0, width: 580, height: 520), + contentRect: NSRect(x: 0, y: 0, width: 620, height: 640), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false ) window.title = "Conjure Settings" + window.appearance = NSAppearance(named: .darkAqua) + window.titlebarAppearsTransparent = true + window.backgroundColor = .clear + window.isOpaque = false + window.isMovableByWindowBackground = true window.isReleasedWhenClosed = false window.center() super.init(window: window) @@ -43,6 +52,10 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { window?.makeKeyAndOrderFront(nil) } + func windowDidBecomeKey(_ notification: Notification) { + refreshLogin() + } + func windowDidResignKey(_ notification: Notification) { cancelRecording() } @@ -94,15 +107,44 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { ) keyboardSettings.bezelStyle = .rounded + loginToggle.target = self + loginToggle.action = #selector(toggleLogin) + loginSettings.target = self + loginSettings.action = #selector(openLoginSettings) + loginSettings.bezelStyle = .rounded + loginStatus.setAccessibilityLabel("Launch at login status") + + heading.font = .systemFont(ofSize: 20, weight: .semibold) + heading.textColor = SunsetPalette.text + for label in [instructions, statusLabel, spotlight, loginStatus] { + label.textColor = SunsetPalette.secondaryText + } + for button in [recorder, commandSpaceButton, clearButton, keyboardSettings, loginToggle, loginSettings] { + button.contentTintColor = SunsetPalette.accent + } + let loginHeading = NSTextField(labelWithString: "Startup") + loginHeading.font = .systemFont(ofSize: 20, weight: .semibold) + loginHeading.textColor = SunsetPalette.text + let divider = NSBox() + divider.boxType = .separator + let stack = NSStackView(views: [ - heading, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings + heading, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings, + divider, loginHeading, loginToggle, loginStatus, loginSettings ]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = 12 stack.translatesAutoresizingMaskIntoConstraints = false - let content = NSView() + let content = NSVisualEffectView() + content.material = .hudWindow + content.blendingMode = .behindWindow + content.state = .active window.contentView = content + let background = LauncherBackground(frame: content.bounds) + background.autoresizingMask = [.width, .height] + background.setAccessibilityElement(false) + content.addSubview(background) content.addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 24), @@ -111,12 +153,51 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -24), recorder.widthAnchor.constraint(greaterThanOrEqualToConstant: 190) ]) - for label in [instructions, statusLabel, errorLabel, spotlight] { + for label in [instructions, statusLabel, errorLabel, spotlight, loginStatus] { label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } } + private func refreshLogin() { + let status = SMAppService.mainApp.status + loginToggle.state = (status == .enabled || status == .requiresApproval) ? .on : .off + loginSettings.isHidden = status != .requiresApproval + switch status { + case .enabled: + loginStatus.stringValue = "Conjure starts in the background when you log in." + case .requiresApproval: + loginStatus.stringValue = "Allow Conjure in System Settings → General → Login Items & Extensions." + case .notRegistered: + loginStatus.stringValue = "Start Conjure automatically so your global shortcut is available." + case .notFound: + loginStatus.stringValue = "Install Conjure in Applications before enabling launch at login." + @unknown default: + loginStatus.stringValue = "Launch at login status is unavailable." + } + } + + @objc private func toggleLogin() { + cancelRecording() + do { + if loginToggle.state == .on { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + refreshLogin() + } catch { + refreshLogin() + loginStatus.stringValue = "Could not change launch at login: \(error.localizedDescription)" + } + } + + @objc private func openLoginSettings() { + cancelRecording() + SMAppService.openSystemSettingsLoginItems() + } + private func refresh() { + refreshLogin() let name = shortcut.shortcut?.displayName recorder.title = recorder.isRecording ? "Type shortcut…" : (name ?? "Record Shortcut…") recorder.setAccessibilityValue(name ?? "Not set")