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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,5 @@ Public macOS window manager. Swift 6, SwiftPM, macOS 14+, Apple Silicon.
- Readiness text must use the full model.ready state, not permission alone.
- Cross-display acceptance must cover a full round trip between unequal screen sizes; verify the realized frame after settling.
- Custom button backgrounds, padding and contentShape must live inside the label; verify physical clicks in empty painted edges, not only AX activation.

Permission colors: Accessibility blue; green means granted/ready. Keep labels and icons.
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.0.1</string>
<string>1.1.0</string>
<key>CFBundleVersion</key>
<string>11</string>
<string>13</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ All notable changes to this project are documented here.

## [Unreleased]

## [1.1.0] - 2026-09-18

### Added

- An update card in the menu bar panel with download progress, a percentage and a
ready-to-install state offering Install now or Later.
- A redesigned About window with the version and build number, update status and
actions, and links to the project and its issue tracker.

### Changed

- Made the menu bar panel opaque instead of translucent.
- Install now quits and reopens Encaje by itself once the update is installed.
- Moved the update card above the pause/resume button in the menu bar panel.
- Use blue consistently for Accessibility setup and green for granted access, preserving the app brand.

## [1.0.1] - 2026-09-06

### Changed
Expand Down
10 changes: 2 additions & 8 deletions Sources/EncajeApp/EncajeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe
private var welcomeWindow: NSWindow?
private var refreshTimer: Timer?
private let menuPopover = MenuBarPopover()
private let aboutWindow = AboutWindowController()

func applicationDidFinishLaunching(_ notification: Notification) {
model = AppModel()
Expand Down Expand Up @@ -162,14 +163,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMe

private func showAbout() {
menuPopover.close(restoreFocus: false)
NSApplication.shared.activate(ignoringOtherApps: true)
NSApplication.shared.orderFrontStandardAboutPanel(options: [
.applicationName: "Encaje", .applicationIcon: AppArtwork.icon,
.applicationVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString")
as? String ?? "",
.credits: NSAttributedString(
string: localized("Your space, your way.", "Tu espacio, a tu manera.")),
])
aboutWindow.show()
}

private func item(_ title: String, _ selector: Selector) -> NSMenuItem {
Expand Down
8 changes: 4 additions & 4 deletions Sources/EncajeApp/MenuBarPopover.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ private struct MenuBarPopoverView: View {
}
Spacer(minLength: 0)
}
if updates.available, updates.phase != .idle {
UpdateCardView()
}
Button {
model.paused.toggle()
} label: {
Expand All @@ -165,9 +168,6 @@ private struct MenuBarPopoverView: View {
.contentShape(RoundedRectangle(cornerRadius: 10))
}.buttonStyle(.plain)
.foregroundStyle(.white)
if updates.available, updates.phase != .idle {
UpdateActionView()
}
VStack(spacing: 0) {
row(
localized("Configuration", "Configuración"), symbol: "slider.horizontal.3",
Expand All @@ -183,7 +183,7 @@ private struct MenuBarPopoverView: View {
}.font(.system(size: 13, weight: .medium))
}.padding(.horizontal, 18).padding(.top, 18).padding(.bottom, 8).frame(width: 300).fixedSize(
horizontal: false, vertical: true
).tint(.teal)
).background(Color(nsColor: .windowBackgroundColor)).tint(.teal)
}

private func row(_ text: String, symbol: String, action: @escaping () -> Void) -> some View {
Expand Down
8 changes: 6 additions & 2 deletions Sources/EncajeApp/Permissions/PermissionGuidePanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ private struct PermissionGuideCard: View {
if state.success {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 44, weight: .medium))
.foregroundStyle(.mint)
.foregroundStyle(.green)
.transition(.scale.combined(with: .opacity))
} else {
PermissionAppIcon()
Expand All @@ -57,6 +57,7 @@ private struct PermissionGuideCard: View {
: localized("Drag Encaje above", "Arrastra Encaje arriba")
)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(state.success ? Color.green : Color.blue)
Text(
state.success
? localized(
Expand All @@ -79,7 +80,10 @@ private struct PermissionGuideCard: View {
.padding(18)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22))
.overlay(RoundedRectangle(cornerRadius: 22).strokeBorder(.cyan.opacity(0.35), lineWidth: 1))
.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)
}
}
189 changes: 189 additions & 0 deletions Sources/EncajeApp/UI/AboutView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import AppKit
import SwiftUI

struct AboutView: View {
@ObservedObject private var updates = UpdateManager.shared

private var version: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
}

private var build: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? ""
}

private var year: String {
String(Calendar.current.component(.year, from: Date()))
}

var body: some View {
VStack(spacing: 18) {
VStack(spacing: 10) {
Image(nsImage: AppArtwork.icon).resizable().frame(width: 96, height: 96)
.shadow(color: .teal.opacity(0.35), radius: 10, y: 5)
Text("Encaje").font(.title.bold())
Text("v\(version) · \(build)").font(.caption.monospaced()).foregroundStyle(.secondary)
updateStatus
}
Text(localized("Your space, your way.", "Tu espacio, a tu manera."))
.font(.callout).foregroundStyle(.secondary)
HStack(spacing: 8) {
chip(symbol: "rectangle.split.2x2", label: localized("Zones", "Zonas"))
chip(symbol: "keyboard", label: localized("Shortcuts", "Atajos"))
chip(symbol: "rectangle.3.group", label: localized("Workspaces", "Espacios"))
}
Rectangle().fill(.secondary.opacity(0.18)).frame(height: 1)
HStack(spacing: 10) {
link(
symbol: "link", label: "GitHub", url: "https://github.com/StevenACZ/Encaje")
link(
symbol: "ladybug", label: localized("Report an issue", "Reportar problema"),
url: "https://github.com/StevenACZ/Encaje/issues")
}
VStack(spacing: 3) {
Text(localized("Made with care for macOS", "Creado con cuidado para macOS"))
.font(.caption).foregroundStyle(.tertiary)
Text("© \(year) StevenACZ").font(.caption2).foregroundStyle(.tertiary)
}
}.padding(.horizontal, 26).padding(.top, 24).padding(.bottom, 18).frame(width: 380)
.fixedSize(horizontal: false, vertical: true)
.background(Color(nsColor: .windowBackgroundColor)).tint(.teal)
}

@ViewBuilder private var updateStatus: some View {
switch updates.phase {
case .idle:
switch updates.manualCheckStatus {
case .checking:
HStack(spacing: 5) {
ProgressView().controlSize(.mini)
Text(localized("Checking for updates…", "Buscando actualizaciones…")).font(.caption2)
.foregroundStyle(.secondary)
}
case .upToDate:
capsule(
symbol: "checkmark.circle",
text: localized("You're up to date", "Tienes la última versión"))
case .failed:
Button {
updates.checkForUpdatesManually()
} label: {
capsule(
symbol: "exclamationmark.arrow.circlepath",
text: localized("Could not check. Try again", "No se pudo comprobar. Reintentar"))
}.buttonStyle(.plain)
case .idle:
Button {
updates.checkForUpdatesManually()
} label: {
Text(localized("Check for updates", "Buscar actualizaciones")).font(.caption2)
.foregroundStyle(.secondary).underline().padding(.vertical, 3)
.contentShape(Rectangle())
}.buttonStyle(.plain)
}
case .available(let version):
VStack(spacing: 5) {
Button {
updates.installPendingUpdate()
} label: {
capsule(
symbol: "arrow.down.circle",
text: localized("Install update", "Instalar actualización")
+ (version.isEmpty ? "" : " · " + version))
}.buttonStyle(.plain)
if updates.releasePageURL != nil {
Button {
updates.openReleasePage()
} label: {
Text(localized("Release notes", "Ver notas de la versión")).font(.caption2)
.foregroundStyle(.secondary).underline().padding(.vertical, 3)
.contentShape(Rectangle())
}.buttonStyle(.plain)
}
}
case .downloading(let fraction):
progressCapsule(
text: localized("Downloading update…", "Descargando actualización…"),
percent: fraction.map { Int(($0 * 100).rounded()) })
case .readyToInstall(let version):
VStack(spacing: 5) {
Button {
updates.installNow()
} label: {
capsule(
symbol: "checkmark.circle.fill",
text: localized("Install now", "Instalar ahora")
+ (version.isEmpty ? "" : " · " + version))
}.buttonStyle(.plain)
Button {
updates.installLater()
} label: {
Text(localized("Later", "Más tarde")).font(.caption2).foregroundStyle(.secondary)
.underline().padding(.vertical, 3).contentShape(Rectangle())
}.buttonStyle(.plain)
}
case .installing:
progressCapsule(text: localized("Installing and restarting…", "Instalando y reiniciando…"))
case .failed:
Button {
updates.installNow()
} label: {
capsule(
symbol: "exclamationmark.arrow.circlepath",
text: localized("Update failed. Retry", "La actualización falló. Reintentar"))
}.buttonStyle(.plain)
}
}

private func capsule(symbol: String, text: String) -> some View {
HStack(spacing: 5) {
Image(systemName: symbol).font(.caption2)
Text(text).font(.caption2.weight(.medium))
}.padding(.horizontal, 10).padding(.vertical, 5)
.background(Capsule().fill(.teal.opacity(0.12)))
.overlay(Capsule().strokeBorder(.teal.opacity(0.22), lineWidth: 1))
.foregroundStyle(.teal).contentShape(Capsule())
}

private func progressCapsule(text: String, percent: Int? = nil) -> some View {
HStack(spacing: 6) {
ProgressView().controlSize(.mini)
Text(text).font(.caption2.weight(.medium))
if let percent {
Text("\(percent) %").font(.caption2.monospacedDigit())
}
}.padding(.horizontal, 10).padding(.vertical, 5)
.background(Capsule().fill(.teal.opacity(0.12)))
.overlay(Capsule().strokeBorder(.teal.opacity(0.22), lineWidth: 1))
.foregroundStyle(.teal)
}

private func chip(symbol: String, label: String) -> some View {
HStack(spacing: 5) {
Image(systemName: symbol).font(.caption2)
Text(label).font(.caption2.weight(.medium))
}.padding(.horizontal, 10).padding(.vertical, 5)
.background(Capsule().fill(.teal.opacity(0.12)))
.overlay(Capsule().strokeBorder(.teal.opacity(0.22), lineWidth: 1))
.foregroundStyle(.teal)
}

private func link(symbol: String, label: String, url: String) -> some View {
Button {
if let target = URL(string: url) { NSWorkspace.shared.open(target) }
} label: {
HStack(spacing: 6) {
Image(systemName: symbol).font(.caption)
Text(label).font(.caption.weight(.medium))
}.padding(.horizontal, 12).padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 9, style: .continuous).fill(.secondary.opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 9, style: .continuous)
.strokeBorder(.secondary.opacity(0.16), lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
}.buttonStyle(.plain)
}
}
72 changes: 72 additions & 0 deletions Sources/EncajeApp/UI/AboutWindowController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import AppKit
import Combine
import SwiftUI

@MainActor
final class AboutWindowController {
private var window: NSWindow?
private var layoutObserver: AnyCancellable?

func show() {
let target = window ?? makeWindow()
window = target
resize(target)
if layoutObserver == nil {
layoutObserver = Publishers.CombineLatest(
UpdateManager.shared.$phase.map { Self.layoutKey($0) },
UpdateManager.shared.$manualCheckStatus
)
.map { "\($0)|\($1)" }
.removeDuplicates()
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard let self, let window = self.window, window.isVisible else { return }
self.resize(window)
}
}
NSApplication.shared.activate(ignoringOtherApps: true)
target.makeKeyAndOrderFront(nil)
}

private func makeWindow() -> NSWindow {
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: measuredSize()),
styleMask: [.titled, .closable, .fullSizeContentView], backing: .buffered, defer: false)
window.title = localized("About Encaje", "Acerca de Encaje")
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.titlebarSeparatorStyle = .none
window.isReleasedWhenClosed = false
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
window.standardWindowButton(.zoomButton)?.isHidden = true
let hosting = NSHostingView(rootView: AboutView())
hosting.sizingOptions = []
window.contentView = hosting
window.center()
return window
}

private func resize(_ window: NSWindow) {
let size = measuredSize()
let topLeft = NSPoint(x: window.frame.minX, y: window.frame.maxY)
window.contentMinSize = size
window.contentMaxSize = size
window.setContentSize(size)
window.setFrameTopLeftPoint(topLeft)
}

private func measuredSize() -> NSSize {
NSHostingView(rootView: AboutView()).intrinsicContentSize
}

private nonisolated static func layoutKey(_ phase: UpdateManager.Phase) -> String {
switch phase {
case .idle: return "idle"
case .available: return "available"
case .downloading: return "downloading"
case .readyToInstall: return "readyToInstall"
case .installing: return "installing"
case .failed: return "failed"
}
}
}
Loading
Loading