diff --git a/Assets/Info.plist b/Assets/Info.plist
index 0d6dd3b..4dd70f8 100644
--- a/Assets/Info.plist
+++ b/Assets/Info.plist
@@ -22,9 +22,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.2.0
+ 1.3.0
CFBundleVersion
- 17
+ 18
LSMinimumSystemVersion
14.0
LSUIElement
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d772f4f..240ee2b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@ All notable changes to this project are documented here.
## [Unreleased]
+## [1.3.0] - 2026-09-25
+
+### Changed
+
+- First launch asks for Accessibility in one setup window with a short reason. Open Settings shows a guide inside System Settings that follows its window and lets you drag Encaje's icon into the list; once access is on, a short welcome plays and closes by itself. Macs where Encaje is already set up skip it.
+
## [1.2.0] - 2026-09-23
### Added
diff --git a/Sources/EncajeApp/AppModel.swift b/Sources/EncajeApp/AppModel.swift
index 8e6b8fe..e2eeacf 100644
--- a/Sources/EncajeApp/AppModel.swift
+++ b/Sources/EncajeApp/AppModel.swift
@@ -60,7 +60,7 @@ final class AppModel: ObservableObject {
let suite = ProcessInfo.processInfo.environment["ENCAJE_DEFAULTS_SUITE"]
defaults = suite.flatMap(UserDefaults.init(suiteName:)) ?? .standard
language = AppLanguage(rawValue: defaults.string(forKey: "language") ?? "") ?? .system
- permissions = PermissionCoordinator(defaults: defaults)
+ permissions = PermissionCoordinator()
gap = defaults.object(forKey: "gap") as? Double ?? 0
animatesWindows = defaults.object(forKey: "animatesWindows") as? Bool ?? true
exclusions = defaults.string(forKey: "exclusions") ?? ""
@@ -79,15 +79,6 @@ final class AppModel: ObservableObject {
applyHotkeys()
}
- var welcomeComplete: Bool {
- get { defaults.bool(forKey: "welcomeComplete") }
- set {
- defaults.set(newValue, forKey: "welcomeComplete")
- if newValue { defaults.removeObject(forKey: "permissionSetupPending") }
- }
- }
-
- var setupPending: Bool { defaults.bool(forKey: "permissionSetupPending") }
var ready: Bool { permissions.granted && shortcutReady }
func refresh() {
@@ -225,7 +216,6 @@ final class AppModel: ObservableObject {
func stop() {
hotkeys.stop()
- permissions.dismiss()
}
func refreshLoginState() {
diff --git a/Sources/EncajeApp/EncajeApp.swift b/Sources/EncajeApp/EncajeApp.swift
index aa1e766..e14de9b 100644
--- a/Sources/EncajeApp/EncajeApp.swift
+++ b/Sources/EncajeApp/EncajeApp.swift
@@ -49,13 +49,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe
private var model: AppModel!
private var statusItem: NSStatusItem!
private var settingsWindow: NSWindow?
- private var welcomeWindow: NSWindow?
+ private var permissionFlow: PermissionFlow!
private var refreshTimer: Timer?
private let menuPopover = MenuBarPopover()
private let aboutWindow = AboutWindowController()
func applicationDidFinishLaunching(_ notification: Notification) {
model = AppModel()
+ permissionFlow = makePermissionFlow()
UpdateManager.shared.start()
configureApplicationMenu()
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
@@ -69,16 +70,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe
self.configureApplicationMenu()
self.rebuildMenu()
self.settingsWindow?.title = localized("Encaje Settings", "Ajustes de Encaje")
- self.welcomeWindow?.title = localized("Welcome to Encaje", "Bienvenido a Encaje")
}
model.onMenuChange = { [weak self] in self?.rebuildMenu() }
rebuildMenu()
NSWorkspace.shared.notificationCenter.addObserver(
self, selector: #selector(workspaceActivated),
name: NSWorkspace.didActivateApplicationNotification, object: nil)
- if !model.welcomeComplete || !model.permissions.granted || model.setupPending {
- showWelcome()
- }
+ permissionFlow.presentIfNeeded()
if ProcessInfo.processInfo.environment["ENCAJE_SHOW_SETTINGS"] == "1" { showSettings() }
if ProcessInfo.processInfo.environment["ENCAJE_SHOW_POPOVER"] == "1" { toggleMenuPopover() }
}
@@ -231,21 +229,46 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe
@objc private func showWelcome() {
menuPopover.close(restoreFocus: false)
model.refresh()
- if welcomeWindow == nil {
- let view = WelcomeView(
- model: model, permissions: model.permissions,
- sourceFrame: { [weak self] in self?.welcomeWindow?.frame },
- finish: { [weak self] in
- self?.model.permissions.dismiss()
- self?.welcomeWindow?.close()
- })
- welcomeWindow = makeWindow(
- title: localized("Welcome to Encaje", "Bienvenido a Encaje"),
- size: NSSize(width: 510, height: 590),
- view: view)
- welcomeWindow?.identifier = NSUserInterfaceItemIdentifier("encaje.welcome")
- }
- present(welcomeWindow)
+ permissionFlow.present()
+ }
+
+ private func makePermissionFlow() -> PermissionFlow {
+ let flow = PermissionFlow(
+ configuration: PermissionFlowConfiguration(
+ appName: "Encaje",
+ icon: AppArtwork.icon,
+ accent: .teal,
+ items: [
+ PermissionFlowItem(
+ .accessibility,
+ reason: PermissionFlowText(
+ "Move and resize the windows of your other apps.",
+ "Mover y redimensionar las ventanas de tus otras apps."))
+ ],
+ defaults: AppLanguage.defaults,
+ language: { localized("en", "es") == "es" ? .spanish : .english },
+ tagline: PermissionFlowText(
+ "Everything fits. Press Shift + S to maximize the current window.",
+ "Todo encaja. Pulsa Shift + S para maximizar la ventana actual."),
+ welcomeDuration: 4.5,
+ legacyCompletionKeys: ["welcomeComplete"],
+ note: PermissionFlowText(
+ "Encaje's shortcuts replace their uppercase letters. Pause or customize them from the menu bar.",
+ "Los atajos de Encaje reemplazan sus letras mayúsculas. Páusalos o cámbialos desde la barra de menú."
+ ),
+ isReady: { [weak self] in self?.model.ready ?? false },
+ pendingRelaunch: { [weak self] in
+ guard let model = self?.model else { return false }
+ return model.permissions.restartSuggested && !model.shortcutReady
+ },
+ menuBarAnchor: { [weak self] in
+ guard let button = self?.statusItem?.button, let window = button.window else {
+ return nil
+ }
+ return window.convertToScreen(button.convert(button.bounds, to: nil))
+ }))
+ flow.model.onGranted = { [weak self] _ in self?.model.refresh() }
+ return flow
}
private func makeWindow(title: String, size: NSSize, view: V) -> NSWindow {
@@ -280,12 +303,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe
func windowWillClose(_ notification: Notification) {
guard let closed = notification.object as? NSWindow else { return }
- if closed === welcomeWindow {
- model.permissions.dismiss()
- welcomeWindow = nil
- }
if closed === settingsWindow { settingsWindow = nil }
- if welcomeWindow == nil && settingsWindow == nil {
+ if settingsWindow == nil {
refreshTimer?.invalidate()
refreshTimer = nil
}
diff --git a/Sources/EncajeApp/PermissionCoordinator.swift b/Sources/EncajeApp/PermissionCoordinator.swift
new file mode 100644
index 0000000..c459eb4
--- /dev/null
+++ b/Sources/EncajeApp/PermissionCoordinator.swift
@@ -0,0 +1,28 @@
+import AppKit
+import ApplicationServices
+import Combine
+
+@MainActor
+final class PermissionCoordinator: NSObject, ObservableObject {
+ @Published private(set) var granted = AXIsProcessTrusted()
+ @Published private(set) var restartSuggested = false
+
+ override init() {
+ super.init()
+ NSWorkspace.shared.notificationCenter.addObserver(
+ self, selector: #selector(refresh),
+ name: NSWorkspace.didActivateApplicationNotification, object: nil
+ )
+ }
+
+ @objc func refresh() {
+ let trusted = AXIsProcessTrusted()
+ if granted != trusted { granted = trusted }
+ if !trusted && restartSuggested { restartSuggested = false }
+ }
+
+ func markRestartNeeded() {
+ refresh()
+ restartSuggested = granted
+ }
+}
diff --git a/Sources/EncajeApp/PermissionFlow/PermissionFlowGuide.swift b/Sources/EncajeApp/PermissionFlow/PermissionFlowGuide.swift
new file mode 100644
index 0000000..9697b33
--- /dev/null
+++ b/Sources/EncajeApp/PermissionFlow/PermissionFlowGuide.swift
@@ -0,0 +1,424 @@
+// Vendored from PermissionFlow; edit the canonical package and re-vendor.
+import AppKit
+import Combine
+import QuartzCore
+import SwiftUI
+
+@MainActor
+final class PermissionFlowGuide: NSObject {
+ private let panel: PermissionFlowGuidePanel
+ private let locator = PermissionFlowSettingsLocator()
+ private let sourceFrame: CGRect?
+ private var discoveryTimer: Timer?
+ private var displayLink: CADisplayLink?
+ private var presented = false
+ private var entering = false
+ private var completed = false
+
+ init(
+ kind: PermissionFlowKind, model: PermissionFlowModel, sourceFrame: CGRect?,
+ close: @escaping () -> Void
+ ) {
+ self.sourceFrame = sourceFrame
+ panel = PermissionFlowGuidePanel(kind: kind, model: model, close: close)
+ super.init()
+ }
+
+ func start() {
+ discoveryTimer = Timer.scheduledTimer(
+ timeInterval: 0.5, target: self, selector: #selector(discover), userInfo: nil, repeats: true)
+ NSWorkspace.shared.notificationCenter.addObserver(
+ self, selector: #selector(discover),
+ name: NSWorkspace.didActivateApplicationNotification, object: nil)
+ discover()
+ }
+
+ func stop() {
+ discoveryTimer?.invalidate()
+ discoveryTimer = nil
+ stopTracking()
+ NSWorkspace.shared.notificationCenter.removeObserver(self)
+ panel.orderOut(nil)
+ }
+
+ func showSuccess() {
+ completed = true
+ panel.state.success = true
+ }
+
+ @objc private func discover() {
+ guard !completed else { return }
+ guard let frame = locator.locate() else {
+ panel.orderOut(nil)
+ stopTracking()
+ return
+ }
+ position(frame)
+ guard panel.isVisible else {
+ stopTracking()
+ return
+ }
+ guard displayLink == nil else { return }
+ let link = panel.displayLink(target: self, selector: #selector(track))
+ link.add(to: .main, forMode: .common)
+ displayLink = link
+ updateRefreshRate()
+ }
+
+ @objc private func track() {
+ guard let frame = locator.locate() else {
+ if !completed { panel.orderOut(nil) }
+ stopTracking()
+ return
+ }
+ position(frame)
+ guard panel.isVisible else {
+ stopTracking()
+ return
+ }
+ updateRefreshRate()
+ }
+
+ private func stopTracking() {
+ displayLink?.invalidate()
+ displayLink = nil
+ }
+
+ private func updateRefreshRate() {
+ guard let displayLink else { return }
+ let rate = Float(min(120, max(30, panel.screen?.maximumFramesPerSecond ?? 60)))
+ if displayLink.preferredFrameRateRange.maximum != rate {
+ displayLink.preferredFrameRateRange = CAFrameRateRange(
+ minimum: min(60, rate), maximum: rate, preferred: rate)
+ }
+ }
+
+ private func position(_ settingsFrame: CGRect) {
+ guard !entering else { return }
+ let screen =
+ NSScreen.screens.max {
+ $0.frame.intersection(settingsFrame).coveredArea
+ < $1.frame.intersection(settingsFrame).coveredArea
+ } ?? NSScreen.main
+ guard let screen else { return }
+ let visible = screen.visibleFrame.insetBy(dx: 10, dy: 10)
+ guard let target = PermissionFlowPlacement.frame(settings: settingsFrame, visible: visible)
+ else {
+ panel.orderOut(nil)
+ return
+ }
+ if !presented {
+ presented = true
+ if !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion {
+ enter(to: target)
+ return
+ }
+ }
+ if panel.frame.size != target.size {
+ panel.setFrame(target, display: true)
+ } else if panel.frame.origin != target.origin {
+ panel.setFrameOrigin(target.origin)
+ }
+ if !panel.isVisible { panel.orderFrontRegardless() }
+ }
+
+ private func enter(to target: CGRect) {
+ entering = true
+ panel.alphaValue = 0
+ let start =
+ sourceFrame.map {
+ CGRect(
+ x: ($0.midX - target.width / 2).rounded(), y: ($0.midY - target.height / 2).rounded(),
+ width: target.width, height: target.height)
+ } ?? target.offsetBy(dx: 0, dy: -14)
+ panel.setFrame(start, display: false)
+ panel.orderFrontRegardless()
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = 0.34
+ context.timingFunction = CAMediaTimingFunction(controlPoints: 0.2, 0.9, 0.25, 1)
+ panel.animator().setFrame(target, display: true)
+ panel.animator().alphaValue = 1
+ } completionHandler: { [weak self] in
+ MainActor.assumeIsolated {
+ self?.entering = false
+ self?.panel.state.arrived += 1
+ }
+ }
+ }
+}
+
+enum PermissionFlowPlacement {
+ static let height: CGFloat = 112
+
+ static func frame(settings: CGRect, visible: CGRect) -> CGRect? {
+ let sidebar = min(240, max(180, settings.width * 0.31))
+ let left = max(settings.minX + sidebar + 20, visible.minX)
+ let right = min(settings.maxX - 20, visible.maxX)
+ let bottom = settings.minY + 20
+ let top = min(settings.maxY - 20, visible.maxY)
+ guard right - left >= 240, bottom >= visible.minY, top - bottom >= height else { return nil }
+ return CGRect(
+ x: left.rounded(.up), y: bottom.rounded(.up),
+ width: right.rounded(.down) - left.rounded(.up), height: height)
+ }
+}
+
+@MainActor
+final class PermissionFlowSettingsLocator {
+ private var windowID: CGWindowID?
+ private var ownerPID: pid_t?
+
+ func locate() -> CGRect? {
+ guard let app = NSWorkspace.shared.frontmostApplication,
+ app.bundleIdentifier == "com.apple.systempreferences"
+ else { return nil }
+ if app.processIdentifier != ownerPID {
+ ownerPID = app.processIdentifier
+ windowID = nil
+ }
+ if let windowID,
+ let windows = CGWindowListCopyWindowInfo(.optionIncludingWindow, windowID)
+ as? [[String: Any]],
+ let window = windows.first, let frame = frame(of: window, pid: app.processIdentifier)
+ {
+ return frame
+ }
+ windowID = nil
+ guard
+ let windows = CGWindowListCopyWindowInfo(
+ [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]
+ else { return nil }
+ for window in windows {
+ if let frame = frame(of: window, pid: app.processIdentifier) {
+ windowID = (window[kCGWindowNumber as String] as? NSNumber)?.uint32Value
+ return frame
+ }
+ }
+ return nil
+ }
+
+ private func frame(of window: [String: Any], pid: pid_t) -> CGRect? {
+ guard (window[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value == pid,
+ (window[kCGWindowLayer as String] as? NSNumber)?.intValue == 0,
+ (window[kCGWindowIsOnscreen as String] as? NSNumber)?.boolValue == true,
+ let bounds = window[kCGWindowBounds as String] as? [String: CGFloat],
+ let frame = CGRect(dictionaryRepresentation: bounds as CFDictionary),
+ frame.width > 300, frame.height > 250
+ else { return nil }
+ let top = NSScreen.screens.first?.frame.maxY ?? 0
+ return CGRect(x: frame.minX, y: top - frame.maxY, width: frame.width, height: frame.height)
+ }
+}
+
+@MainActor
+public final class PermissionFlowGuideState: ObservableObject {
+ @Published public var success = false
+ @Published public var arrived = 0
+
+ public init() {}
+}
+
+@MainActor
+final class PermissionFlowGuidePanel: NSPanel {
+ let state = PermissionFlowGuideState()
+
+ init(kind: PermissionFlowKind, model: PermissionFlowModel, close: @escaping () -> Void) {
+ super.init(
+ contentRect: CGRect(x: 0, y: 0, width: 420, height: PermissionFlowPlacement.height),
+ styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
+ isOpaque = false
+ backgroundColor = .clear
+ hasShadow = true
+ level = .floating
+ hidesOnDeactivate = false
+ collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
+ isReleasedWhenClosed = false
+ let hosting = NSHostingView(
+ rootView: PermissionFlowGuideCard(kind: kind, model: model, state: state, close: close))
+ hosting.sizingOptions = []
+ contentView = hosting
+ }
+
+ override var canBecomeKey: Bool { false }
+}
+
+public struct PermissionFlowGuideCard: View {
+ let kind: PermissionFlowKind
+ @ObservedObject var model: PermissionFlowModel
+ @ObservedObject var state: PermissionFlowGuideState
+ let close: () -> Void
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ public init(
+ kind: PermissionFlowKind, model: PermissionFlowModel, state: PermissionFlowGuideState,
+ close: @escaping () -> Void
+ ) {
+ self.kind = kind
+ self.model = model
+ self.state = state
+ self.close = close
+ }
+
+ private var text: PermissionFlowStrings { PermissionFlowStrings(model.language) }
+ private var name: String { model.configuration.appName }
+ private var draggable: Bool { kind.acceptsDraggedApp }
+ private var tint: Color { state.success ? .green : kind.color }
+
+ public var body: some View {
+ HStack(spacing: 16) {
+ tile
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(spacing: 6) {
+ if !state.success && draggable {
+ Image(systemName: "arrow.up")
+ .font(.system(size: 13, weight: .bold))
+ .symbolEffect(.bounce.up, options: .repeat(3), value: state.arrived)
+ }
+ Text(title).font(.system(size: 15, weight: .semibold))
+ }
+ .foregroundStyle(tint)
+ Text(detail)
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .id(state.success)
+ .transition(.opacity.combined(with: .offset(y: 4)))
+ Spacer(minLength: 0)
+ if !state.success {
+ Button(action: close) {
+ Image(systemName: "xmark")
+ .font(.system(size: 10, weight: .bold))
+ .foregroundStyle(.secondary)
+ .frame(width: 22, height: 22)
+ .background(.quaternary.opacity(0.6), in: Circle())
+ .contentShape(Circle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(text.dismissGuide)
+ }
+ }
+ .padding(.horizontal, 18)
+ .padding(.vertical, 14)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
+ .background(
+ RoundedRectangle(cornerRadius: 20, style: .continuous)
+ .fill(tint.opacity(0.07))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 20, style: .continuous)
+ .strokeBorder(tint.opacity(0.35), lineWidth: 1)
+ )
+ .animation(
+ reduceMotion ? nil : .spring(response: 0.38, dampingFraction: 0.8), value: state.success)
+ }
+
+ @ViewBuilder private var tile: some View {
+ ZStack {
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .fill(tint.opacity(0.10))
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .strokeBorder(
+ tint.opacity(0.55),
+ style: StrokeStyle(lineWidth: 1.5, dash: state.success || !draggable ? [] : [5, 4]))
+ if state.success {
+ Image(systemName: "checkmark.seal.fill")
+ .font(.system(size: 36, weight: .semibold))
+ .foregroundStyle(.white, .green)
+ .transition(.scale(scale: 0.4).combined(with: .opacity))
+ } else {
+ ZStack(alignment: .bottomTrailing) {
+ if draggable {
+ PermissionFlowDragIcon(
+ icon: model.configuration.icon, label: text.dragAccessibility(name, kind)
+ )
+ .frame(width: 52, height: 52)
+ } else {
+ Image(nsImage: model.configuration.icon).resizable().frame(width: 52, height: 52)
+ }
+ PermissionFlowBadge(kind: kind, size: 22)
+ .offset(x: 6, y: 6)
+ .allowsHitTesting(false)
+ }
+ .transition(.opacity)
+ }
+ }
+ .frame(width: 76, height: 76)
+ }
+
+ private var title: String {
+ if state.success { return text.accessGranted }
+ return draggable ? text.dragTitle(name) : text.toggleTitle(name)
+ }
+
+ private var detail: String {
+ if state.success { return text.returning(name) }
+ return draggable ? text.dragDetail(kind) : text.toggleDetail(name, kind)
+ }
+}
+
+struct PermissionFlowDragIcon: NSViewRepresentable {
+ let icon: NSImage
+ let label: String
+
+ func makeNSView(context: Context) -> PermissionFlowDragImageView {
+ let view = PermissionFlowDragImageView()
+ view.image = icon
+ view.imageScaling = .scaleProportionallyUpOrDown
+ view.setAccessibilityLabel(label)
+ return view
+ }
+
+ func updateNSView(_ view: PermissionFlowDragImageView, context: Context) {
+ view.setAccessibilityLabel(label)
+ }
+
+ func sizeThatFits(
+ _ proposal: ProposedViewSize, nsView: PermissionFlowDragImageView, context: Context
+ ) -> CGSize? {
+ CGSize(width: proposal.width ?? 52, height: proposal.height ?? 52)
+ }
+}
+
+@MainActor
+final class PermissionFlowDragImageView: NSImageView, NSDraggingSource {
+ private var dragging = false
+
+ override var intrinsicContentSize: NSSize { NSSize(width: 52, height: 52) }
+
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
+
+ override func resetCursorRects() {
+ addCursorRect(bounds, cursor: .openHand)
+ }
+
+ override func mouseDown(with event: NSEvent) {}
+
+ override func mouseDragged(with event: NSEvent) {
+ guard !dragging, let image else { return }
+ let pasteboardItem = NSPasteboardItem()
+ pasteboardItem.setString(Bundle.main.bundleURL.absoluteString, forType: .fileURL)
+ let item = NSDraggingItem(pasteboardWriter: pasteboardItem)
+ item.setDraggingFrame(bounds, contents: image)
+ dragging = true
+ let session = beginDraggingSession(with: [item], event: event, source: self)
+ session.animatesToStartingPositionsOnCancelOrFail = true
+ }
+
+ func draggingSession(
+ _ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext
+ ) -> NSDragOperation {
+ .copy
+ }
+
+ func draggingSession(
+ _ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation
+ ) {
+ dragging = false
+ }
+}
+
+extension CGRect {
+ fileprivate var coveredArea: CGFloat { isNull ? 0 : width * height }
+}
diff --git a/Sources/EncajeApp/PermissionFlow/PermissionFlowKind.swift b/Sources/EncajeApp/PermissionFlow/PermissionFlowKind.swift
new file mode 100644
index 0000000..deca1f5
--- /dev/null
+++ b/Sources/EncajeApp/PermissionFlow/PermissionFlowKind.swift
@@ -0,0 +1,170 @@
+// Vendored from PermissionFlow; edit the canonical package and re-vendor.
+import AppKit
+import SwiftUI
+
+public enum PermissionFlowKind: String, CaseIterable, Sendable {
+ case accessibility
+ case fullDiskAccess
+ case automation
+ case microphone
+ case localNetwork
+ case inputMonitoring
+ case screenRecording
+
+ var order: Int { Self.allCases.firstIndex(of: self) ?? 0 }
+
+ var color: Color { Color(nsColor: nsColor) }
+
+ var nsColor: NSColor {
+ switch self {
+ case .accessibility: .systemBlue
+ case .fullDiskAccess: .systemBrown
+ case .automation: .systemPink
+ case .microphone: .systemOrange
+ case .localNetwork: .systemCyan
+ case .inputMonitoring: .systemPurple
+ case .screenRecording: .systemRed
+ }
+ }
+
+ var symbol: String {
+ switch self {
+ case .accessibility: "accessibility"
+ case .fullDiskAccess: "externaldrive.fill"
+ case .automation: "gearshape.2.fill"
+ case .microphone: "mic.fill"
+ case .localNetwork: "network"
+ case .inputMonitoring: "keyboard.fill"
+ case .screenRecording: "rectangle.dashed.badge.record"
+ }
+ }
+
+ var settingsAnchor: String {
+ switch self {
+ case .accessibility: "Privacy_Accessibility"
+ case .fullDiskAccess: "Privacy_AllFiles"
+ case .automation: "Privacy_Automation"
+ case .microphone: "Privacy_Microphone"
+ case .localNetwork: "Privacy_LocalNetwork"
+ case .inputMonitoring: "Privacy_ListenEvent"
+ case .screenRecording: "Privacy_ScreenCapture"
+ }
+ }
+
+ var settingsURL: URL? {
+ URL(string: "x-apple.systempreferences:com.apple.preference.security?\(settingsAnchor)")
+ }
+
+ var acceptsDraggedApp: Bool {
+ switch self {
+ case .accessibility, .fullDiskAccess, .inputMonitoring, .screenRecording: true
+ case .automation, .microphone, .localNetwork: false
+ }
+ }
+
+ var mayRequireRelaunch: Bool { self == .screenRecording || self == .inputMonitoring }
+}
+
+public enum PermissionFlowStatus: Equatable, Sendable {
+ case granted
+ case notDetermined
+ case denied
+ case unknown
+}
+
+public enum PermissionFlowLanguage: Sendable {
+ case english
+ case spanish
+
+ public static var system: PermissionFlowLanguage {
+ Locale.preferredLanguages.first?.hasPrefix("es") == true ? .spanish : .english
+ }
+}
+
+public struct PermissionFlowText: Sendable, Equatable {
+ public let english: String
+ public let spanish: String
+
+ public init(_ english: String, _ spanish: String) {
+ self.english = english
+ self.spanish = spanish
+ }
+
+ public func resolve(_ language: PermissionFlowLanguage) -> String {
+ language == .spanish ? spanish : english
+ }
+}
+
+public struct PermissionFlowItem: Identifiable {
+ public let kind: PermissionFlowKind
+ public var reason: PermissionFlowText
+ public var required: Bool
+ public var status: (@MainActor () -> PermissionFlowStatus)?
+ public var request: (@MainActor (@escaping @MainActor () -> Void) -> Void)?
+
+ public var id: PermissionFlowKind { kind }
+
+ public init(
+ _ kind: PermissionFlowKind,
+ reason: PermissionFlowText,
+ required: Bool = true,
+ status: (@MainActor () -> PermissionFlowStatus)? = nil,
+ request: (@MainActor (@escaping @MainActor () -> Void) -> Void)? = nil
+ ) {
+ self.kind = kind
+ self.reason = reason
+ self.required = required
+ self.status = status
+ self.request = request
+ }
+}
+
+public struct PermissionFlowConfiguration {
+ public var appName: String
+ public var icon: NSImage
+ public var accent: Color
+ public var items: [PermissionFlowItem]
+ public var defaults: UserDefaults
+ public var language: @MainActor () -> PermissionFlowLanguage
+ public var tagline: PermissionFlowText?
+ public var welcomeDuration: TimeInterval
+ public var legacyCompletionKeys: [String]
+ public var automationTarget: String?
+ public var note: PermissionFlowText?
+ public var isReady: (@MainActor () -> Bool)?
+ public var pendingRelaunch: (@MainActor () -> Bool)?
+ public var menuBarAnchor: (@MainActor () -> CGRect?)?
+
+ @MainActor
+ public init(
+ appName: String,
+ icon: NSImage? = nil,
+ accent: Color = .accentColor,
+ items: [PermissionFlowItem],
+ defaults: UserDefaults = .standard,
+ language: @escaping @MainActor () -> PermissionFlowLanguage = { .system },
+ tagline: PermissionFlowText? = nil,
+ welcomeDuration: TimeInterval = 3.5,
+ legacyCompletionKeys: [String] = [],
+ automationTarget: String? = nil,
+ note: PermissionFlowText? = nil,
+ isReady: (@MainActor () -> Bool)? = nil,
+ pendingRelaunch: (@MainActor () -> Bool)? = nil,
+ menuBarAnchor: (@MainActor () -> CGRect?)? = nil
+ ) {
+ self.appName = appName
+ self.icon = icon ?? NSApplication.shared.applicationIconImage
+ self.accent = accent
+ self.items = items.sorted { $0.kind.order < $1.kind.order }
+ self.defaults = defaults
+ self.language = language
+ self.tagline = tagline
+ self.welcomeDuration = welcomeDuration
+ self.legacyCompletionKeys = legacyCompletionKeys
+ self.automationTarget = automationTarget
+ self.note = note
+ self.isReady = isReady
+ self.pendingRelaunch = pendingRelaunch
+ self.menuBarAnchor = menuBarAnchor
+ }
+}
diff --git a/Sources/EncajeApp/PermissionFlow/PermissionFlowModel.swift b/Sources/EncajeApp/PermissionFlow/PermissionFlowModel.swift
new file mode 100644
index 0000000..bab8063
--- /dev/null
+++ b/Sources/EncajeApp/PermissionFlow/PermissionFlowModel.swift
@@ -0,0 +1,220 @@
+// Vendored from PermissionFlow; edit the canonical package and re-vendor.
+import AppKit
+import Combine
+import SwiftUI
+
+@MainActor
+public final class PermissionFlowModel: NSObject, ObservableObject {
+ public let configuration: PermissionFlowConfiguration
+ @Published public private(set) var statuses: [PermissionFlowKind: PermissionFlowStatus] = [:]
+ @Published public private(set) var requested: Set = []
+ @Published public private(set) var guiding: PermissionFlowKind?
+ @Published public private(set) var relaunchFailed = false
+ @Published public private(set) var ready = false
+ @Published public private(set) var relaunchPending = false
+ public var onGranted: ((PermissionFlowKind) -> Void)?
+ private var guide: PermissionFlowGuide?
+ private var refreshTimer: Timer?
+ private var guideDismissTimer: Timer?
+ private var monitoring = false
+
+ private static let completedKey = "permissionFlow.completed"
+ private static let resumeKey = "permissionFlow.resume"
+ private static let requestedKey = "permissionFlow.requested"
+
+ public init(configuration: PermissionFlowConfiguration) {
+ self.configuration = configuration
+ super.init()
+ requested = Set(
+ (configuration.defaults.stringArray(forKey: Self.requestedKey) ?? [])
+ .compactMap(PermissionFlowKind.init(rawValue:)))
+ refresh()
+ }
+
+ public var language: PermissionFlowLanguage { configuration.language() }
+ public var items: [PermissionFlowItem] { configuration.items }
+ public var requiredItems: [PermissionFlowItem] { items.filter(\.required) }
+
+ public func status(_ kind: PermissionFlowKind) -> PermissionFlowStatus {
+ statuses[kind] ?? .unknown
+ }
+
+ public func isSatisfied(_ kind: PermissionFlowKind) -> Bool {
+ switch status(kind) {
+ case .granted: true
+ case .unknown: requested.contains(kind)
+ case .notDetermined, .denied: false
+ }
+ }
+
+ public var allRequiredSatisfied: Bool { requiredItems.allSatisfy { isSatisfied($0.kind) } }
+ public var satisfiedCount: Int { items.filter { isSatisfied($0.kind) }.count }
+ public var next: PermissionFlowItem? {
+ let pending = items.filter { !isSatisfied($0.kind) && isAvailable($0.kind) }
+ return pending.first(where: \.required) ?? pending.first
+ }
+
+ public func isAvailable(_ kind: PermissionFlowKind) -> Bool {
+ guard kind.mayRequireRelaunch else { return true }
+ return items.allSatisfy {
+ $0.kind == kind || !$0.required || $0.kind.order > kind.order || isSatisfied($0.kind)
+ }
+ }
+
+ public func awaitingRelaunch(_ kind: PermissionFlowKind) -> Bool {
+ kind.mayRequireRelaunch && requested.contains(kind) && !isSatisfied(kind)
+ }
+
+ public var isCompleted: Bool {
+ configuration.defaults.bool(forKey: Self.completedKey)
+ || configuration.legacyCompletionKeys.contains { configuration.defaults.bool(forKey: $0) }
+ }
+
+ public var isResuming: Bool { configuration.defaults.bool(forKey: Self.resumeKey) }
+
+ public var shouldPresentAtLaunch: Bool {
+ refresh()
+ return !isCompleted || !ready || isResuming
+ }
+
+ public func markCompleted() {
+ configuration.defaults.set(true, forKey: Self.completedKey)
+ configuration.defaults.removeObject(forKey: Self.resumeKey)
+ }
+
+ @objc public func refresh() {
+ var updated: [PermissionFlowKind: PermissionFlowStatus] = [:]
+ for item in items {
+ updated[item.kind] =
+ item.status?()
+ ?? PermissionFlowProbe.status(
+ item.kind, automationTarget: configuration.automationTarget)
+ }
+ let newlyGranted = items.map(\.kind).filter {
+ updated[$0] == .granted && statuses[$0] != nil && statuses[$0] != .granted
+ }
+ if updated != statuses { statuses = updated }
+ let isReady = allRequiredSatisfied && (configuration.isReady?() ?? true)
+ if ready != isReady { ready = isReady }
+ let pending = !isReady && (configuration.pendingRelaunch?() ?? false)
+ if relaunchPending != pending { relaunchPending = pending }
+ for kind in newlyGranted { handleGranted(kind) }
+ }
+
+ public func request(_ kind: PermissionFlowKind, from sourceFrame: CGRect? = nil) {
+ guard let item = items.first(where: { $0.kind == kind }), isAvailable(kind) else { return }
+ markRequested(kind)
+ dismissGuide()
+ if let custom = item.request {
+ custom { [weak self] in self?.refresh() }
+ return
+ }
+ if PermissionFlowProbe.request(
+ kind, automationTarget: configuration.automationTarget,
+ completion: { [weak self] in self?.afterSystemPrompt() })
+ {
+ return
+ }
+ openSettings(kind, from: sourceFrame)
+ }
+
+ public func openSettings(_ kind: PermissionFlowKind, from sourceFrame: CGRect? = nil) {
+ if kind.mayRequireRelaunch { configuration.defaults.set(true, forKey: Self.resumeKey) }
+ guiding = kind
+ let guide = PermissionFlowGuide(
+ kind: kind, model: self, sourceFrame: sourceFrame,
+ close: { [weak self] in self?.dismissGuide() })
+ self.guide = guide
+ guide.start()
+ if let url = kind.settingsURL { NSWorkspace.shared.open(url) }
+ startMonitoring()
+ }
+
+ public func dismissGuide() {
+ guideDismissTimer?.invalidate()
+ guideDismissTimer = nil
+ guide?.stop()
+ guide = nil
+ guiding = nil
+ }
+
+ public func startMonitoring() {
+ guard !monitoring else { return }
+ monitoring = true
+ refreshTimer = Timer.scheduledTimer(
+ timeInterval: 0.5, target: self, selector: #selector(refresh), userInfo: nil, repeats: true)
+ NSWorkspace.shared.notificationCenter.addObserver(
+ self, selector: #selector(refresh),
+ name: NSWorkspace.didActivateApplicationNotification, object: nil)
+ }
+
+ public func stopMonitoring() {
+ monitoring = false
+ refreshTimer?.invalidate()
+ refreshTimer = nil
+ NSWorkspace.shared.notificationCenter.removeObserver(self)
+ if guideDismissTimer == nil { dismissGuide() }
+ }
+
+ public func relaunch() {
+ guard Bundle.main.bundleURL.pathExtension == "app" else {
+ relaunchFailed = true
+ return
+ }
+ configuration.defaults.set(true, forKey: Self.resumeKey)
+ configuration.defaults.synchronize()
+ let helper = Process()
+ helper.executableURL = URL(fileURLWithPath: "/bin/sh")
+ helper.arguments = [
+ "-c", "while kill -0 \"$1\" 2>/dev/null; do sleep 0.2; done; /usr/bin/open \"$2\"",
+ "permission-flow-relaunch", String(ProcessInfo.processInfo.processIdentifier),
+ Bundle.main.bundleURL.path,
+ ]
+ helper.standardInput = FileHandle.nullDevice
+ helper.standardOutput = FileHandle.nullDevice
+ helper.standardError = FileHandle.nullDevice
+ do {
+ try helper.run()
+ NSApp.terminate(nil)
+ } catch {
+ relaunchFailed = true
+ }
+ }
+
+ private func markRequested(_ kind: PermissionFlowKind) {
+ requested.insert(kind)
+ configuration.defaults.set(requested.map(\.rawValue).sorted(), forKey: Self.requestedKey)
+ }
+
+ private func afterSystemPrompt() {
+ refresh()
+ PermissionFlowActivation.bringForward()
+ }
+
+ private func handleGranted(_ kind: PermissionFlowKind) {
+ onGranted?(kind)
+ guard guiding == kind, let guide else { return }
+ guide.showSuccess()
+ guideDismissTimer?.invalidate()
+ guideDismissTimer = Timer.scheduledTimer(
+ timeInterval: 1.2, target: self, selector: #selector(finishGuide), userInfo: nil,
+ repeats: false)
+ }
+
+ @objc private func finishGuide() {
+ dismissGuide()
+ PermissionFlowActivation.bringForward()
+ }
+}
+
+@MainActor
+enum PermissionFlowActivation {
+ static func bringForward(_ window: NSWindow? = nil) {
+ NSApp.activate()
+ let windows =
+ window.map { [$0] }
+ ?? NSApp.orderedWindows.filter { $0.isVisible && $0.level == .normal }
+ for candidate in windows.reversed() { candidate.orderFrontRegardless() }
+ windows.first?.makeKey()
+ }
+}
diff --git a/Sources/EncajeApp/PermissionFlow/PermissionFlowProbe.swift b/Sources/EncajeApp/PermissionFlow/PermissionFlowProbe.swift
new file mode 100644
index 0000000..a63bf45
--- /dev/null
+++ b/Sources/EncajeApp/PermissionFlow/PermissionFlowProbe.swift
@@ -0,0 +1,121 @@
+// Vendored from PermissionFlow; edit the canonical package and re-vendor.
+import AVFoundation
+import AppKit
+import ApplicationServices
+import CoreGraphics
+import IOKit.hid
+import Network
+
+@MainActor
+enum PermissionFlowProbe {
+ static func status(_ kind: PermissionFlowKind, automationTarget: String?) -> PermissionFlowStatus
+ {
+ switch kind {
+ case .accessibility:
+ return AXIsProcessTrusted() ? .granted : .denied
+ case .screenRecording:
+ return CGPreflightScreenCaptureAccess() ? .granted : .denied
+ case .microphone:
+ switch AVCaptureDevice.authorizationStatus(for: .audio) {
+ case .authorized: return .granted
+ case .notDetermined: return .notDetermined
+ default: return .denied
+ }
+ case .inputMonitoring:
+ switch IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) {
+ case kIOHIDAccessTypeGranted: return .granted
+ case kIOHIDAccessTypeUnknown: return .notDetermined
+ default: return .denied
+ }
+ case .fullDiskAccess:
+ let path = NSHomeDirectory() + "/Library/Application Support/com.apple.TCC/TCC.db"
+ let descriptor = open(path, O_RDONLY)
+ guard descriptor >= 0 else { return .denied }
+ close(descriptor)
+ return .granted
+ case .automation:
+ guard let automationTarget else { return .unknown }
+ return automationStatus(target: automationTarget, ask: false)
+ case .localNetwork:
+ return .unknown
+ }
+ }
+
+ static func request(
+ _ kind: PermissionFlowKind, automationTarget: String?,
+ completion: @escaping @MainActor () -> Void
+ ) -> Bool {
+ switch kind {
+ case .microphone where status(kind, automationTarget: nil) == .notDetermined:
+ AVCaptureDevice.requestAccess(for: .audio) { _ in
+ Task { @MainActor in completion() }
+ }
+ return true
+ case .automation:
+ guard let target = automationTarget,
+ status(kind, automationTarget: target) != .denied
+ else { return false }
+ launchQuietly(bundleIdentifier: target)
+ Task.detached(priority: .userInitiated) {
+ _ = PermissionFlowProbe.automationDetermination(target: target, ask: true)
+ await MainActor.run { completion() }
+ }
+ return true
+ case .localNetwork:
+ LocalNetworkTrigger.shared.fire(completion: completion)
+ return true
+ default:
+ return false
+ }
+ }
+
+ private static func automationStatus(target: String, ask: Bool) -> PermissionFlowStatus {
+ switch automationDetermination(target: target, ask: ask) {
+ case noErr: return .granted
+ case OSStatus(errAEEventWouldRequireUserConsent): return .notDetermined
+ case OSStatus(errAEEventNotPermitted): return .denied
+ default: return .notDetermined
+ }
+ }
+
+ nonisolated static func automationDetermination(target: String, ask: Bool) -> OSStatus {
+ var descriptor = AEAddressDesc()
+ let bytes = Array(target.utf8)
+ let created = bytes.withUnsafeBufferPointer {
+ AECreateDesc(typeApplicationBundleID, $0.baseAddress, $0.count, &descriptor)
+ }
+ guard created == noErr else { return OSStatus(created) }
+ defer { AEDisposeDesc(&descriptor) }
+ return AEDeterminePermissionToAutomateTarget(
+ &descriptor, typeWildCard, typeWildCard, ask)
+ }
+
+ private static func launchQuietly(bundleIdentifier: String) {
+ guard NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier).isEmpty,
+ let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier)
+ else { return }
+ let configuration = NSWorkspace.OpenConfiguration()
+ configuration.activates = false
+ configuration.hides = true
+ NSWorkspace.shared.openApplication(at: url, configuration: configuration)
+ }
+}
+
+@MainActor
+private final class LocalNetworkTrigger {
+ static let shared = LocalNetworkTrigger()
+ private var browser: NWBrowser?
+
+ func fire(completion: @escaping @MainActor () -> Void) {
+ browser?.cancel()
+ let browser = NWBrowser(for: .bonjour(type: "_http._tcp", domain: nil), using: NWParameters())
+ self.browser = browser
+ browser.start(queue: .main)
+ Task { @MainActor [weak self] in
+ try? await Task.sleep(for: .seconds(2))
+ self?.browser?.cancel()
+ self?.browser = nil
+ completion()
+ }
+ }
+}
diff --git a/Sources/EncajeApp/PermissionFlow/PermissionFlowStrings.swift b/Sources/EncajeApp/PermissionFlow/PermissionFlowStrings.swift
new file mode 100644
index 0000000..7dc23fb
--- /dev/null
+++ b/Sources/EncajeApp/PermissionFlow/PermissionFlowStrings.swift
@@ -0,0 +1,118 @@
+// Vendored from PermissionFlow; edit the canonical package and re-vendor.
+import Foundation
+
+struct PermissionFlowStrings {
+ let language: PermissionFlowLanguage
+
+ init(_ language: PermissionFlowLanguage) {
+ self.language = language
+ }
+
+ private func pick(_ english: String, _ spanish: String) -> String {
+ language == .spanish ? spanish : english
+ }
+
+ func title(_ kind: PermissionFlowKind) -> String {
+ switch kind {
+ case .accessibility: pick("Accessibility", "Accesibilidad")
+ case .fullDiskAccess: pick("Full Disk Access", "Acceso total al disco")
+ case .automation: pick("Automation", "Automatización")
+ case .microphone: pick("Microphone", "Micrófono")
+ case .localNetwork: pick("Local Network", "Red local")
+ case .inputMonitoring: pick("Input Monitoring", "Monitoreo de entrada")
+ case .screenRecording: pick("Screen Recording", "Grabación de pantalla")
+ }
+ }
+
+ func windowTitle(_ app: String) -> String { pick("Set up \(app)", "Configura \(app)") }
+
+ func headline(_ app: String) -> String {
+ pick("Let's set up \(app)", "Vamos a configurar \(app)")
+ }
+
+ func subtitle(_ app: String, required: Int, optional: Int) -> String {
+ let need: String
+ switch required {
+ case 0:
+ need = pick(
+ "\(app) works better with these permissions", "\(app) funciona mejor con estos permisos")
+ case 1:
+ need = pick(
+ "\(app) needs one permission to work", "\(app) necesita un permiso para funcionar")
+ default:
+ need = pick(
+ "\(app) needs \(required) permissions to work",
+ "\(app) necesita \(required) permisos para funcionar")
+ }
+ let extra: String
+ switch (required, optional) {
+ case (0, _), (_, 0): extra = ""
+ case (_, 1): extra = pick(", plus one optional", " y uno opcional")
+ default: extra = pick(", plus \(optional) optional", " y \(optional) opcionales")
+ }
+ return need + extra + ".\n" + pick("You only do this once.", "Solo se hace una vez.")
+ }
+
+ func progress(_ done: Int, of total: Int) -> String {
+ pick("\(done) of \(total) ready", "\(done) de \(total) listos")
+ }
+
+ var allow: String { pick("Allow", "Permitir") }
+ var openSettings: String { pick("Open Settings", "Abrir Ajustes") }
+ var waiting: String { pick("Waiting…", "Esperando…") }
+ var granted: String { pick("Ready", "Listo") }
+ var requested: String { pick("Requested", "Solicitado") }
+ var optional: String { pick("Optional", "Opcional") }
+ var later: String { pick("Set up later", "Configurar después") }
+ var lockedUntilOthers: String {
+ pick(
+ "Comes last: macOS asks to reopen the app after this one.",
+ "Va al final: macOS pide reabrir la app después de este permiso.")
+ }
+ func reopen(_ app: String) -> String { pick("Reopen \(app)", "Reabrir \(app)") }
+ func reopenHint(_ app: String) -> String {
+ pick(
+ "Turned it on? Reopen \(app) to finish.",
+ "¿Ya lo activaste? Reabre \(app) para terminar.")
+ }
+ func finishWithReopen(_ app: String) -> String {
+ pick("Reopen \(app) to finish the setup.", "Reabre \(app) para terminar la configuración.")
+ }
+ var reopenFailed: String {
+ pick(
+ "Couldn't reopen automatically. Quit and open it again.",
+ "No se pudo reabrir. Ciérrala y ábrela de nuevo.")
+ }
+
+ func welcome(_ app: String) -> String { pick("Welcome to \(app)", "Bienvenido a \(app)") }
+ func ready(_ app: String) -> String {
+ pick(
+ "Everything is ready. \(app) is already working from your menu bar.",
+ "Todo listo. \(app) ya está funcionando desde tu barra de menús.")
+ }
+ func start(_ app: String) -> String { pick("Start using \(app)", "Empezar a usar \(app)") }
+ var closesSoon: String { pick("This window closes on its own", "Esta ventana se cierra sola") }
+
+ func dragTitle(_ app: String) -> String {
+ pick("Drag \(app) into the list above", "Arrastra \(app) a la lista de arriba")
+ }
+ func dragDetail(_ kind: PermissionFlowKind) -> String {
+ pick(
+ "Drop it in \(title(kind)), then turn on its switch.",
+ "Suéltalo en \(title(kind)) y activa su interruptor.")
+ }
+ func dragAccessibility(_ app: String, _ kind: PermissionFlowKind) -> String {
+ pick("Drag \(app) to the \(title(kind)) list", "Arrastra \(app) a la lista de \(title(kind))")
+ }
+ func toggleTitle(_ app: String) -> String {
+ pick("Turn on \(app)'s switch", "Activa el interruptor de \(app)")
+ }
+ func toggleDetail(_ app: String, _ kind: PermissionFlowKind) -> String {
+ pick(
+ "Find \(app) in the \(title(kind)) list above and turn it on.",
+ "Busca \(app) en la lista de \(title(kind)) de arriba y actívalo.")
+ }
+ var accessGranted: String { pick("Access granted", "Permiso concedido") }
+ func returning(_ app: String) -> String { pick("Back to \(app)…", "Volviendo a \(app)…") }
+ var dismissGuide: String { pick("Dismiss guide", "Cerrar guía") }
+}
diff --git a/Sources/EncajeApp/PermissionFlow/PermissionFlowViews.swift b/Sources/EncajeApp/PermissionFlow/PermissionFlowViews.swift
new file mode 100644
index 0000000..dde07bb
--- /dev/null
+++ b/Sources/EncajeApp/PermissionFlow/PermissionFlowViews.swift
@@ -0,0 +1,288 @@
+// Vendored from PermissionFlow; edit the canonical package and re-vendor.
+import AppKit
+import SwiftUI
+
+public struct PermissionFlowBadge: View {
+ let kind: PermissionFlowKind
+ var size: CGFloat
+
+ public init(kind: PermissionFlowKind, size: CGFloat = 36) {
+ self.kind = kind
+ self.size = size
+ }
+
+ public var body: some View {
+ let top = Color(nsColor: kind.nsColor.blended(withFraction: 0.22, of: .white) ?? kind.nsColor)
+ RoundedRectangle(cornerRadius: size * 0.27, style: .continuous)
+ .fill(LinearGradient(colors: [top, kind.color], startPoint: .top, endPoint: .bottom))
+ .overlay(
+ Image(systemName: kind.symbol)
+ .font(.system(size: size * 0.48, weight: .semibold))
+ .foregroundStyle(.white)
+ .shadow(color: .black.opacity(0.18), radius: 0.5, y: 0.5)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: size * 0.27, style: .continuous)
+ .strokeBorder(.white.opacity(0.22), lineWidth: 0.5)
+ )
+ .frame(width: size, height: size)
+ .shadow(color: kind.color.opacity(0.28), radius: size * 0.1, y: size * 0.05)
+ .accessibilityHidden(true)
+ }
+}
+
+public struct PermissionFlowChecklist: View {
+ @ObservedObject var model: PermissionFlowModel
+ let sourceFrame: () -> CGRect?
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @State private var appeared = false
+
+ public init(model: PermissionFlowModel, sourceFrame: @escaping () -> CGRect? = { nil }) {
+ self.model = model
+ self.sourceFrame = sourceFrame
+ }
+
+ public var body: some View {
+ VStack(spacing: 14) {
+ PermissionFlowProgress(model: model)
+ if model.relaunchPending {
+ PermissionFlowRelaunchBanner(model: model)
+ .transition(.opacity.combined(with: .move(edge: .top)))
+ }
+ VStack(spacing: 10) {
+ ForEach(Array(model.items.enumerated()), id: \.element.id) { index, item in
+ PermissionFlowCard(model: model, item: item, sourceFrame: sourceFrame)
+ .opacity(appeared ? 1 : 0)
+ .offset(y: appeared ? 0 : 10)
+ .animation(
+ reduceMotion
+ ? nil
+ : .spring(response: 0.5, dampingFraction: 0.86).delay(0.12 + Double(index) * 0.07),
+ value: appeared)
+ }
+ }
+ }
+ .onAppear {
+ appeared = true
+ model.refresh()
+ model.startMonitoring()
+ }
+ .onDisappear { model.stopMonitoring() }
+ .animation(
+ reduceMotion ? nil : .spring(response: 0.4, dampingFraction: 0.85),
+ value: model.relaunchPending)
+ }
+}
+
+struct PermissionFlowRelaunchBanner: View {
+ @ObservedObject var model: PermissionFlowModel
+
+ var body: some View {
+ let text = PermissionFlowStrings(model.language)
+ let name = model.configuration.appName
+ HStack(spacing: 10) {
+ Image(systemName: "arrow.clockwise.circle.fill")
+ .font(.system(size: 20))
+ .foregroundStyle(.white, model.configuration.accent)
+ Text(model.relaunchFailed ? text.reopenFailed : text.finishWithReopen(name))
+ .font(.system(size: 12, weight: .medium))
+ .fixedSize(horizontal: false, vertical: true)
+ Spacer(minLength: 8)
+ Button(text.reopen(name)) { model.relaunch() }
+ .buttonStyle(PermissionFlowButtonStyle(tint: model.configuration.accent, prominent: true))
+ }
+ .padding(12)
+ .background(
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .fill(model.configuration.accent.opacity(0.1))
+ )
+ }
+}
+
+struct PermissionFlowProgress: View {
+ @ObservedObject var model: PermissionFlowModel
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ var body: some View {
+ let text = PermissionFlowStrings(model.language)
+ HStack(spacing: 10) {
+ HStack(spacing: 4) {
+ ForEach(model.items) { item in
+ Capsule()
+ .fill(model.isSatisfied(item.kind) ? Color.green : Color.primary.opacity(0.1))
+ .frame(height: 5)
+ }
+ }
+ Text(text.progress(model.satisfiedCount, of: model.items.count))
+ .font(.system(size: 11, weight: .medium))
+ .monospacedDigit()
+ .foregroundStyle(.secondary)
+ .contentTransition(.numericText())
+ }
+ .animation(
+ reduceMotion ? nil : .spring(response: 0.45, dampingFraction: 0.8),
+ value: model.satisfiedCount
+ )
+ .accessibilityElement(children: .combine)
+ }
+}
+
+struct PermissionFlowCard: View {
+ @ObservedObject var model: PermissionFlowModel
+ let item: PermissionFlowItem
+ let sourceFrame: () -> CGRect?
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ private var kind: PermissionFlowKind { item.kind }
+ private var text: PermissionFlowStrings { PermissionFlowStrings(model.language) }
+ private var satisfied: Bool { model.isSatisfied(kind) }
+ private var available: Bool { model.isAvailable(kind) }
+ private var isNext: Bool { model.next?.kind == kind }
+ private var guiding: Bool { model.guiding == kind }
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 14) {
+ ZStack(alignment: .bottomTrailing) {
+ PermissionFlowBadge(kind: kind, size: 40)
+ .saturation(available || satisfied ? 1 : 0.35)
+ .opacity(available || satisfied ? 1 : 0.7)
+ if satisfied {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 17, weight: .bold))
+ .foregroundStyle(.white, .green)
+ .background(Circle().fill(Color(nsColor: .windowBackgroundColor)).padding(1))
+ .offset(x: 6, y: 6)
+ .transition(.scale(scale: 0.3).combined(with: .opacity))
+ }
+ }
+ VStack(alignment: .leading, spacing: 3) {
+ HStack(spacing: 6) {
+ Text(text.title(kind))
+ .font(.system(size: 14, weight: .semibold))
+ if !item.required {
+ Text(text.optional)
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 1.5)
+ .background(Capsule().fill(Color.primary.opacity(0.07)))
+ }
+ }
+ Text(item.reason.resolve(model.language))
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ if kind.mayRequireRelaunch {
+ relaunchLine
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ trailing
+ .frame(minWidth: 92, alignment: .trailing)
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 12)
+ .background(
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .fill(background)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .strokeBorder(border, lineWidth: isNext && !satisfied ? 1.5 : 1)
+ )
+ .animation(
+ reduceMotion ? nil : .spring(response: 0.42, dampingFraction: 0.78), value: satisfied
+ )
+ .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: guiding)
+ .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: isNext)
+ .accessibilityElement(children: .contain)
+ }
+
+ private var background: Color {
+ if satisfied { return Color.green.opacity(0.07) }
+ if isNext { return kind.color.opacity(0.07) }
+ return Color.primary.opacity(0.035)
+ }
+
+ private var border: Color {
+ if satisfied { return Color.green.opacity(0.3) }
+ if isNext { return kind.color.opacity(0.55) }
+ return Color.primary.opacity(0.08)
+ }
+
+ @ViewBuilder private var trailing: some View {
+ if satisfied {
+ Label(
+ model.status(kind) == .granted ? text.granted : text.requested,
+ systemImage: "checkmark.circle.fill"
+ )
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(.green)
+ .labelStyle(.titleAndIcon)
+ .transition(.scale(scale: 0.6).combined(with: .opacity))
+ } else if guiding {
+ HStack(spacing: 8) {
+ ProgressView().controlSize(.small)
+ Button(text.openSettings) { model.request(kind, from: sourceFrame()) }
+ .buttonStyle(PermissionFlowButtonStyle(tint: kind.color, prominent: false))
+ }
+ .transition(.opacity)
+ } else {
+ Button(
+ model.status(kind) == .denied && !kind.acceptsDraggedApp ? text.openSettings : text.allow
+ ) {
+ model.request(kind, from: sourceFrame())
+ }
+ .buttonStyle(PermissionFlowButtonStyle(tint: kind.color, prominent: isNext))
+ .disabled(!available)
+ .transition(.opacity)
+ }
+ }
+
+ @ViewBuilder private var relaunchLine: some View {
+ if satisfied {
+ EmptyView()
+ } else if model.relaunchFailed {
+ Text(text.reopenFailed)
+ .font(.system(size: 11))
+ .foregroundStyle(.orange)
+ } else if model.awaitingRelaunch(kind) {
+ Button {
+ model.relaunch()
+ } label: {
+ Label(text.reopenHint(model.configuration.appName), systemImage: "arrow.clockwise")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(kind.color)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ } else {
+ Label(text.lockedUntilOthers, systemImage: "arrow.clockwise")
+ .font(.system(size: 11))
+ .foregroundStyle(.tertiary)
+ }
+ }
+}
+
+struct PermissionFlowButtonStyle: ButtonStyle {
+ let tint: Color
+ let prominent: Bool
+ @Environment(\.isEnabled) private var isEnabled
+
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .font(.system(size: 12, weight: .semibold))
+ .lineLimit(1)
+ .foregroundStyle(prominent ? Color.white : tint)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 6)
+ .background(
+ Capsule().fill(prominent ? AnyShapeStyle(tint.gradient) : AnyShapeStyle(tint.opacity(0.13)))
+ )
+ .overlay(Capsule().strokeBorder(.white.opacity(prominent ? 0.18 : 0), lineWidth: 0.5))
+ .contentShape(Capsule())
+ .opacity(isEnabled ? (configuration.isPressed ? 0.78 : 1) : 0.4)
+ .scaleEffect(configuration.isPressed ? 0.96 : 1)
+ .animation(.easeOut(duration: 0.12), value: configuration.isPressed)
+ }
+}
diff --git a/Sources/EncajeApp/PermissionFlow/PermissionFlowWelcome.swift b/Sources/EncajeApp/PermissionFlow/PermissionFlowWelcome.swift
new file mode 100644
index 0000000..e1a4d19
--- /dev/null
+++ b/Sources/EncajeApp/PermissionFlow/PermissionFlowWelcome.swift
@@ -0,0 +1,192 @@
+// Vendored from PermissionFlow; edit the canonical package and re-vendor.
+import AppKit
+import SwiftUI
+
+public struct PermissionFlowWelcomeView: View {
+ @ObservedObject var model: PermissionFlowModel
+ let autoClose: Bool
+ let onDone: () -> Void
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @State private var shown = false
+ @State private var ring = false
+ @State private var badge = false
+ @State private var burst = 0
+ @State private var countdown = false
+ @State private var finished = false
+
+ public init(model: PermissionFlowModel, autoClose: Bool = true, onDone: @escaping () -> Void) {
+ self.model = model
+ self.autoClose = autoClose
+ self.onDone = onDone
+ }
+
+ private var text: PermissionFlowStrings { PermissionFlowStrings(model.language) }
+ private var name: String { model.configuration.appName }
+ private var accent: Color { model.configuration.accent }
+ private var duration: TimeInterval { max(1.5, model.configuration.welcomeDuration) }
+
+ public var body: some View {
+ VStack(spacing: 0) {
+ Spacer(minLength: 12)
+ hero
+ VStack(spacing: 8) {
+ Text(text.welcome(name))
+ .font(.system(size: 26, weight: .bold, design: .rounded))
+ .multilineTextAlignment(.center)
+ Text(model.configuration.tagline?.resolve(model.language) ?? text.ready(name))
+ .font(.system(size: 13))
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .padding(.top, 22)
+ .opacity(shown ? 1 : 0)
+ .offset(y: shown ? 0 : 8)
+ Spacer(minLength: 18)
+ VStack(spacing: 10) {
+ Button(text.start(name), action: finish)
+ .buttonStyle(PermissionFlowButtonStyle(tint: accent, prominent: true))
+ .controlSize(.large)
+ .keyboardShortcut(.defaultAction)
+ if autoClose {
+ VStack(spacing: 6) {
+ Capsule()
+ .fill(Color.primary.opacity(0.08))
+ .frame(width: 120, height: 3)
+ .overlay(alignment: .leading) {
+ Capsule()
+ .fill(accent.opacity(0.7))
+ .frame(width: countdown ? 0 : 120, height: 3)
+ }
+ Text(text.closesSoon)
+ .font(.system(size: 10))
+ .foregroundStyle(.tertiary)
+ }
+ }
+ }
+ .opacity(shown ? 1 : 0)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .onAppear(perform: play)
+ }
+
+ private var hero: some View {
+ ZStack {
+ Circle()
+ .fill(
+ RadialGradient(
+ colors: [accent.opacity(0.28), .clear], center: .center, startRadius: 8, endRadius: 96)
+ )
+ .frame(width: 200, height: 200)
+ .scaleEffect(shown ? 1 : 0.6)
+ .opacity(shown ? 1 : 0)
+ if !reduceMotion {
+ PermissionFlowBurst(colors: burstColors, trigger: burst)
+ }
+ Circle()
+ .trim(from: 0, to: ring ? 1 : 0)
+ .stroke(
+ AngularGradient(
+ colors: [.green.opacity(0.35), .green, .mint, .green.opacity(0.35)], center: .center),
+ style: StrokeStyle(lineWidth: 3.5, lineCap: .round)
+ )
+ .rotationEffect(.degrees(-90))
+ .frame(width: 128, height: 128)
+ Image(nsImage: model.configuration.icon)
+ .resizable()
+ .interpolation(.high)
+ .frame(width: 96, height: 96)
+ .shadow(color: .black.opacity(0.18), radius: 10, y: 5)
+ .scaleEffect(shown ? 1 : 0.55)
+ .opacity(shown ? 1 : 0)
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 30, weight: .bold))
+ .foregroundStyle(.white, .green)
+ .background(Circle().fill(Color(nsColor: .windowBackgroundColor)).padding(2))
+ .offset(x: 42, y: 42)
+ .scaleEffect(badge ? 1 : 0.2)
+ .opacity(badge ? 1 : 0)
+ }
+ .frame(width: 200, height: 170)
+ }
+
+ private var burstColors: [Color] {
+ let kinds = model.items.map(\.kind.color)
+ return [accent, .green] + kinds + [.mint, accent]
+ }
+
+ private func play() {
+ guard !shown else { return }
+ if reduceMotion {
+ shown = true
+ ring = true
+ badge = true
+ } else {
+ withAnimation(.spring(response: 0.55, dampingFraction: 0.68)) { shown = true }
+ withAnimation(.easeInOut(duration: 0.75).delay(0.12)) { ring = true }
+ withAnimation(.spring(response: 0.4, dampingFraction: 0.55).delay(0.55)) { badge = true }
+ Task { @MainActor in
+ try? await Task.sleep(for: .milliseconds(420))
+ burst += 1
+ }
+ }
+ guard autoClose else { return }
+ withAnimation(.linear(duration: duration).delay(0.5)) { countdown = true }
+ Task { @MainActor in
+ try? await Task.sleep(for: .seconds(duration + 0.5))
+ finish()
+ }
+ }
+
+ private func finish() {
+ guard !finished else { return }
+ finished = true
+ onDone()
+ }
+}
+
+private struct PermissionFlowBurst: View {
+ let colors: [Color]
+ let trigger: Int
+
+ private struct Frame {
+ var distance: CGFloat = 40
+ var opacity: Double = 0
+ var scale: CGFloat = 1
+ }
+
+ var body: some View {
+ ZStack {
+ ForEach(0..<18, id: \.self) { index in
+ let angle = Double(index) / 18 * 2 * .pi + (index.isMultiple(of: 2) ? 0.12 : -0.08)
+ let reach: CGFloat = index.isMultiple(of: 3) ? 108 : (index.isMultiple(of: 2) ? 94 : 82)
+ let size: CGFloat = index.isMultiple(of: 3) ? 8 : 5.5
+ Circle()
+ .fill(colors[index % colors.count])
+ .frame(width: size, height: size)
+ .keyframeAnimator(initialValue: Frame(), trigger: trigger) { content, frame in
+ content
+ .scaleEffect(frame.scale)
+ .opacity(frame.opacity)
+ .offset(x: cos(angle) * frame.distance, y: sin(angle) * frame.distance)
+ } keyframes: { _ in
+ KeyframeTrack(\.distance) {
+ CubicKeyframe(52, duration: 0.01)
+ SpringKeyframe(reach, duration: 0.9, spring: .init(response: 0.7, dampingRatio: 0.7))
+ }
+ KeyframeTrack(\.opacity) {
+ LinearKeyframe(1, duration: 0.08)
+ LinearKeyframe(1, duration: 0.45)
+ LinearKeyframe(0, duration: 0.5)
+ }
+ KeyframeTrack(\.scale) {
+ LinearKeyframe(1.2, duration: 0.2)
+ LinearKeyframe(0.4, duration: 0.8)
+ }
+ }
+ }
+ }
+ .allowsHitTesting(false)
+ .accessibilityHidden(true)
+ }
+}
diff --git a/Sources/EncajeApp/PermissionFlow/PermissionFlowWindow.swift b/Sources/EncajeApp/PermissionFlow/PermissionFlowWindow.swift
new file mode 100644
index 0000000..8275f7b
--- /dev/null
+++ b/Sources/EncajeApp/PermissionFlow/PermissionFlowWindow.swift
@@ -0,0 +1,334 @@
+// Vendored from PermissionFlow; edit the canonical package and re-vendor.
+import AppKit
+import QuartzCore
+import SwiftUI
+
+@MainActor
+public final class PermissionFlow {
+ public let model: PermissionFlowModel
+ public var onFinish: ((Bool) -> Void)?
+ private var controller: PermissionFlowWindowController?
+
+ public init(configuration: PermissionFlowConfiguration) {
+ model = PermissionFlowModel(configuration: configuration)
+ }
+
+ public var isPresented: Bool { controller != nil }
+
+ @discardableResult
+ public func presentIfNeeded() -> Bool {
+ guard model.shouldPresentAtLaunch else { return false }
+ present()
+ return true
+ }
+
+ public func present() {
+ if let controller {
+ controller.bringToFront()
+ return
+ }
+ let controller = PermissionFlowWindowController(model: model)
+ controller.onClose = { [weak self] completed in
+ self?.controller = nil
+ self?.onFinish?(completed)
+ }
+ self.controller = controller
+ controller.show()
+ }
+
+ public func close() {
+ controller?.close()
+ }
+}
+
+@MainActor
+final class PermissionFlowWindowController: NSObject, NSWindowDelegate {
+ static let width: CGFloat = 468
+ let model: PermissionFlowModel
+ var onClose: ((Bool) -> Void)?
+ private var window: NSWindow?
+ private var completed = false
+ private var closing = false
+
+ init(model: PermissionFlowModel) {
+ self.model = model
+ super.init()
+ }
+
+ func show() {
+ let text = PermissionFlowStrings(model.language)
+ let height = measuredHeight()
+ let size = CGSize(width: Self.width, height: height)
+ let window = NSWindow(
+ contentRect: CGRect(origin: .zero, size: size),
+ styleMask: [.titled, .closable, .fullSizeContentView], backing: .buffered, defer: false)
+ window.title = text.windowTitle(model.configuration.appName)
+ window.titleVisibility = .hidden
+ window.titlebarAppearsTransparent = true
+ window.isMovableByWindowBackground = true
+ window.isReleasedWhenClosed = false
+ window.backgroundColor = .windowBackgroundColor
+ window.standardWindowButton(.miniaturizeButton)?.isHidden = true
+ window.standardWindowButton(.zoomButton)?.isHidden = true
+ window.collectionBehavior = [.moveToActiveSpace, .fullScreenNone]
+ window.delegate = self
+ let container = PermissionFlowBackdrop(frame: CGRect(origin: .zero, size: size))
+ let hosting = NSHostingView(
+ rootView: PermissionFlowScreen(
+ model: model, height: height,
+ sourceFrame: { [weak self] in self?.window?.frame },
+ finish: { [weak self] in self?.finish() },
+ later: { [weak self] in self?.close() }))
+ hosting.sizingOptions = []
+ hosting.frame = container.bounds
+ hosting.autoresizingMask = [.width, .height]
+ container.addSubview(hosting)
+ window.contentView = container
+ window.setContentSize(size)
+ window.center()
+ if let screen = window.screen ?? NSScreen.main {
+ var frame = window.frame
+ frame.origin.y = min(
+ screen.visibleFrame.maxY - frame.height - 40,
+ frame.origin.y + screen.visibleFrame.height * 0.06)
+ window.setFrameOrigin(CGPoint(x: frame.origin.x.rounded(), y: frame.origin.y.rounded()))
+ }
+ self.window = window
+ model.startMonitoring()
+ window.alphaValue = 0
+ PermissionFlowActivation.bringForward(window)
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0.12 : 0.28
+ window.animator().alphaValue = 1
+ }
+ }
+
+ func bringToFront() {
+ guard let window else { return }
+ PermissionFlowActivation.bringForward(window)
+ }
+
+ func close() {
+ window?.close()
+ }
+
+ private func measuredHeight() -> CGFloat {
+ let probe = NSHostingView(
+ rootView: PermissionFlowScreen(
+ model: model, height: nil, sourceFrame: { nil }, finish: {}, later: {}))
+ let fitting = probe.fittingSize.height
+ return max(430, ceil(fitting))
+ }
+
+ private func finish() {
+ guard !closing, let window else { return }
+ closing = true
+ completed = true
+ model.markCompleted()
+ model.stopMonitoring()
+ let anchor = model.configuration.menuBarAnchor?()
+ PermissionFlowDismissal.run(window: window, toward: anchor) { [weak self] in
+ self?.window?.close()
+ }
+ }
+
+ func windowWillClose(_ notification: Notification) {
+ model.stopMonitoring()
+ window?.delegate = nil
+ window = nil
+ if !completed && model.ready {
+ completed = true
+ model.markCompleted()
+ }
+ onClose?(completed)
+ }
+}
+
+final class PermissionFlowBackdrop: NSView {
+ override var isOpaque: Bool { true }
+
+ override func draw(_ dirtyRect: NSRect) {
+ NSColor.windowBackgroundColor.setFill()
+ dirtyRect.fill()
+ }
+}
+
+public struct PermissionFlowScreen: View {
+ @ObservedObject var model: PermissionFlowModel
+ let height: CGFloat?
+ let sourceFrame: () -> CGRect?
+ let finish: () -> Void
+ let later: () -> Void
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @State private var welcome = false
+ @State private var appeared = false
+
+ public init(
+ model: PermissionFlowModel, height: CGFloat?, sourceFrame: @escaping () -> CGRect? = { nil },
+ finish: @escaping () -> Void, later: @escaping () -> Void
+ ) {
+ self.model = model
+ self.height = height
+ self.sourceFrame = sourceFrame
+ self.finish = finish
+ self.later = later
+ }
+
+ public var body: some View {
+ ZStack(alignment: .top) {
+ LinearGradient(
+ colors: [model.configuration.accent.opacity(0.13), .clear],
+ startPoint: .top, endPoint: .center
+ )
+ .ignoresSafeArea()
+ if welcome {
+ PermissionFlowWelcomeView(model: model, onDone: finish)
+ .padding(.horizontal, 32)
+ .padding(.top, 34)
+ .padding(.bottom, 26)
+ .transition(
+ .asymmetric(insertion: .opacity.combined(with: .scale(scale: 0.96)), removal: .opacity))
+ } else {
+ checklist
+ .transition(.opacity.combined(with: .scale(scale: 1.02)))
+ }
+ }
+ .frame(width: PermissionFlowWindowController.width)
+ .frame(height: height)
+ .onAppear {
+ if height != nil && model.ready { welcome = true }
+ appeared = true
+ }
+ .onChange(of: model.ready) { _, satisfied in
+ guard satisfied, !welcome, height != nil else { return }
+ Task { @MainActor in
+ try? await Task.sleep(for: .milliseconds(reduceMotion ? 150 : 650))
+ withAnimation(
+ reduceMotion ? .easeInOut(duration: 0.2) : .spring(response: 0.6, dampingFraction: 0.86)
+ ) {
+ welcome = true
+ }
+ }
+ }
+ }
+
+ private var checklist: some View {
+ let text = PermissionFlowStrings(model.language)
+ let name = model.configuration.appName
+ return VStack(spacing: 0) {
+ VStack(spacing: 10) {
+ ZStack {
+ Circle()
+ .fill(
+ RadialGradient(
+ colors: [model.configuration.accent.opacity(0.3), .clear], center: .center,
+ startRadius: 4, endRadius: 64)
+ )
+ .frame(width: 128, height: 128)
+ Image(nsImage: model.configuration.icon)
+ .resizable()
+ .interpolation(.high)
+ .frame(width: 76, height: 76)
+ .shadow(color: .black.opacity(0.16), radius: 8, y: 4)
+ .scaleEffect(appeared || reduceMotion ? 1 : 0.8)
+ .opacity(appeared ? 1 : 0)
+ .animation(
+ reduceMotion ? nil : .spring(response: 0.55, dampingFraction: 0.7), value: appeared)
+ }
+ .frame(height: 96)
+ Text(text.headline(name))
+ .font(.system(size: 22, weight: .bold, design: .rounded))
+ .multilineTextAlignment(.center)
+ Text(
+ text.subtitle(
+ name, required: model.requiredItems.count,
+ optional: model.items.count - model.requiredItems.count)
+ )
+ .font(.system(size: 13))
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .padding(.top, 30)
+ PermissionFlowChecklist(model: model, sourceFrame: sourceFrame)
+ .padding(.top, 22)
+ if let note = model.configuration.note {
+ Text(note.resolve(model.language))
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.top, 14)
+ }
+ Spacer(minLength: 16)
+ Button(text.later, action: later)
+ .buttonStyle(.plain)
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .padding(.vertical, 4)
+ .contentShape(Rectangle())
+ }
+ .padding(.horizontal, 28)
+ .padding(.bottom, 18)
+ }
+}
+
+@MainActor
+enum PermissionFlowDismissal {
+ static func run(
+ window: NSWindow, toward anchor: CGRect?, completion: @escaping @MainActor @Sendable () -> Void
+ ) {
+ guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion,
+ let content = window.contentView,
+ let rep = content.bitmapImageRepForCachingDisplay(in: content.bounds)
+ else {
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = 0.2
+ window.animator().alphaValue = 0
+ } completionHandler: {
+ MainActor.assumeIsolated { completion() }
+ }
+ return
+ }
+ content.cacheDisplay(in: content.bounds, to: rep)
+ let frame = window.frame
+ let ghost = NSWindow(
+ contentRect: frame, styleMask: [.borderless], backing: .buffered, defer: false)
+ ghost.isOpaque = false
+ ghost.backgroundColor = .clear
+ ghost.hasShadow = true
+ ghost.level = .floating
+ ghost.isReleasedWhenClosed = false
+ ghost.ignoresMouseEvents = true
+ let view = NSView(frame: CGRect(origin: .zero, size: frame.size))
+ view.wantsLayer = true
+ view.layer?.contents = rep.cgImage
+ view.layer?.contentsGravity = .resize
+ view.layer?.cornerRadius = 16
+ view.layer?.cornerCurve = .continuous
+ view.layer?.masksToBounds = true
+ view.autoresizingMask = [.width, .height]
+ ghost.contentView = view
+ ghost.setFrame(frame, display: false)
+ ghost.orderFront(nil)
+ window.alphaValue = 0
+ let target: CGRect
+ if let anchor, !anchor.isEmpty {
+ target = CGRect(x: anchor.midX - 14, y: anchor.midY - 10, width: 28, height: 20)
+ } else {
+ target = frame.insetBy(dx: frame.width * 0.06, dy: frame.height * 0.06).offsetBy(
+ dx: 0, dy: 10)
+ }
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = anchor == nil ? 0.32 : 0.55
+ context.timingFunction = CAMediaTimingFunction(controlPoints: 0.55, 0, 0.75, 0.35)
+ ghost.animator().setFrame(target, display: true)
+ ghost.animator().alphaValue = 0
+ } completionHandler: {
+ MainActor.assumeIsolated {
+ ghost.orderOut(nil)
+ completion()
+ }
+ }
+ }
+}
diff --git a/Sources/EncajeApp/Permissions/PermissionAppIcon.swift b/Sources/EncajeApp/Permissions/PermissionAppIcon.swift
deleted file mode 100644
index f6026f6..0000000
--- a/Sources/EncajeApp/Permissions/PermissionAppIcon.swift
+++ /dev/null
@@ -1,56 +0,0 @@
-import AppKit
-import SwiftUI
-
-struct PermissionAppIcon: NSViewRepresentable {
- func makeNSView(context: Context) -> PermissionDragImageView {
- let view = PermissionDragImageView()
- view.image = AppArtwork.icon
- view.imageScaling = .scaleProportionallyUpOrDown
- view.setAccessibilityLabel(
- localized(
- "Drag Encaje to the Accessibility list", "Arrastra Encaje a la lista de Accesibilidad"))
- return view
- }
-
- func updateNSView(_ view: PermissionDragImageView, context: Context) {}
-
- func sizeThatFits(_ proposal: ProposedViewSize, nsView: PermissionDragImageView, context: Context)
- -> CGSize?
- {
- CGSize(width: proposal.width ?? 58, height: proposal.height ?? 58)
- }
-}
-
-@MainActor
-final class PermissionDragImageView: NSImageView, NSDraggingSource {
- private var dragging = false
-
- override var intrinsicContentSize: NSSize { NSSize(width: 58, height: 58) }
-
- override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
-
- override func mouseDown(with event: NSEvent) {}
-
- override func mouseDragged(with event: NSEvent) {
- guard !dragging, let image else { return }
- let pasteboardItem = NSPasteboardItem()
- pasteboardItem.setString(Bundle.main.bundleURL.absoluteString, forType: .fileURL)
- let item = NSDraggingItem(pasteboardWriter: pasteboardItem)
- item.setDraggingFrame(bounds, contents: image)
- dragging = true
- let session = beginDraggingSession(with: [item], event: event, source: self)
- session.animatesToStartingPositionsOnCancelOrFail = true
- }
-
- func draggingSession(
- _ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext
- ) -> NSDragOperation {
- .copy
- }
-
- func draggingSession(
- _ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation
- ) {
- dragging = false
- }
-}
diff --git a/Sources/EncajeApp/Permissions/PermissionCoordinator.swift b/Sources/EncajeApp/Permissions/PermissionCoordinator.swift
deleted file mode 100644
index b3a5735..0000000
--- a/Sources/EncajeApp/Permissions/PermissionCoordinator.swift
+++ /dev/null
@@ -1,97 +0,0 @@
-import AppKit
-import ApplicationServices
-import Combine
-
-@MainActor
-final class PermissionCoordinator: NSObject, ObservableObject {
- @Published private(set) var granted = AXIsProcessTrusted()
- @Published private(set) var restartSuggested = false
- @Published private(set) var guiding = false
- private let defaults: UserDefaults
- private var assistant: SettingsPermissionAssistant?
- private var permissionTimer: Timer?
-
- init(defaults: UserDefaults = .standard) {
- self.defaults = defaults
- super.init()
- NSWorkspace.shared.notificationCenter.addObserver(
- self, selector: #selector(refresh),
- name: NSWorkspace.didActivateApplicationNotification, object: nil
- )
- }
-
- @objc func refresh() {
- let trusted = AXIsProcessTrusted()
- let newlyGranted = trusted && !granted
- if granted != trusted { granted = trusted }
- if newlyGranted && guiding {
- assistant?.showSuccess()
- permissionTimer?.invalidate()
- permissionTimer = Timer.scheduledTimer(
- timeInterval: 1.1, target: self, selector: #selector(dismiss),
- userInfo: nil, repeats: false
- )
- }
- if !trusted && restartSuggested { restartSuggested = false }
- }
-
- func request(from sourceFrame: CGRect? = nil) {
- dismiss()
- refresh()
- guard !granted else { return }
- defaults.set(true, forKey: "permissionSetupPending")
- guiding = true
- assistant = SettingsPermissionAssistant(sourceFrame: sourceFrame) { [weak self] in
- self?.dismiss()
- }
- assistant?.start()
- if let url = URL(
- string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
- {
- NSWorkspace.shared.open(url)
- }
- permissionTimer = Timer.scheduledTimer(
- timeInterval: 0.5, target: self, selector: #selector(refresh),
- userInfo: nil, repeats: true
- )
- }
-
- @objc func dismiss() {
- permissionTimer?.invalidate()
- permissionTimer = nil
- assistant?.stop()
- assistant = nil
- guiding = false
- }
-
- func completeSetup() {
- defaults.removeObject(forKey: "permissionSetupPending")
- }
-
- func markRestartNeeded() {
- refresh()
- restartSuggested = granted
- }
-
- func restartApplication() {
- guard restartSuggested, Bundle.main.bundleURL.pathExtension == "app" else { return }
- defaults.set(true, forKey: "permissionSetupPending")
- defaults.synchronize()
- let helper = Process()
- helper.executableURL = URL(fileURLWithPath: "/bin/sh")
- helper.arguments = [
- "-c", "while kill -0 \"$1\" 2>/dev/null; do sleep 0.2; done; /usr/bin/open \"$2\"",
- "encaje-relaunch", String(ProcessInfo.processInfo.processIdentifier),
- Bundle.main.bundleURL.path,
- ]
- helper.standardInput = FileHandle.nullDevice
- helper.standardOutput = FileHandle.nullDevice
- helper.standardError = FileHandle.nullDevice
- do {
- try helper.run()
- NSApp.terminate(nil)
- } catch {
- restartSuggested = true
- }
- }
-}
diff --git a/Sources/EncajeApp/Permissions/PermissionGuidePanel.swift b/Sources/EncajeApp/Permissions/PermissionGuidePanel.swift
deleted file mode 100644
index 533e93f..0000000
--- a/Sources/EncajeApp/Permissions/PermissionGuidePanel.swift
+++ /dev/null
@@ -1,89 +0,0 @@
-import AppKit
-import SwiftUI
-
-@MainActor
-final class PermissionGuideState: ObservableObject {
- @Published var success = false
-}
-
-@MainActor
-final class PermissionGuidePanel: NSPanel {
- let guideState = PermissionGuideState()
-
- init(close: @escaping () -> Void) {
- super.init(
- contentRect: CGRect(x: 0, y: 0, width: 360, height: 118),
- styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
- isOpaque = false
- backgroundColor = .clear
- hasShadow = true
- level = .floating
- hidesOnDeactivate = false
- collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
- isReleasedWhenClosed = false
- let hosting = NSHostingView(rootView: PermissionGuideCard(state: guideState, close: close))
- hosting.sizingOptions = []
- contentView = hosting
- }
-
- override var canBecomeKey: Bool { false }
-}
-
-private struct PermissionGuideCard: View {
- @AppStorage("language", store: AppLanguage.defaults) private var language = "system"
- @ObservedObject var state: PermissionGuideState
- let close: () -> Void
- @Environment(\.accessibilityReduceMotion) private var reduceMotion
-
- var body: some View {
- HStack(spacing: 14) {
- if state.success {
- Image(systemName: "checkmark.circle.fill")
- .font(.system(size: 44, weight: .medium))
- .foregroundStyle(.green)
- .transition(.scale.combined(with: .opacity))
- } else {
- PermissionAppIcon()
- .frame(width: 58, height: 58)
- .accessibilityLabel(
- localized(
- "Drag Encaje to the Accessibility list", "Arrastra Encaje a la lista de Accesibilidad"
- ))
- }
- VStack(alignment: .leading, spacing: 5) {
- Text(
- state.success
- ? localized("Access granted", "Permiso concedido")
- : localized("Drag Encaje above", "Arrastra Encaje arriba")
- )
- .font(.system(size: 15, weight: .semibold))
- .foregroundStyle(state.success ? Color.green : Color.blue)
- Text(
- state.success
- ? localized(
- "Accessibility access is enabled.", "El acceso a Accesibilidad está activado.")
- : localized(
- "Drop the icon in the list, then enable its switch.",
- "Suelta el icono en la lista y activa su interruptor.")
- )
- .font(.system(size: 12)).foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- }
- Spacer(minLength: 0)
- Button(action: close) {
- Image(systemName: "xmark").font(.system(size: 11, weight: .semibold))
- }
- .buttonStyle(.plain)
- .accessibilityLabel(localized("Dismiss guide", "Cerrar guía"))
- }
- .id(language)
- .padding(18)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22))
- .overlay(
- RoundedRectangle(cornerRadius: 22).strokeBorder(
- (state.success ? Color.green : Color.blue).opacity(0.35), lineWidth: 1)
- )
- .animation(reduceMotion ? nil : .easeInOut(duration: 0.25), value: state.success)
- }
-}
diff --git a/Sources/EncajeApp/Permissions/PermissionPlacement.swift b/Sources/EncajeApp/Permissions/PermissionPlacement.swift
deleted file mode 100644
index eee56c9..0000000
--- a/Sources/EncajeApp/Permissions/PermissionPlacement.swift
+++ /dev/null
@@ -1,15 +0,0 @@
-import CoreGraphics
-
-struct PermissionPlacement {
- static func frame(settings: CGRect, visible: CGRect) -> CGRect? {
- let sidebar = min(240, max(180, settings.width * 0.31))
- let left = max(settings.minX + sidebar + 20, visible.minX)
- let right = min(settings.maxX - 20, visible.maxX)
- let bottom = max(settings.minY + 20, visible.minY)
- let top = min(settings.maxY - 20, visible.maxY)
- guard right - left >= 200, top - bottom >= 128 else { return nil }
- return CGRect(
- x: left.rounded(.up), y: bottom.rounded(.up),
- width: right.rounded(.down) - left.rounded(.up), height: 128)
- }
-}
diff --git a/Sources/EncajeApp/Permissions/PermissionTrackingMetrics.swift b/Sources/EncajeApp/Permissions/PermissionTrackingMetrics.swift
deleted file mode 100644
index 011289f..0000000
--- a/Sources/EncajeApp/Permissions/PermissionTrackingMetrics.swift
+++ /dev/null
@@ -1,41 +0,0 @@
-import Foundation
-
-@MainActor
-final class PermissionTrackingMetrics {
- private let destination = ProcessInfo.processInfo.environment["ENCAJE_PERMISSION_METRICS_FILE"]
- private var samples = 0
- private var updates = 0
- private var totalCost = 0.0
- private var maximumCost = 0.0
- private var previousSample: Double?
- private var maximumInterval = 0.0
-
- func record(start: Double, moved: Bool) {
- guard destination != nil else { return }
- let end = ProcessInfo.processInfo.systemUptime
- samples += 1
- if moved { updates += 1 }
- let cost = end - start
- totalCost += cost
- maximumCost = max(maximumCost, cost)
- if let previousSample { maximumInterval = max(maximumInterval, start - previousSample) }
- previousSample = start
- }
-
- func pause() {
- previousSample = nil
- }
-
- func flush() {
- guard let destination, samples > 0 else { return }
- let report: [String: Double] = [
- "trackingSamples": Double(samples), "positionUpdates": Double(updates),
- "meanQueryAndUpdateMilliseconds": totalCost / Double(samples) * 1_000,
- "maxQueryAndUpdateMilliseconds": maximumCost * 1_000,
- "maxSampleIntervalMilliseconds": maximumInterval * 1_000,
- ]
- guard let data = try? JSONSerialization.data(withJSONObject: report, options: .sortedKeys)
- else { return }
- try? data.write(to: URL(fileURLWithPath: destination), options: .atomic)
- }
-}
diff --git a/Sources/EncajeApp/Permissions/SettingsPermissionAssistant.swift b/Sources/EncajeApp/Permissions/SettingsPermissionAssistant.swift
deleted file mode 100644
index fec737d..0000000
--- a/Sources/EncajeApp/Permissions/SettingsPermissionAssistant.swift
+++ /dev/null
@@ -1,143 +0,0 @@
-import AppKit
-import QuartzCore
-
-@MainActor
-final class SettingsPermissionAssistant: NSObject {
- private let panel: PermissionGuidePanel
- private let locator = SettingsWindowLocator()
- private let metrics = PermissionTrackingMetrics()
- private let sourceFrame: CGRect?
- private var discoveryTimer: Timer?
- private var displayLink: CADisplayLink?
- private var presented = false
- private var entering = false
- private var completed = false
-
- init(sourceFrame: CGRect?, close: @escaping () -> Void) {
- self.sourceFrame = sourceFrame
- panel = PermissionGuidePanel(close: close)
- super.init()
- }
-
- func start() {
- discoveryTimer = Timer.scheduledTimer(
- timeInterval: 0.5, target: self,
- selector: #selector(discover), userInfo: nil, repeats: true)
- NSWorkspace.shared.notificationCenter.addObserver(
- self, selector: #selector(discover),
- name: NSWorkspace.didActivateApplicationNotification, object: nil)
- discover()
- }
-
- func stop() {
- discoveryTimer?.invalidate()
- discoveryTimer = nil
- stopTracking()
- NSWorkspace.shared.notificationCenter.removeObserver(self)
- panel.orderOut(nil)
- metrics.flush()
- }
-
- func showSuccess() {
- completed = true
- panel.guideState.success = true
- }
-
- @objc private func discover() {
- guard !completed else { return }
- guard let frame = locator.locate() else {
- panel.orderOut(nil)
- stopTracking()
- return
- }
- position(frame)
- guard panel.isVisible else {
- stopTracking()
- return
- }
- guard displayLink == nil else { return }
- let link = panel.displayLink(target: self, selector: #selector(track))
- link.add(to: .main, forMode: .common)
- displayLink = link
- updateRefreshRate()
- }
-
- @objc private func track() {
- let start = ProcessInfo.processInfo.systemUptime
- guard let frame = locator.locate() else {
- panel.orderOut(nil)
- stopTracking()
- return
- }
- let moved = position(frame)
- guard panel.isVisible else {
- stopTracking()
- return
- }
- updateRefreshRate()
- metrics.record(start: start, moved: moved)
- }
-
- private func stopTracking() {
- displayLink?.invalidate()
- displayLink = nil
- metrics.pause()
- }
-
- private func updateRefreshRate() {
- guard let displayLink else { return }
- let rate = Float(min(120, max(30, panel.screen?.maximumFramesPerSecond ?? 60)))
- if displayLink.preferredFrameRateRange.maximum != rate {
- displayLink.preferredFrameRateRange = CAFrameRateRange(
- minimum: min(60, rate), maximum: rate, preferred: rate)
- }
- }
-
- @discardableResult
- private func position(_ settingsFrame: CGRect) -> Bool {
- guard !entering else { return false }
- let screen =
- NSScreen.screens.max {
- $0.frame.intersection(settingsFrame).area < $1.frame.intersection(settingsFrame).area
- } ?? NSScreen.main
- guard let screen else { return false }
- let visible = screen.visibleFrame.insetBy(dx: 10, dy: 10)
- guard let target = PermissionPlacement.frame(settings: settingsFrame, visible: visible) else {
- panel.orderOut(nil)
- return false
- }
- let size = target.size
- if !presented {
- presented = true
- if !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion {
- entering = true
- panel.alphaValue = 0
- let origin =
- sourceFrame.map {
- CGRect(
- x: $0.midX - size.width / 2,
- y: $0.midY - size.height / 2, width: size.width, height: size.height)
- }
- ?? target.offsetBy(dx: 0, dy: 12)
- panel.setFrame(origin, display: false)
- panel.orderFrontRegardless()
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.28
- panel.animator().setFrame(target, display: true)
- panel.animator().alphaValue = 1
- } completionHandler: { [weak self] in
- MainActor.assumeIsolated { self?.entering = false }
- }
- return true
- }
- }
- let changed = panel.frame != target
- if changed { panel.setFrame(target, display: true) }
- if !panel.isVisible { panel.orderFrontRegardless() }
- return changed
- }
-}
-
-extension CGRect {
- fileprivate var area: CGFloat { isNull ? 0 : width * height }
-}
diff --git a/Sources/EncajeApp/Permissions/SettingsWindowLocator.swift b/Sources/EncajeApp/Permissions/SettingsWindowLocator.swift
deleted file mode 100644
index 8a8d1f2..0000000
--- a/Sources/EncajeApp/Permissions/SettingsWindowLocator.swift
+++ /dev/null
@@ -1,48 +0,0 @@
-import AppKit
-
-@MainActor
-final class SettingsWindowLocator {
- private var windowID: CGWindowID?
- private var ownerPID: pid_t?
-
- func locate() -> CGRect? {
- guard let app = NSWorkspace.shared.frontmostApplication,
- app.bundleIdentifier == "com.apple.systempreferences"
- else { return nil }
- if app.processIdentifier != ownerPID {
- ownerPID = app.processIdentifier
- windowID = nil
- }
- if let windowID,
- let windows = CGWindowListCopyWindowInfo(.optionIncludingWindow, windowID)
- as? [[String: Any]],
- let window = windows.first, let frame = frame(of: window, pid: app.processIdentifier)
- {
- return frame
- }
- windowID = nil
- guard
- let windows = CGWindowListCopyWindowInfo(
- [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]
- else { return nil }
- for window in windows {
- if let frame = frame(of: window, pid: app.processIdentifier) {
- windowID = (window[kCGWindowNumber as String] as? NSNumber)?.uint32Value
- return frame
- }
- }
- return nil
- }
-
- private func frame(of window: [String: Any], pid: pid_t) -> CGRect? {
- guard (window[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value == pid,
- (window[kCGWindowLayer as String] as? NSNumber)?.intValue == 0,
- (window[kCGWindowIsOnscreen as String] as? NSNumber)?.boolValue == true,
- let bounds = window[kCGWindowBounds as String] as? [String: CGFloat],
- let frame = CGRect(dictionaryRepresentation: bounds as CFDictionary),
- frame.width > 300, frame.height > 250
- else { return nil }
- let top = NSScreen.screens.first?.frame.maxY ?? 0
- return CGRect(x: frame.minX, y: top - frame.maxY, width: frame.width, height: frame.height)
- }
-}
diff --git a/Sources/EncajeApp/WelcomeView.swift b/Sources/EncajeApp/WelcomeView.swift
deleted file mode 100644
index a87e4c6..0000000
--- a/Sources/EncajeApp/WelcomeView.swift
+++ /dev/null
@@ -1,121 +0,0 @@
-import AppKit
-import SwiftUI
-
-struct WelcomeView: View {
- @ObservedObject var model: AppModel
- @ObservedObject var permissions: PermissionCoordinator
- @Environment(\.accessibilityReduceMotion) private var reduceMotion
- @State private var entrance = false
- @State private var completing = false
- let sourceFrame: () -> CGRect?
- let finish: () -> Void
-
- var body: some View {
- VStack(spacing: 22) {
- Spacer(minLength: 0)
- ZStack {
- Circle().fill(.teal.opacity(0.10)).frame(width: 126, height: 126)
- if permissions.granted {
- Image(systemName: "checkmark.seal.fill").font(.system(size: 68)).foregroundStyle(
- .green.gradient
- )
- .transition(.scale.combined(with: .opacity))
- } else {
- Image(nsImage: AppArtwork.icon).resizable().frame(
- width: 94, height: 94
- )
- .transition(.opacity)
- }
- }.scaleEffect(entrance ? 1 : 0.88)
- VStack(spacing: 10) {
- Text(
- model.ready
- ? localized("Everything fits.", "Todo encaja.")
- : localized("Welcome to Encaje", "Bienvenido a Encaje")
- )
- .font(.system(size: 30, weight: .bold, design: .rounded))
- Text(
- permissions.granted
- ? (model.ready
- ? localized(
- "Accessibility is enabled. Your windows are ready to move.",
- "Accesibilidad activada. Tus ventanas están listas para moverse.")
- : localized(
- "Accessibility is enabled. Finishing shortcut setup…",
- "Accesibilidad activada. Preparando los atajos…"))
- : localized(
- "One permission to put your windows in their place.",
- "Un permiso para poner tus ventanas en su sitio.")
- )
- .foregroundStyle(.secondary).multilineTextAlignment(.center)
- }
- VStack(alignment: .leading, spacing: 12) {
- Label(
- localized(
- "Arrange windows across all your displays", "Ordena ventanas entre todas tus pantallas"),
- systemImage: "rectangle.2.swap")
- Label(
- localized(
- "Repeat Shift + A / D / W / X to keep moving",
- "Repite Shift + A / D / W / X para seguir moviendo"), systemImage: "keyboard")
- Label(
- localized(
- "Shift + S maximizes on the current display", "Shift + S maximiza en la pantalla actual"
- ), systemImage: "arrow.up.left.and.arrow.down.right")
- }.font(.callout).frame(maxWidth: .infinity, alignment: .leading).padding(18)
- .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 16))
- Text(
- localized(
- "These shortcuts replace their uppercase letters. Pause or customize them from the menu bar.",
- "Estos atajos reemplazan sus letras mayúsculas. Páusalos o cámbialos desde la barra de menú."
- )
- )
- .font(.caption).foregroundStyle(.secondary).multilineTextAlignment(.center).fixedSize(
- horizontal: false, vertical: true)
- if permissions.restartSuggested && !model.shortcutReady {
- Button(localized("Restart Encaje to finish", "Reiniciar Encaje para terminar")) {
- permissions.restartApplication()
- }
- .buttonStyle(.borderedProminent).controlSize(.large)
- Text(
- localized(
- "Your setup will resume automatically.", "La configuración continuará automáticamente.")
- )
- .font(.caption).foregroundStyle(.secondary)
- } else if permissions.granted {
- Button(localized("Start using Encaje", "Empezar a usar Encaje"), action: complete)
- .buttonStyle(.borderedProminent).controlSize(.large).disabled(!model.ready)
- } else {
- Button(localized("Allow Accessibility", "Dar acceso a Accesibilidad")) {
- permissions.request(from: sourceFrame())
- }
- .buttonStyle(.borderedProminent).controlSize(.large).tint(.blue)
- Text(
- localized(
- "Drag Encaje into the list, then enable its switch if asked.",
- "Arrastra Encaje a la lista y activa su interruptor si se solicita.")
- )
- .font(.caption).foregroundStyle(.secondary)
- }
- Spacer(minLength: 0)
- if !model.ready {
- Button(localized("Set up later", "Configurar después"), action: finish).buttonStyle(.plain)
- .foregroundStyle(.secondary).font(.caption)
- }
- }.padding(32).frame(width: 510, height: 590).ignoresSafeArea(.container, edges: .top).tint(
- .teal
- )
- .animation(
- reduceMotion ? nil : .spring(response: 0.4, dampingFraction: 0.85),
- value: permissions.granted
- )
- .onAppear { withAnimation(reduceMotion ? nil : .easeOut(duration: 0.3)) { entrance = true } }
- }
-
- private func complete() {
- guard !completing, model.ready else { return }
- completing = true
- model.welcomeComplete = true
- finish()
- }
-}
diff --git a/Tests/EncajeAppTests/PermissionIconTests.swift b/Tests/EncajeAppTests/PermissionIconTests.swift
index 529a58d..149c83e 100644
--- a/Tests/EncajeAppTests/PermissionIconTests.swift
+++ b/Tests/EncajeAppTests/PermissionIconTests.swift
@@ -6,21 +6,23 @@ import XCTest
final class PermissionIconTests: XCTestCase {
@MainActor func testLargeArtworkDoesNotDictateDragControlSize() {
- let view = PermissionDragImageView()
+ let view = PermissionFlowDragImageView()
view.image = NSImage(size: NSSize(width: 256, height: 256))
- XCTAssertEqual(view.intrinsicContentSize, NSSize(width: 58, height: 58))
+ XCTAssertEqual(view.intrinsicContentSize, NSSize(width: 52, height: 52))
}
@MainActor func testHostedDragIconStaysInsideItsProposedFrame() {
_ = NSApplication.shared
- let hosting = NSHostingView(rootView: PermissionAppIcon().frame(width: 58, height: 58))
- hosting.frame = NSRect(x: 0, y: 0, width: 58, height: 58)
+ let hosting = NSHostingView(
+ rootView: PermissionFlowDragIcon(icon: AppArtwork.icon, label: "Encaje")
+ .frame(width: 52, height: 52))
+ hosting.frame = NSRect(x: 0, y: 0, width: 52, height: 52)
hosting.layoutSubtreeIfNeeded()
func descendants(_ view: NSView) -> [NSView] {
view.subviews.flatMap { [$0] + descendants($0) }
}
- let icons = descendants(hosting).compactMap { $0 as? PermissionDragImageView }
+ let icons = descendants(hosting).compactMap { $0 as? PermissionFlowDragImageView }
XCTAssertEqual(icons.count, 1)
- XCTAssertEqual(icons.first?.frame.size, NSSize(width: 58, height: 58))
+ XCTAssertEqual(icons.first?.frame.size, NSSize(width: 52, height: 52))
}
}
diff --git a/Tests/EncajeAppTests/PermissionLifecycleTests.swift b/Tests/EncajeAppTests/PermissionLifecycleTests.swift
deleted file mode 100644
index a99b209..0000000
--- a/Tests/EncajeAppTests/PermissionLifecycleTests.swift
+++ /dev/null
@@ -1,23 +0,0 @@
-import XCTest
-
-@testable import EncajeApp
-
-final class PermissionLifecycleTests: XCTestCase {
- @MainActor func testDismissPreservesPendingSetupAcrossCoordinatorInstances() {
- let name = "Encaje.PermissionLifecycleTests"
- let defaults = UserDefaults(suiteName: name)!
- defaults.removePersistentDomain(forName: name)
- defer { defaults.removePersistentDomain(forName: name) }
- defaults.set(true, forKey: "permissionSetupPending")
- defaults.set(true, forKey: "welcomeComplete")
- let coordinator = PermissionCoordinator(defaults: defaults)
- coordinator.dismiss()
- XCTAssertTrue(defaults.bool(forKey: "permissionSetupPending"))
- XCTAssertTrue(defaults.bool(forKey: "welcomeComplete"))
- let resumed = PermissionCoordinator(defaults: defaults)
- resumed.dismiss()
- XCTAssertTrue(defaults.bool(forKey: "permissionSetupPending"))
- resumed.completeSetup()
- XCTAssertFalse(defaults.bool(forKey: "permissionSetupPending"))
- }
-}
diff --git a/Tests/EncajeAppTests/PermissionPlacementTests.swift b/Tests/EncajeAppTests/PermissionPlacementTests.swift
index 6d22874..25a7b2f 100644
--- a/Tests/EncajeAppTests/PermissionPlacementTests.swift
+++ b/Tests/EncajeAppTests/PermissionPlacementTests.swift
@@ -6,7 +6,7 @@ final class PermissionPlacementTests: XCTestCase {
func testGuideFillsRightColumnInsideSettings() throws {
let settings = CGRect(x: 100, y: 100, width: 724, height: 1000)
let frame = try XCTUnwrap(
- PermissionPlacement.frame(
+ PermissionFlowPlacement.frame(
settings: settings, visible: CGRect(x: 0, y: 0, width: 1440, height: 1200)))
XCTAssertTrue(settings.contains(frame))
XCTAssertEqual(frame.minY, 120)
@@ -19,13 +19,13 @@ final class PermissionPlacementTests: XCTestCase {
func testGuideHidesWhenInsufficientVisibleContentAndRecovers() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 900)
XCTAssertNil(
- PermissionPlacement.frame(
+ PermissionFlowPlacement.frame(
settings: CGRect(x: 700, y: 100, width: 724, height: 800), visible: screen))
XCTAssertNotNil(
- PermissionPlacement.frame(
+ PermissionFlowPlacement.frame(
settings: CGRect(x: 100, y: 100, width: 724, height: 800), visible: screen))
XCTAssertNil(
- PermissionPlacement.frame(
+ PermissionFlowPlacement.frame(
settings: CGRect(x: 100, y: 800, width: 724, height: 800), visible: screen))
}
}