diff --git a/Shared/WriteFreely_MultiPlatformApp.swift b/Shared/WriteFreely_MultiPlatformApp.swift index ea741c8..4378ada 100644 --- a/Shared/WriteFreely_MultiPlatformApp.swift +++ b/Shared/WriteFreely_MultiPlatformApp.swift @@ -1,154 +1,146 @@ import SwiftUI #if os(macOS) import Sparkle #endif @main struct CheckForDebugModifier { static func main() { #if os(macOS) if NSEvent.modifierFlags.contains(.shift) { // Clear the launch-to-last-draft values to load a new draft. UserDefaults.shared.setValue(false, forKey: WFDefaults.showAllPostsFlag) UserDefaults.shared.setValue(nil, forKey: WFDefaults.selectedCollectionURL) UserDefaults.shared.setValue(nil, forKey: WFDefaults.lastDraftURL) } else { // No-op } #endif WriteFreely_MultiPlatformApp.main() } } struct WriteFreely_MultiPlatformApp: App { @StateObject private var model = WriteFreelyModel.shared #if os(macOS) - // swiftlint:disable:next weak_delegate @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject var updaterViewModel = MacUpdatesViewModel() @State private var selectedTab = 0 #endif var body: some Scene { WindowGroup { ContentView() .onAppear(perform: { if model.editor.showAllPostsFlag { DispatchQueue.main.async { self.model.selectedCollection = nil self.model.showAllPosts = true showLastDraftOrCreateNewLocalPost() } } else { DispatchQueue.main.async { self.model.selectedCollection = model.editor.fetchSelectedCollectionFromAppStorage() self.model.showAllPosts = false showLastDraftOrCreateNewLocalPost() } } -// DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -// if model.editor.lastDraftURL != nil { -// self.model.selectedPost = model.editor.fetchLastDraftFromAppStorage() -// } else { -// createNewLocalPost() -// } -// } }) .environmentObject(model) .environment(\.managedObjectContext, LocalStorageManager.standard.container.viewContext) // .preferredColorScheme(preferences.selectedColorScheme) // See PreferencesModel for info. } .commands { #if os(macOS) CommandGroup(after: .appInfo) { CheckForUpdatesView(updaterViewModel: updaterViewModel) } #endif CommandGroup(replacing: .newItem, addition: { Button("New Post") { createNewLocalPost() } .keyboardShortcut("n", modifiers: [.command]) }) CommandGroup(after: .newItem) { Button("Refresh Posts") { DispatchQueue.main.async { model.fetchUserCollections() model.fetchUserPosts() } } .disabled(!model.account.isLoggedIn) .keyboardShortcut("r", modifiers: [.command]) } SidebarCommands() #if os(macOS) PostCommands(model: model) #endif CommandGroup(after: .help) { Button("Visit Support Forum") { #if os(macOS) NSWorkspace().open(model.helpURL) #else UIApplication.shared.open(model.helpURL) #endif } } ToolbarCommands() TextEditingCommands() } #if os(macOS) Settings { TabView(selection: $selectedTab) { MacAccountView() .environmentObject(model) .tabItem { Image(systemName: "person.crop.circle") Text("Account") } .tag(0) MacPreferencesView(preferences: model.preferences) .tabItem { Image(systemName: "gear") Text("Preferences") } .tag(1) - MacUpdatesView() + MacUpdatesView(updaterViewModel: updaterViewModel) .tabItem { Image(systemName: "arrow.down.circle") Text("Updates") } .tag(2) } .frame(minWidth: 500, maxWidth: 500, minHeight: 200) .padding() // .preferredColorScheme(preferences.selectedColorScheme) // See PreferencesModel for info. } #endif } private func showLastDraftOrCreateNewLocalPost() { if model.editor.lastDraftURL != nil { self.model.selectedPost = model.editor.fetchLastDraftFromAppStorage() } else { createNewLocalPost() } } private func createNewLocalPost() { withAnimation { // Un-set the currently selected post self.model.selectedPost = nil } // Create the new-post managed object let managedPost = model.editor.generateNewLocalPost(withFont: model.preferences.font) withAnimation { // Set it as the selectedPost DispatchQueue.main.asyncAfter(deadline: .now()) { self.model.selectedPost = managedPost } } } } diff --git a/macOS/AppDelegate.swift b/macOS/AppDelegate.swift index e002e64..410ffe9 100644 --- a/macOS/AppDelegate.swift +++ b/macOS/AppDelegate.swift @@ -1,61 +1,38 @@ import Cocoa import Sparkle class AppDelegate: NSObject, NSApplicationDelegate { - func applicationWillFinishLaunching(_ notification: Notification) { - // Check UserDefaults for values; if the key doesn't exist (e.g., if MacUpdatesView hasn't ever been shown), - // bool(forKey:) returns false, so set SUUpdater.shared() appropriately. - let automaticallyChecksForUpdates = UserDefaults.shared.bool(forKey: WFDefaults.automaticallyChecksForUpdates) - let subscribeToBetaUpdates = UserDefaults.shared.bool(forKey: WFDefaults.subscribeToBetaUpdates) - - // Set Sparkle properties. - SUUpdater.shared()?.automaticallyChecksForUpdates = automaticallyChecksForUpdates - if subscribeToBetaUpdates { - SUUpdater.shared()?.feedURL = URL(string: AppcastFeedUrl.beta.rawValue) - } else { - SUUpdater.shared()?.feedURL = URL(string: AppcastFeedUrl.release.rawValue) - } - - // If enabled, check for updates. - if automaticallyChecksForUpdates { - SUUpdater.shared()?.checkForUpdatesInBackground() - } - } - // MARK: - Window handling when miniaturized into app icon on the Dock // Credit to Henry Cooper (pillboxer) on GitHub: // https://github.com/tact/beta-bugs/issues/31#issuecomment-855914705 // If the window is currently minimized into the Dock, de-miniaturize it (note that if it's minimized // and the user uses OPT+TAB to switch to it, it will be de-miniaturized and brought to the foreground). func applicationDidBecomeActive(_ notification: Notification) { - print("💬 Fired:", #function) if let window = NSApp.windows.first { window.deminiaturize(nil) } } // If we're miniaturizing the window, deactivate it as well by activating Finder.app (note that // this will bring any Finder windows that are behind other apps to the foreground). func applicationDidChangeOcclusionState(_ notification: Notification) { - print("💬 Fired:", #function) if let window = NSApp.windows.first, window.isMiniaturized { NSWorkspace.shared.runningApplications.first(where: { $0.activationPolicy == .regular })?.activate(options: .activateAllWindows) } } lazy var windows = NSWindow() func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - print("💬 Fired:", #function) if !flag { for window in sender.windows { window.makeKeyAndOrderFront(self) } } return true } } diff --git a/macOS/Settings/MacUpdatesView.swift b/macOS/Settings/MacUpdatesView.swift index 53b4f0e..afb6c48 100644 --- a/macOS/Settings/MacUpdatesView.swift +++ b/macOS/Settings/MacUpdatesView.swift @@ -1,95 +1,91 @@ import SwiftUI import Sparkle -enum AppcastFeedUrl: String { - case release = "https://writefreely-files.s3.amazonaws.com/apps/mac/appcast.xml" - case beta = "https://writefreely-files.s3.amazonaws.com/apps/mac/appcast-beta.xml" -} - struct MacUpdatesView: View { + @ObservedObject var updaterViewModel: MacUpdatesViewModel + @AppStorage(WFDefaults.automaticallyChecksForUpdates, store: UserDefaults.shared) var automaticallyChecksForUpdates: Bool = false @AppStorage(WFDefaults.subscribeToBetaUpdates, store: UserDefaults.shared) var subscribeToBetaUpdates: Bool = false + @State private var lastUpdateCheck: Date? private let betaWarningString = """ To get brand new features before each official release, choose "Test versions." Note that test versions may have bugs \ that can cause crashes and data loss. """ static let lastUpdateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .short formatter.timeStyle = .short formatter.doesRelativeDateFormatting = true return formatter }() var body: some View { VStack(spacing: 24) { Toggle(isOn: $automaticallyChecksForUpdates, label: { Text("Check for updates automatically") }) VStack { Button(action: { - SUUpdater.shared()?.checkForUpdates(self) - DispatchQueue.main.async { - lastUpdateCheck = SUUpdater.shared()?.lastUpdateCheckDate + updaterViewModel.checkForUpdates() + // There's a delay between requesting an update, and the timestamp for that update request being + // written to user defaults; we therefore delay updating the "Last checked" UI for one second. + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + lastUpdateCheck = updaterViewModel.getLastUpdateCheckDate() } }, label: { Text("Check For Updates") }) HStack { Text("Last checked:") .font(.caption) if let lastUpdateCheck = lastUpdateCheck { Text(lastUpdateCheck, formatter: Self.lastUpdateFormatter) .font(.caption) } else { Text("Never") .font(.caption) } } } VStack(spacing: 16) { HStack(alignment: .top) { Text("Download:") Picker(selection: $subscribeToBetaUpdates, label: Text("Download:"), content: { Text("Release versions").tag(false) Text("Test versions").tag(true) }) .pickerStyle(RadioGroupPickerStyle()) .labelsHidden() } Text(betaWarningString) .frame(width: 350) .foregroundColor(.secondary) } } .padding() .onAppear { - lastUpdateCheck = SUUpdater.shared()?.lastUpdateCheckDate + lastUpdateCheck = updaterViewModel.getLastUpdateCheckDate() } .onChange(of: automaticallyChecksForUpdates) { value in - SUUpdater.shared()?.automaticallyChecksForUpdates = value + updaterViewModel.automaticallyCheckForUpdates = value } - .onChange(of: subscribeToBetaUpdates) { value in - if value { - SUUpdater.shared()?.feedURL = URL(string: AppcastFeedUrl.beta.rawValue) - } else { - SUUpdater.shared()?.feedURL = URL(string: AppcastFeedUrl.release.rawValue) - } + .onChange(of: subscribeToBetaUpdates) { _ in + updaterViewModel.toggleAllowedChannels() } } } struct MacUpdatesView_Previews: PreviewProvider { static var previews: some View { - MacUpdatesView() + MacUpdatesView(updaterViewModel: MacUpdatesViewModel()) } } diff --git a/macOS/Settings/MacUpdatesViewModel.swift b/macOS/Settings/MacUpdatesViewModel.swift index 6b0c6fa..b960524 100644 --- a/macOS/Settings/MacUpdatesViewModel.swift +++ b/macOS/Settings/MacUpdatesViewModel.swift @@ -1,38 +1,73 @@ /// See https://sparkle-project.org/documentation/programmatic-setup#create-an-updater-in-swiftui import SwiftUI import Sparkle /// This view model class manages Sparkle's updater and publishes when new updates are allowed to be checked. final class MacUpdatesViewModel: ObservableObject { @Published var canCheckForUpdates = false private let updaterController: SPUStandardUpdaterController + private let updaterDelegate = MacUpdatesViewModelDelegate() + + var automaticallyCheckForUpdates: Bool { + get { + return updaterController.updater.automaticallyChecksForUpdates + } + set(newValue) { + updaterController.updater.automaticallyChecksForUpdates = newValue + } + } init() { updaterController = SPUStandardUpdaterController(startingUpdater: true, - updaterDelegate: nil, + updaterDelegate: updaterDelegate, userDriverDelegate: nil) updaterController.updater.publisher(for: \.canCheckForUpdates) .assign(to: &$canCheckForUpdates) + + if automaticallyCheckForUpdates { + updaterController.updater.checkForUpdatesInBackground() + } } func checkForUpdates() { updaterController.checkForUpdates(nil) } + func getLastUpdateCheckDate() -> Date? { + return updaterController.updater.lastUpdateCheckDate + } + + @discardableResult + func toggleAllowedChannels() -> Set { + return updaterDelegate.allowedChannels(for: updaterController.updater) + } + +} + +final class MacUpdatesViewModelDelegate: NSObject, SPUUpdaterDelegate { + + @AppStorage(WFDefaults.subscribeToBetaUpdates, store: UserDefaults.shared) + var subscribeToBetaUpdates: Bool = false + + func allowedChannels(for updater: SPUUpdater) -> Set { + let allowedChannels = Set(subscribeToBetaUpdates ? ["beta"] : []) + return allowedChannels + } + } // This additional view is needed for the disabled state on the menu item to work properly before Monterey. // See https://stackoverflow.com/questions/68553092/menu-not-updating-swiftui-bug for more information struct CheckForUpdatesView: View { @ObservedObject var updaterViewModel: MacUpdatesViewModel var body: some View { Button("Check for Updates…", action: updaterViewModel.checkForUpdates) .disabled(!updaterViewModel.canCheckForUpdates) } }