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