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)) } }