@@ -19,12 +19,15 @@ final class AppUpdater: ObservableObject {
1919 @Published var phase : Phase = . idle
2020
2121 private var panel : NSPanel ?
22+ /// Kept so Retry can run after a failed attempt (phase does not carry URL).
23+ private var lastUpdateURL : URL ?
2224
2325 private let fileManager = FileManager . default
2426
2527 func presentUpdate( remoteURL: URL , remoteVersion: String ) {
2628 Task { @MainActor in
2729 let notes = ( try ? await AppUpdateChecker . fetchLatestReleaseNotes ( ) ) ?? " "
30+ lastUpdateURL = remoteURL
2831 phase = . updateAvailable( version: remoteVersion, url: remoteURL, releaseNotes: notes)
2932 showPanel ( )
3033 }
@@ -33,19 +36,33 @@ final class AppUpdater: ObservableObject {
3336 func dismiss( ) {
3437 closePanel ( )
3538 phase = . idle
39+ lastUpdateURL = nil
40+ NotificationCenter . default. post ( name: . appUpdateFlowDidFinish, object: nil )
41+ }
42+
43+ func retry( ) {
44+ guard let url = lastUpdateURL else { return }
45+ Task { await downloadAndInstall ( remoteURL: url) }
3646 }
3747
3848 func downloadAndInstall( remoteURL: URL ) async {
49+ if case . downloading = phase { return }
3950 phase = . downloading
51+ lastUpdateURL = remoteURL
52+
53+ guard let downloads = fileManager. urls ( for: . downloadsDirectory, in: . userDomainMask) . first else {
54+ phase = . failed( " Downloads folder unavailable. " )
55+ return
56+ }
57+
58+ let extractDir = downloads. appendingPathComponent ( " NumWorksUpdate " , isDirectory: true )
4059
4160 do {
4261 let ( tmpURL, response) = try await URLSession . shared. download ( from: remoteURL)
4362
44- let downloads = fileManager. urls ( for: . downloadsDirectory, in: . userDomainMask) . first!
45-
4663 var filename = response. suggestedFilename ?? remoteURL. lastPathComponent
4764 if filename. isEmpty { filename = " NumWorks.zip " }
48- if filename. lowercased ( ) . hasSuffix ( " .zip " ) == false {
65+ if ! filename. lowercased ( ) . hasSuffix ( " .zip " ) {
4966 filename += " .zip "
5067 }
5168
@@ -54,32 +71,25 @@ final class AppUpdater: ObservableObject {
5471 try ? fileManager. removeItem ( at: zipURL)
5572 try fileManager. moveItem ( at: tmpURL, to: zipURL)
5673
57- if zipURL. pathExtension. lowercased ( ) != " zip " {
58- throw NSError ( domain: " AppUpdater " , code: 2 )
59- }
74+ try ? fileManager. removeItem ( at: extractDir)
75+ try fileManager. createDirectory ( at: extractDir, withIntermediateDirectories: true )
6076
61- let unzipStart = Date ( )
62- try unzip ( zipURL: zipURL, to: downloads)
77+ try unzip ( zipURL: zipURL, to: extractDir)
6378 try ? fileManager. removeItem ( at: zipURL)
6479
65- let extractedApp = try findNewestExtractedApp ( in: downloads , since : unzipStart )
80+ let extractedApp = try findExtractedApp ( in: extractDir )
6681 let targetApp = downloads. appendingPathComponent ( " NumWorks.app " )
6782
6883 if extractedApp. standardizedFileURL != targetApp. standardizedFileURL {
6984 try ? fileManager. removeItem ( at: targetApp)
7085 try fileManager. moveItem ( at: extractedApp, to: targetApp)
71- } else {
72- // If it already extracted as NumWorks.app, make sure it replaces any previous copy.
73- // (At this point, targetApp is the extracted app.)
7486 }
7587
88+ try ? fileManager. removeItem ( at: extractDir)
7689 phase = . readyToOpen
7790 } catch {
78- // Best effort cleanup
79- if let downloads = fileManager. urls ( for: . downloadsDirectory, in: . userDomainMask) . first {
80- let fallback = downloads. appendingPathComponent ( " NumWorks.zip " )
81- try ? fileManager. removeItem ( at: fallback)
82- }
91+ try ? fileManager. removeItem ( at: extractDir)
92+ try ? fileManager. removeItem ( at: downloads. appendingPathComponent ( " NumWorks.zip " ) )
8393 phase = . failed( error. localizedDescription)
8494 }
8595 }
@@ -98,31 +108,23 @@ final class AppUpdater: ObservableObject {
98108 NSApp . terminate ( nil )
99109 }
100110
101- private func findNewestExtractedApp( in downloads: URL , since: Date ) throws -> URL {
111+ /// Finds the first .app bundle under the given directory (e.g. our dedicated extract folder).
112+ /// Unzip often restores timestamps from the zip, so we do not filter by date.
113+ private func findExtractedApp( in directory: URL ) throws -> URL {
102114 let fm = fileManager
103- let keys : Set < URLResourceKey > = [ . contentModificationDateKey , . isDirectoryKey]
115+ let keys : Set < URLResourceKey > = [ . isDirectoryKey]
104116
105- guard let e = fm. enumerator ( at: downloads , includingPropertiesForKeys: Array ( keys) , options: [ . skipsHiddenFiles] ) else {
117+ guard let e = fm. enumerator ( at: directory , includingPropertiesForKeys: Array ( keys) , options: [ . skipsHiddenFiles] ) else {
106118 throw NSError ( domain: " AppUpdater " , code: 3 )
107119 }
108120
109- var bestURL : URL ?
110- var bestDate : Date = since
111-
112121 for case let url as URL in e {
113- if url. pathExtension. lowercased ( ) != " app " { continue }
114-
122+ guard url. pathExtension. lowercased ( ) == " app " else { continue }
115123 let rv = try ? url. resourceValues ( forKeys: keys)
116124 guard rv? . isDirectory == true else { continue }
117-
118- let d = rv? . contentModificationDate ?? . distantPast
119- if d >= bestDate {
120- bestDate = d
121- bestURL = url
122- }
125+ return url
123126 }
124127
125- if let bestURL { return bestURL }
126128 throw NSError ( domain: " AppUpdater " , code: 4 )
127129 }
128130
0 commit comments