From 2034102395ed4d5cca6c784d59c22478b810e1ec Mon Sep 17 00:00:00 2001
From: StevenACZ <51928039+StevenACZ@users.noreply.github.com>
Date: Fri, 18 Sep 2026 01:32:30 -0500
Subject: [PATCH 1/2] feat(updates): visible update flow, opaque panel and
About window
Show the detected update in the menu bar panel with download progress,
a ready-to-install state with Install now and Later, and relaunch the app
after installing. Make the panel opaque and add an About window with the
version, update status and project links.
---
AGENTS.md | 2 +
Sources/EncajeApp/EncajeApp.swift | 10 +-
Sources/EncajeApp/MenuBarPopover.swift | 8 +-
.../Permissions/PermissionGuidePanel.swift | 8 +-
Sources/EncajeApp/UI/AboutView.swift | 189 ++++++++++
.../EncajeApp/UI/AboutWindowController.swift | 72 ++++
Sources/EncajeApp/UI/PreferencesView.swift | 6 +-
Sources/EncajeApp/UI/UpdateViews.swift | 144 ++++++-
Sources/EncajeApp/UpdateManager.swift | 116 +++++-
Sources/EncajeApp/WelcomeView.swift | 4 +-
Tests/EncajeAppTests/UpdateManagerTests.swift | 356 +++++++++++++++++-
11 files changed, 870 insertions(+), 45 deletions(-)
create mode 100644 Sources/EncajeApp/UI/AboutView.swift
create mode 100644 Sources/EncajeApp/UI/AboutWindowController.swift
diff --git a/AGENTS.md b/AGENTS.md
index 5854934..19e2fdb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/Sources/EncajeApp/EncajeApp.swift b/Sources/EncajeApp/EncajeApp.swift
index eef9131..de67ce7 100644
--- a/Sources/EncajeApp/EncajeApp.swift
+++ b/Sources/EncajeApp/EncajeApp.swift
@@ -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()
@@ -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 {
diff --git a/Sources/EncajeApp/MenuBarPopover.swift b/Sources/EncajeApp/MenuBarPopover.swift
index 7162094..414f0a1 100644
--- a/Sources/EncajeApp/MenuBarPopover.swift
+++ b/Sources/EncajeApp/MenuBarPopover.swift
@@ -149,6 +149,9 @@ private struct MenuBarPopoverView: View {
}
Spacer(minLength: 0)
}
+ if updates.available, updates.phase != .idle {
+ UpdateCardView()
+ }
Button {
model.paused.toggle()
} label: {
@@ -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",
@@ -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 {
diff --git a/Sources/EncajeApp/Permissions/PermissionGuidePanel.swift b/Sources/EncajeApp/Permissions/PermissionGuidePanel.swift
index b173ad0..533e93f 100644
--- a/Sources/EncajeApp/Permissions/PermissionGuidePanel.swift
+++ b/Sources/EncajeApp/Permissions/PermissionGuidePanel.swift
@@ -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()
@@ -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(
@@ -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)
}
}
diff --git a/Sources/EncajeApp/UI/AboutView.swift b/Sources/EncajeApp/UI/AboutView.swift
new file mode 100644
index 0000000..c75523a
--- /dev/null
+++ b/Sources/EncajeApp/UI/AboutView.swift
@@ -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)
+ }
+}
diff --git a/Sources/EncajeApp/UI/AboutWindowController.swift b/Sources/EncajeApp/UI/AboutWindowController.swift
new file mode 100644
index 0000000..2d9fd9b
--- /dev/null
+++ b/Sources/EncajeApp/UI/AboutWindowController.swift
@@ -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"
+ }
+ }
+}
diff --git a/Sources/EncajeApp/UI/PreferencesView.swift b/Sources/EncajeApp/UI/PreferencesView.swift
index 2c21b34..111cad7 100644
--- a/Sources/EncajeApp/UI/PreferencesView.swift
+++ b/Sources/EncajeApp/UI/PreferencesView.swift
@@ -22,7 +22,7 @@ struct PreferencesView: View {
GroupBox {
HStack(spacing: 12) {
Image(systemName: permissions.granted ? "checkmark.shield.fill" : "hand.raised.fill")
- .font(.title2).foregroundStyle(.teal)
+ .font(.title2).foregroundStyle(permissions.granted ? Color.green : Color.blue)
VStack(alignment: .leading, spacing: 3) {
Text(localized("Accessibility", "Accesibilidad")).font(.headline)
Text(
@@ -40,7 +40,9 @@ struct PreferencesView: View {
Button(
permissions.granted
? localized("Review setup", "Ver bienvenida")
- : localized("Allow access", "Dar acceso"), action: showWelcome)
+ : localized("Allow access", "Dar acceso"), action: showWelcome
+ )
+ .tint(.blue)
}.padding(9)
}
GroupBox {
diff --git a/Sources/EncajeApp/UI/UpdateViews.swift b/Sources/EncajeApp/UI/UpdateViews.swift
index 961adbd..11700bc 100644
--- a/Sources/EncajeApp/UI/UpdateViews.swift
+++ b/Sources/EncajeApp/UI/UpdateViews.swift
@@ -64,27 +64,165 @@ struct UpdateActionView: View {
updates.installPendingUpdate()
} label: {
Label(
- localized("Install update", "Instalar actualización") + " · " + version,
+ localized("Install update", "Instalar actualización")
+ + (version.isEmpty ? "" : " · " + version),
systemImage: "arrow.down.circle")
}
case .downloading(let fraction):
VStack(alignment: .leading, spacing: 5) {
Text(localized("Downloading update…", "Descargando actualización…"))
if let fraction {
- ProgressView(value: fraction)
+ HStack(spacing: 8) {
+ ProgressView(value: fraction)
+ Text("\(Int((fraction * 100).rounded())) %").font(.caption.monospacedDigit())
+ .foregroundStyle(.secondary)
+ }
} else {
ProgressView().controlSize(.small)
}
}
+ case .readyToInstall(let version):
+ HStack(spacing: 8) {
+ Button(
+ localized("Install now", "Instalar ahora") + (version.isEmpty ? "" : " · " + version)
+ ) {
+ updates.installNow()
+ }
+ Button(localized("Later", "Más tarde")) {
+ updates.installLater()
+ }
+ }
case .installing:
Label(
localized("Installing and restarting…", "Instalando y reiniciando…"),
systemImage: "arrow.triangle.2.circlepath")
case .failed:
Button(localized("Update failed. Retry", "La actualización falló. Reintentar")) {
- updates.installPendingUpdate()
+ updates.installNow()
}
}
}.font(.callout).frame(maxWidth: .infinity, alignment: .leading)
}
}
+
+struct UpdateCardView: View {
+ @ObservedObject private var updates = UpdateManager.shared
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: symbol).font(.system(size: 22)).foregroundStyle(.teal)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title).font(.subheadline.weight(.semibold))
+ if let subtitle {
+ Text(subtitle).font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ controls
+ }.padding(12)
+ .background(RoundedRectangle(cornerRadius: 12).fill(.teal.opacity(0.12)))
+ .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(.teal.opacity(0.22), lineWidth: 1))
+ .contentShape(RoundedRectangle(cornerRadius: 12))
+ .animation(.easeInOut(duration: 0.25), value: updates.phase)
+ }
+
+ private var version: String {
+ switch updates.phase {
+ case .available(let version), .readyToInstall(let version), .failed(let version):
+ return version
+ case .idle, .downloading, .installing:
+ return updates.pendingVersion ?? ""
+ }
+ }
+
+ private var symbol: String {
+ switch updates.phase {
+ case .idle, .available: return "arrow.down.circle.fill"
+ case .downloading: return "arrow.down.circle"
+ case .readyToInstall: return "checkmark.circle.fill"
+ case .installing: return "arrow.triangle.2.circlepath"
+ case .failed: return "exclamationmark.arrow.circlepath"
+ }
+ }
+
+ private var title: String {
+ switch updates.phase {
+ case .idle, .available:
+ return version.isEmpty
+ ? localized("A new version is available", "Nueva versión disponible")
+ : localized("Version \(version) is available", "Nueva versión v\(version) disponible")
+ case .downloading:
+ return version.isEmpty
+ ? localized("Downloading update…", "Descargando actualización…")
+ : localized("Downloading v\(version)…", "Descargando v\(version)…")
+ case .readyToInstall:
+ return version.isEmpty
+ ? localized("Update ready to install", "Actualización lista para instalar")
+ : localized("v\(version) ready to install", "v\(version) lista para instalar")
+ case .installing:
+ return version.isEmpty
+ ? localized("Installing update…", "Instalando actualización…")
+ : localized("Installing v\(version)…", "Instalando v\(version)…")
+ case .failed:
+ return localized("Update failed", "No se pudo actualizar")
+ }
+ }
+
+ private var subtitle: String? {
+ switch updates.phase {
+ case .idle, .available:
+ return localized("One-click download and install", "Descarga e instalación en un clic")
+ case .downloading:
+ return nil
+ case .readyToInstall:
+ return localized(
+ "The app will quit and reopen by itself", "La app se cerrará y volverá a abrir sola")
+ case .installing:
+ return localized("Restarting in a moment", "Se reiniciará en un momento")
+ case .failed:
+ return localized(
+ "Check your connection and try again", "Revisa tu conexión e inténtalo de nuevo")
+ }
+ }
+
+ @ViewBuilder private var controls: some View {
+ switch updates.phase {
+ case .idle:
+ EmptyView()
+ case .available:
+ prominentButton(localized("Update", "Actualizar")) { updates.installPendingUpdate() }
+ case .downloading(let fraction):
+ HStack(spacing: 8) {
+ if let fraction {
+ ProgressView(value: fraction).progressViewStyle(.linear).tint(.teal)
+ Text("\(Int((fraction * 100).rounded())) %").font(.caption.monospacedDigit())
+ .foregroundStyle(.secondary)
+ } else {
+ ProgressView().progressViewStyle(.linear).tint(.teal)
+ }
+ }
+ case .readyToInstall:
+ HStack(spacing: 8) {
+ prominentButton(localized("Install now", "Instalar ahora")) { updates.installNow() }
+ Button {
+ updates.installLater()
+ } label: {
+ Text(localized("Later", "Más tarde")).frame(maxWidth: .infinity).padding(.vertical, 6)
+ .contentShape(Rectangle())
+ }.buttonStyle(.plain).foregroundStyle(.secondary)
+ }
+ case .installing:
+ ProgressView().progressViewStyle(.linear).tint(.teal)
+ case .failed:
+ prominentButton(localized("Retry", "Reintentar")) { updates.installNow() }
+ }
+ }
+
+ private func prominentButton(_ text: String, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ Text(text).frame(maxWidth: .infinity).contentShape(Rectangle())
+ }.buttonStyle(.borderedProminent).tint(.teal)
+ }
+}
diff --git a/Sources/EncajeApp/UpdateManager.swift b/Sources/EncajeApp/UpdateManager.swift
index dc58be7..1bd5c18 100644
--- a/Sources/EncajeApp/UpdateManager.swift
+++ b/Sources/EncajeApp/UpdateManager.swift
@@ -13,6 +13,7 @@ final class UpdateManager: ObservableObject {
case idle
case available(version: String)
case downloading(fraction: Double?)
+ case readyToInstall(version: String)
case installing
case failed(version: String)
}
@@ -25,10 +26,12 @@ final class UpdateManager: ObservableObject {
}
static let autoCheckDefaultsKey = "autoUpdateCheckEnabled"
+ static let installNowCheckRetryLimit = 40
@Published private(set) var phase: Phase = .idle
@Published private(set) var releasePageURL: URL?
@Published private(set) var manualCheckStatus: ManualCheckStatus = .idle
+ @Published private(set) var pendingVersion: String?
@Published private(set) var autoCheckEnabled: Bool
let isDevelopmentBuild =
@@ -43,8 +46,10 @@ final class UpdateManager: ObservableObject {
private var driver: Driver?
private var updaterDelegate: UpdaterDelegate?
- private var installRequested = false
- private var pendingVersion: String?
+ private(set) var installRequested = false
+ private(set) var installNowRequested = false
+ private(set) var resumeCheckPending = false
+ private var pendingInstallReply: ((SPUUserUpdateChoice) -> Void)?
private var pendingIsInformationOnly = false
private var expectedDownloadBytes: UInt64 = 0
private var receivedDownloadBytes: UInt64 = 0
@@ -105,6 +110,26 @@ final class UpdateManager: ObservableObject {
updater.checkForUpdates()
}
+ func installNow() {
+ guard phase != .installing else { return }
+ if let reply = pendingInstallReply {
+ pendingInstallReply = nil
+ installRequested = true
+ phase = .installing
+ reply(.install)
+ return
+ }
+ guard updater != nil else { return }
+ handleInstallNowRequested()
+ }
+
+ func installLater() {
+ guard let reply = pendingInstallReply else { return }
+ pendingInstallReply = nil
+ installRequested = false
+ reply(.dismiss)
+ }
+
func checkForUpdatesManually() {
guard let updater, updater.sessionInProgress == false else { return }
manualCheckResetTask?.cancel()
@@ -123,21 +148,55 @@ final class UpdateManager: ObservableObject {
phase = .downloading(fraction: nil)
}
+ func handleInstallNowRequested() {
+ installRequested = true
+ installNowRequested = true
+ resumeCheckPending = true
+ phase = .installing
+ startInstallNowCheck(attempt: 0)
+ }
+
+ func startInstallNowCheck(attempt: Int) {
+ guard resumeCheckPending else { return }
+ if let updater, updater.sessionInProgress == false {
+ resumeCheckPending = false
+ updater.checkForUpdates()
+ return
+ }
+ guard attempt < Self.installNowCheckRetryLimit else {
+ installRequested = false
+ installNowRequested = false
+ resumeCheckPending = false
+ phase = .failed(version: pendingVersion ?? "")
+ return
+ }
+ Task { [weak self] in
+ try? await Task.sleep(nanoseconds: 250_000_000)
+ guard let self, !Task.isCancelled else { return }
+ self.startInstallNowCheck(attempt: attempt + 1)
+ }
+ }
+
func handleUpdateFound(
version: String,
releasePage: URL?,
- informationOnly: Bool
+ informationOnly: Bool,
+ stage: SPUUserUpdateStage
) -> SPUUserUpdateChoice {
+ resumeCheckPending = false
pendingVersion = version
pendingIsInformationOnly = informationOnly
releasePageURL = releasePage
finishManualCheck(status: .idle)
- if installRequested && !informationOnly {
+ let prepared = stage != .notDownloaded
+ if !informationOnly, installRequested || (prepared && installNowRequested) {
+ phase = prepared ? .installing : .downloading(fraction: nil)
return .install
}
installRequested = false
- phase = .available(version: version)
+ installNowRequested = false
+ phase = prepared ? .readyToInstall(version: version) : .available(version: version)
return .dismiss
}
@@ -162,9 +221,16 @@ final class UpdateManager: ObservableObject {
phase = .installing
}
- func handleReadyToInstall() -> SPUUserUpdateChoice {
- phase = .installing
- return .install
+ func handleReadyToInstall(reply: @escaping (SPUUserUpdateChoice) -> Void) {
+ if installNowRequested {
+ installNowRequested = false
+ resumeCheckPending = false
+ phase = .installing
+ reply(.install)
+ return
+ }
+ pendingInstallReply = reply
+ phase = .readyToInstall(version: pendingVersion ?? "")
}
func handleInstalling() {
@@ -172,7 +238,13 @@ final class UpdateManager: ObservableObject {
}
func handleNotFound() {
+ if resumeCheckPending {
+ pendingInstallReply = nil
+ return
+ }
installRequested = false
+ installNowRequested = false
+ pendingInstallReply = nil
pendingVersion = nil
pendingIsInformationOnly = false
releasePageURL = nil
@@ -181,21 +253,40 @@ final class UpdateManager: ObservableObject {
}
func handleError(_ message: String) {
+ if resumeCheckPending {
+ pendingInstallReply = nil
+ return
+ }
finishManualCheck(status: .failed)
if installRequested, let pendingVersion {
log.error("Update install failed: \(message, privacy: .public)")
phase = .failed(version: pendingVersion)
} else {
log.debug("Update check failed silently")
- phase = pendingVersion.map { .available(version: $0) } ?? .idle
+ switch phase {
+ case .readyToInstall, .installing:
+ phase = .readyToInstall(version: pendingVersion ?? "")
+ case .idle, .available, .downloading, .failed:
+ phase = pendingVersion.map { .available(version: $0) } ?? .idle
+ }
}
installRequested = false
+ installNowRequested = false
+ pendingInstallReply = nil
}
func handleDismissInstallation() {
+ if resumeCheckPending {
+ pendingInstallReply = nil
+ return
+ }
installRequested = false
+ installNowRequested = false
+ pendingInstallReply = nil
switch phase {
- case .downloading, .installing:
+ case .installing, .readyToInstall:
+ phase = .readyToInstall(version: pendingVersion ?? "")
+ case .downloading:
phase = pendingVersion.map { .available(version: $0) } ?? .idle
case .idle, .available, .failed:
break
@@ -241,7 +332,8 @@ private final class Driver: NSObject, SPUUserDriver {
let choice = manager.handleUpdateFound(
version: appcastItem.displayVersionString,
releasePage: appcastItem.infoURL,
- informationOnly: appcastItem.isInformationOnlyUpdate
+ informationOnly: appcastItem.isInformationOnlyUpdate,
+ stage: state.stage
)
reply(choice)
}
@@ -279,7 +371,7 @@ private final class Driver: NSObject, SPUUserDriver {
func showExtractionReceivedProgress(_ progress: Double) {}
func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) {
- reply(manager.handleReadyToInstall())
+ manager.handleReadyToInstall(reply: reply)
}
func showInstallingUpdate(
diff --git a/Sources/EncajeApp/WelcomeView.swift b/Sources/EncajeApp/WelcomeView.swift
index c1dce92..a87e4c6 100644
--- a/Sources/EncajeApp/WelcomeView.swift
+++ b/Sources/EncajeApp/WelcomeView.swift
@@ -17,7 +17,7 @@ struct WelcomeView: View {
Circle().fill(.teal.opacity(0.10)).frame(width: 126, height: 126)
if permissions.granted {
Image(systemName: "checkmark.seal.fill").font(.system(size: 68)).foregroundStyle(
- .teal.gradient
+ .green.gradient
)
.transition(.scale.combined(with: .opacity))
} else {
@@ -89,7 +89,7 @@ struct WelcomeView: View {
Button(localized("Allow Accessibility", "Dar acceso a Accesibilidad")) {
permissions.request(from: sourceFrame())
}
- .buttonStyle(.borderedProminent).controlSize(.large)
+ .buttonStyle(.borderedProminent).controlSize(.large).tint(.blue)
Text(
localized(
"Drag Encaje into the list, then enable its switch if asked.",
diff --git a/Tests/EncajeAppTests/UpdateManagerTests.swift b/Tests/EncajeAppTests/UpdateManagerTests.swift
index 30aeb43..f137107 100644
--- a/Tests/EncajeAppTests/UpdateManagerTests.swift
+++ b/Tests/EncajeAppTests/UpdateManagerTests.swift
@@ -1,3 +1,4 @@
+import Sparkle
import XCTest
@testable import EncajeApp
@@ -14,7 +15,8 @@ final class UpdateManagerTests: XCTestCase {
let choice = manager.handleUpdateFound(
version: "9.9.9",
releasePage: URL(string: "https://example.com/release"),
- informationOnly: false
+ informationOnly: false,
+ stage: .notDownloaded
)
XCTAssertEqual(choice, .dismiss)
@@ -27,7 +29,7 @@ final class UpdateManagerTests: XCTestCase {
manager.installPendingUpdate()
let choice = manager.handleUpdateFound(
- version: "9.9.9", releasePage: nil, informationOnly: true)
+ version: "9.9.9", releasePage: nil, informationOnly: true, stage: .notDownloaded)
XCTAssertEqual(choice, .dismiss)
XCTAssertEqual(manager.phase, .available(version: "9.9.9"))
@@ -63,19 +65,104 @@ final class UpdateManagerTests: XCTestCase {
XCTAssertEqual(manager.phase, .downloading(fraction: 1.0))
}
- func testExtractionAndReadyToInstallShowInstalling() {
+ func testExtractionShowsInstalling() {
let manager = makeManager()
manager.handleExtractionStarted()
+
XCTAssertEqual(manager.phase, .installing)
+ }
+
+ func testReadyToInstallHoldsTheReplyAndSurfacesTheChoice() {
+ let manager = makeManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+ var choices: [SPUUserUpdateChoice] = []
+
+ manager.handleReadyToInstall { choices.append($0) }
+
+ XCTAssertTrue(choices.isEmpty)
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9"))
+ }
+
+ func testInstallNowRepliesInstall() {
+ let manager = makeManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+ var choices: [SPUUserUpdateChoice] = []
+ manager.handleReadyToInstall { choices.append($0) }
- XCTAssertEqual(manager.handleReadyToInstall(), .install)
+ manager.installNow()
+
+ XCTAssertEqual(choices, [.install])
XCTAssertEqual(manager.phase, .installing)
}
+ func testInstallLaterThenScheduledPreparedCheckKeepsReadyToInstall() {
+ let manager = makeManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+ var choices: [SPUUserUpdateChoice] = []
+ manager.handleReadyToInstall { choices.append($0) }
+
+ manager.installLater()
+ manager.handleDismissInstallation()
+ let choice = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .installing)
+
+ XCTAssertEqual(choices, [.dismiss])
+ XCTAssertEqual(choice, .dismiss)
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9"))
+ }
+
+ func testScheduledPreparedUpdateSurfacesReadyToInstall() {
+ let manager = makeManager()
+ let choice = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .installing)
+
+ XCTAssertEqual(choice, .dismiss)
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9"))
+ }
+
+ func testPreparedUpdateInstallsWhenInstallNowWasRequested() {
+ let manager = makeManager()
+ manager.handleInstallNowRequested()
+
+ let choice = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .installing)
+
+ XCTAssertEqual(choice, .install)
+ XCTAssertEqual(manager.phase, .installing)
+ }
+
+ func testInstallNowRepliesExactlyOnce() {
+ let manager = makeManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+ var choices: [SPUUserUpdateChoice] = []
+ manager.handleReadyToInstall { choices.append($0) }
+
+ manager.installNow()
+ manager.installNow()
+
+ XCTAssertEqual(choices, [.install])
+ }
+
+ func testInstallFailureAfterInstallNowIsVisible() {
+ let manager = makeManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+ manager.handleReadyToInstall { _ in }
+ manager.installNow()
+
+ manager.handleError("installer failed")
+
+ XCTAssertEqual(manager.phase, .failed(version: "9.9.9"))
+ }
+
func testScheduledCheckErrorStaysSilent() {
let manager = makeManager()
let choice = manager.handleUpdateFound(
- version: "9.9.9", releasePage: nil, informationOnly: false)
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
XCTAssertEqual(choice, .dismiss)
manager.handleError("network down")
@@ -93,7 +180,7 @@ final class UpdateManagerTests: XCTestCase {
func testDismissDuringDownloadRollsBackToAvailable() {
let manager = makeManager()
_ = manager.handleUpdateFound(
- version: "9.9.9", releasePage: nil, informationOnly: false)
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
manager.handleDownloadInitiated()
manager.handleDismissInstallation()
@@ -104,7 +191,7 @@ final class UpdateManagerTests: XCTestCase {
func testDismissKeepsPendingRowAlive() {
let manager = makeManager()
_ = manager.handleUpdateFound(
- version: "9.9.9", releasePage: nil, informationOnly: false)
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
manager.handleDismissInstallation()
@@ -114,7 +201,7 @@ final class UpdateManagerTests: XCTestCase {
func testNotFoundClearsPendingState() {
let manager = makeManager()
_ = manager.handleUpdateFound(
- version: "9.9.9", releasePage: nil, informationOnly: false)
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
manager.handleNotFound()
@@ -123,19 +210,264 @@ final class UpdateManagerTests: XCTestCase {
}
func testCancelledInstallationCannotAuthorizeTheNextCheck() {
let manager = makeManager()
- _ = manager.handleUpdateFound(version: "1.0.1", releasePage: nil, informationOnly: false)
+ _ = manager.handleUpdateFound(
+ version: "1.0.1", releasePage: nil, informationOnly: false, stage: .notDownloaded)
manager.handleInstallRequested()
XCTAssertEqual(
- manager.handleUpdateFound(version: "1.0.1", releasePage: nil, informationOnly: false),
+ manager.handleUpdateFound(
+ version: "1.0.1", releasePage: nil, informationOnly: false, stage: .notDownloaded),
.install)
manager.handleDismissInstallation()
XCTAssertEqual(
- manager.handleUpdateFound(version: "1.0.1", releasePage: nil, informationOnly: false),
+ manager.handleUpdateFound(
+ version: "1.0.1", releasePage: nil, informationOnly: false, stage: .notDownloaded),
.dismiss)
manager.handleInstallRequested()
XCTAssertEqual(
- manager.handleUpdateFound(version: "1.0.1", releasePage: nil, informationOnly: false),
+ manager.handleUpdateFound(
+ version: "1.0.1", releasePage: nil, informationOnly: false, stage: .notDownloaded),
+ .install)
+ }
+
+ func testInstallNowWithoutHeldReplyStartsOneCheckAndSetsBothFlags() {
+ let manager = makeManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .installing)
+
+ manager.handleInstallNowRequested()
+
+ XCTAssertEqual(manager.phase, .installing)
+ XCTAssertTrue(manager.installRequested)
+ XCTAssertTrue(manager.installNowRequested)
+ XCTAssertTrue(manager.resumeCheckPending)
+ }
+
+ func testResumedDownloadedUpdateKeepsInstallNowRequested() {
+ let manager = makeManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .installing)
+ manager.handleInstallNowRequested()
+
+ let choice = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .downloaded)
+
+ XCTAssertEqual(choice, .install)
+ XCTAssertTrue(manager.installNowRequested)
+ }
+
+ func testReadyAfterResumedInstallNowRepliesInstallAndClearsTheFlag() {
+ let manager = makeManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .installing)
+ manager.handleInstallNowRequested()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .downloaded)
+ var choices: [SPUUserUpdateChoice] = []
+
+ manager.handleReadyToInstall { choices.append($0) }
+
+ XCTAssertEqual(choices, [.install])
+ XCTAssertFalse(manager.installNowRequested)
+ XCTAssertEqual(manager.phase, .installing)
+ }
+
+ func testInstallLaterRepliesExactlyOnce() {
+ let manager = makeManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+ var choices: [SPUUserUpdateChoice] = []
+ manager.handleReadyToInstall { choices.append($0) }
+
+ manager.installLater()
+ manager.installLater()
+
+ XCTAssertEqual(choices, [.dismiss])
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9"))
+ }
+
+ func testDismissWhileInstallingKeepsReadyToInstall() {
+ let manager = makeManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+ manager.handleReadyToInstall { _ in }
+ manager.installNow()
+
+ manager.handleDismissInstallation()
+
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9"))
+ }
+
+ func testInstallNowWhileInstallingIsANoOp() {
+ let manager = makeManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .notDownloaded)
+ var choices: [SPUUserUpdateChoice] = []
+ manager.handleReadyToInstall { choices.append($0) }
+ manager.installNow()
+ manager.handleReadyToInstall { choices.append($0) }
+ manager.handleInstalling()
+
+ manager.installNow()
+
+ XCTAssertEqual(choices, [.install])
+ XCTAssertFalse(manager.resumeCheckPending)
+ }
+
+ func testReadyAfterInstallNowInstallsEvenWithoutAVersion() {
+ let manager = makeManager()
+ manager.handleInstallNowRequested()
+ var choices: [SPUUserUpdateChoice] = []
+
+ manager.handleReadyToInstall { choices.append($0) }
+
+ XCTAssertEqual(choices, [.install])
+ XCTAssertEqual(manager.phase, .installing)
+ XCTAssertFalse(manager.installNowRequested)
+ XCTAssertFalse(manager.resumeCheckPending)
+ }
+
+ func testDismissedFoundUpdateDropsTheInstallNowIntent() {
+ let manager = makeManager()
+ manager.handleInstallNowRequested()
+
+ XCTAssertEqual(
+ manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: true, stage: .notDownloaded),
+ .dismiss)
+ XCTAssertFalse(manager.installNowRequested)
+
+ var choices: [SPUUserUpdateChoice] = []
+ XCTAssertEqual(
+ manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .downloaded),
+ .dismiss)
+ manager.handleReadyToInstall { choices.append($0) }
+
+ XCTAssertTrue(choices.isEmpty)
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9"))
+ }
+
+ func testNotFoundDropsTheInstallNowIntent() {
+ let manager = makeManager()
+ manager.handleInstallNowRequested()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .downloaded)
+
+ manager.handleNotFound()
+
+ XCTAssertFalse(manager.installNowRequested)
+ }
+
+ func testErrorDropsTheInstallNowIntent() {
+ let manager = makeManager()
+ manager.handleInstallNowRequested()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .downloaded)
+
+ manager.handleError("installer failed")
+
+ XCTAssertEqual(manager.phase, .failed(version: "9.9.9"))
+ XCTAssertFalse(manager.installNowRequested)
+ }
+
+ func testDismissInstallationDropsTheInstallNowIntent() {
+ let manager = makeManager()
+ manager.handleInstallNowRequested()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .downloaded)
+
+ manager.handleDismissInstallation()
+
+ XCTAssertFalse(manager.installNowRequested)
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9"))
+ }
+
+ func testErrorWhileReadyToInstallKeepsTheCard() {
+ let manager = makeManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .installing)
+
+ manager.handleError("network down")
+
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9"))
+ }
+
+ func testOldSessionDismissDuringResumeCheckKeepsTheInstallIntent() {
+ let manager = makeManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .installing)
+ manager.handleInstallNowRequested()
+
+ manager.handleDismissInstallation()
+
+ XCTAssertTrue(manager.installRequested)
+ XCTAssertTrue(manager.installNowRequested)
+ XCTAssertEqual(manager.phase, .installing)
+
+ let choice = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .installing)
+
+ XCTAssertEqual(choice, .install)
+ }
+
+ func testResumeCheckExhaustionFailsAndClearsTheInstallIntent() {
+ let manager = makeManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .installing)
+ manager.handleInstallNowRequested()
+
+ manager.startInstallNowCheck(attempt: UpdateManager.installNowCheckRetryLimit)
+
+ XCTAssertEqual(manager.phase, .failed(version: "9.9.9"))
+ XCTAssertFalse(manager.installRequested)
+ XCTAssertFalse(manager.installNowRequested)
+ XCTAssertFalse(manager.resumeCheckPending)
+ }
+
+ func testReachingTheNewSessionStopsTheResumeLoop() {
+ let manager = makeManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .installing)
+ manager.handleInstallNowRequested()
+
+ XCTAssertEqual(
+ manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .downloaded),
.install)
+ XCTAssertFalse(manager.resumeCheckPending)
+
+ manager.startInstallNowCheck(attempt: 0)
+
+ XCTAssertEqual(manager.phase, .installing)
+ XCTAssertTrue(manager.installRequested)
+ XCTAssertTrue(manager.installNowRequested)
+ }
+
+ func testExhaustionAfterTheNewSessionStartedCannotFailTheInstall() {
+ let manager = makeManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .installing)
+ manager.handleInstallNowRequested()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .downloaded)
+
+ manager.startInstallNowCheck(attempt: UpdateManager.installNowCheckRetryLimit)
+
+ XCTAssertEqual(manager.phase, .installing)
+ XCTAssertTrue(manager.installRequested)
+ XCTAssertTrue(manager.installNowRequested)
+ }
+
+ func testInstallNowWithoutUpdaterKeepsThePhase() {
+ let manager = makeManager()
+ _ = manager.handleUpdateFound(
+ version: "9.9.9", releasePage: nil, informationOnly: false, stage: .installing)
+
+ manager.installNow()
+
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9"))
+ XCTAssertFalse(manager.installRequested)
+ XCTAssertFalse(manager.installNowRequested)
}
}
From e4f8bc262a13fdce8869bbd0408bdb8dd2a4e45b Mon Sep 17 00:00:00 2001
From: StevenACZ <51928039+StevenACZ@users.noreply.github.com>
Date: Fri, 18 Sep 2026 01:32:30 -0500
Subject: [PATCH 2/2] chore(release): prepare v1.1.0
---
Assets/Info.plist | 4 ++--
CHANGELOG.md | 16 ++++++++++++++++
2 files changed, 18 insertions(+), 2 deletions(-)
diff --git a/Assets/Info.plist b/Assets/Info.plist
index 22877f3..82e8520 100644
--- a/Assets/Info.plist
+++ b/Assets/Info.plist
@@ -22,9 +22,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.0.1
+ 1.1.0
CFBundleVersion
- 11
+ 13
LSMinimumSystemVersion
14.0
LSUIElement
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0a97124..1e23948 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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