Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Assets/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.2.0</string>
<string>1.3.0</string>
<key>CFBundleVersion</key>
<string>17</string>
<string>18</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 1 addition & 11 deletions Sources/EncajeApp/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") ?? ""
Expand All @@ -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() {
Expand Down Expand Up @@ -225,7 +216,6 @@ final class AppModel: ObservableObject {

func stop() {
hotkeys.stop()
permissions.dismiss()
}

func refreshLoginState() {
Expand Down
69 changes: 44 additions & 25 deletions Sources/EncajeApp/EncajeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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() }
}
Expand Down Expand Up @@ -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<V: View>(title: String, size: NSSize, view: V) -> NSWindow {
Expand Down Expand Up @@ -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
}
Expand Down
28 changes: 28 additions & 0 deletions Sources/EncajeApp/PermissionCoordinator.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading