mirror of
https://github.com/kiwix/kiwix-apple.git
synced 2025-08-03 20:47:22 -04:00
385 lines
16 KiB
Swift
385 lines
16 KiB
Swift
// This file is part of Kiwix for iOS & macOS.
|
|
//
|
|
// Kiwix is free software; you can redistribute it and/or modify it
|
|
// under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation; either version 3 of the License, or
|
|
// any later version.
|
|
//
|
|
// Kiwix is distributed in the hope that it will be useful, but
|
|
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
// General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
|
|
|
|
import SwiftUI
|
|
import UserNotifications
|
|
import Combine
|
|
import Defaults
|
|
import CoreKiwix
|
|
import PassKit
|
|
|
|
#if os(macOS)
|
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
@main
|
|
struct Kiwix: App {
|
|
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
|
@Environment(\.openWindow) var openWindow
|
|
@StateObject private var libraryRefreshViewModel = LibraryViewModel()
|
|
private let notificationCenterDelegate = NotificationCenterDelegate()
|
|
private var amountSelected = PassthroughSubject<SelectedAmount?, Never>()
|
|
@State private var selectedAmount: SelectedAmount?
|
|
@StateObject var formReset = FormReset()
|
|
@FocusState private var isSearchFocused: Bool
|
|
|
|
init() {
|
|
UNUserNotificationCenter.current().delegate = notificationCenterDelegate
|
|
if FeatureFlags.hasLibrary {
|
|
LibraryViewModel().start(isUserInitiated: false)
|
|
}
|
|
}
|
|
|
|
var body: some Scene {
|
|
WindowGroup {
|
|
RootView(isSearchFocused: $isSearchFocused)
|
|
.environment(\.managedObjectContext, Database.shared.viewContext)
|
|
.environmentObject(libraryRefreshViewModel)
|
|
|
|
}.commands {
|
|
SidebarCommands()
|
|
CommandGroup(replacing: .importExport) {
|
|
OpenFileButton(context: .command) { Text(LocalString.app_macos_commands_open_file) }
|
|
}
|
|
CommandGroup(replacing: .newItem) {
|
|
Button(LocalString.app_macos_commands_new) {
|
|
guard let currentWindow = NSApp.keyWindow,
|
|
let controller = currentWindow.windowController else { return }
|
|
controller.newWindowForTab(nil)
|
|
guard let newWindow = NSApp.keyWindow, currentWindow != newWindow else { return }
|
|
currentWindow.addTabbedWindow(newWindow, ordered: .above)
|
|
}.keyboardShortcut("t")
|
|
Divider()
|
|
}
|
|
CommandGroup(after: .toolbar) {
|
|
NavigationCommands(goBack: {
|
|
NotificationCenter.default.post(name: .goBack, object: nil)
|
|
}, goForward: {
|
|
NotificationCenter.default.post(name: .goForward, object: nil)
|
|
})
|
|
Divider()
|
|
PageZoomCommands()
|
|
Divider()
|
|
SidebarNavigationCommands()
|
|
Divider()
|
|
}
|
|
CommandGroup(after: .textEditing) {
|
|
Button(LocalString.common_search) {
|
|
isSearchFocused = true
|
|
}
|
|
.keyboardShortcut("f", modifiers: [.command])
|
|
}
|
|
CommandGroup(replacing: .help) {}
|
|
}
|
|
Settings {
|
|
TabView {
|
|
ReadingSettings()
|
|
if FeatureFlags.hasLibrary {
|
|
LibrarySettings()
|
|
.environmentObject(libraryRefreshViewModel)
|
|
}
|
|
About()
|
|
}
|
|
.frame(width: 550, height: 400)
|
|
}
|
|
Window(LocalString.payment_donate_title, id: "donation") {
|
|
Group {
|
|
if let selectedAmount {
|
|
PaymentSummary(selectedAmount: selectedAmount, onComplete: {
|
|
closeDonation()
|
|
switch Payment.showResult() {
|
|
case .none: break
|
|
case .thankYou:
|
|
openWindow(id: "donation-thank-you")
|
|
case .error:
|
|
openWindow(id: "donation-error")
|
|
}
|
|
})
|
|
} else {
|
|
PaymentForm(amountSelected: amountSelected)
|
|
.frame(width: 320, height: 320)
|
|
}
|
|
}
|
|
.onReceive(amountSelected) { amount in
|
|
selectedAmount = amount
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: NSWindow.willCloseNotification)) { notification in
|
|
if let window = notification.object as? NSWindow,
|
|
window.identifier?.rawValue == "donation" {
|
|
formReset.reset()
|
|
selectedAmount = nil
|
|
}
|
|
}
|
|
.environmentObject(formReset)
|
|
}
|
|
.windowResizability(.contentMinSize)
|
|
.windowStyle(.titleBar)
|
|
.commandsRemoved()
|
|
.defaultSize(width: 320, height: 400)
|
|
|
|
Window("", id: "donation-thank-you") {
|
|
PaymentResultPopUp(state: .thankYou)
|
|
.padding()
|
|
}
|
|
.windowResizability(.contentMinSize)
|
|
.commandsRemoved()
|
|
.defaultSize(width: 320, height: 198)
|
|
|
|
Window("", id: "donation-error") {
|
|
PaymentResultPopUp(state: .error)
|
|
.padding()
|
|
}
|
|
.windowResizability(.contentMinSize)
|
|
.commandsRemoved()
|
|
.defaultSize(width: 320, height: 198)
|
|
}
|
|
|
|
private func closeDonation() {
|
|
// after upgrading to macOS 14, use:
|
|
// @Environment(\.dismissWindow) var dismissWindow
|
|
// and call:
|
|
// dismissWindow(id: "donation")
|
|
NSApplication.shared.windows.first { window in
|
|
window.identifier?.rawValue == "donation"
|
|
}?.close()
|
|
}
|
|
|
|
private class NotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|
/// Handling file download complete notification
|
|
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
|
didReceive response: UNNotificationResponse,
|
|
withCompletionHandler completionHandler: @escaping () -> Void) {
|
|
Task {
|
|
if let zimFileID = UUID(uuidString: response.notification.request.identifier),
|
|
let mainPageURL = await ZimFileService.shared.getMainPageURL(zimFileID: zimFileID) {
|
|
NSWorkspace.shared.open(mainPageURL)
|
|
}
|
|
await MainActor.run { completionHandler() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct RootView: View {
|
|
@Environment(\.openWindow) var openWindow
|
|
@Environment(\.controlActiveState) var controlActiveState
|
|
@StateObject private var navigation = NavigationViewModel()
|
|
@State private var currentNavItem: MenuItem?
|
|
@StateObject private var windowTracker = WindowTracker()
|
|
@State private var paymentButtonLabel: PayWithApplePayButtonLabel?
|
|
<<<<<<< HEAD
|
|
@StateObject private var multiSelection = LibraryMultiSelectViewModel()
|
|
var isSearchFocused: FocusState<Bool>.Binding
|
|
=======
|
|
@StateObject private var selection = SelectedZimFileViewModel(isMultiSelection: false)
|
|
>>>>>>> f3ebab51 (Split and rename viewModels)
|
|
|
|
private let primaryItems: [MenuItem] = [.bookmarks]
|
|
private let libraryItems: [MenuItem] = [.opened, .categories, .downloads, .new]
|
|
private let openURL = NotificationCenter.default.publisher(for: .openURL)
|
|
private let appTerminates = NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
|
|
private let tabCloses = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification)
|
|
private let goBackPublisher = NotificationCenter.default.publisher(for: .goBack)
|
|
private let goForwardPublisher = NotificationCenter.default.publisher(for: .goForward)
|
|
/// Close other tabs then the ones received
|
|
private let keepOnlyTabs = NotificationCenter.default.publisher(for: .keepOnlyTabs)
|
|
|
|
var body: some View {
|
|
NavigationSplitView {
|
|
List(selection: $currentNavItem) {
|
|
ForEach(
|
|
[MenuItem.tab(objectID: navigation.currentTabId)] + primaryItems,
|
|
id: \.self
|
|
) { menuItem in
|
|
Label(menuItem.name, systemImage: menuItem.icon)
|
|
}
|
|
if FeatureFlags.hasLibrary {
|
|
Section(LocalString.app_macos_navigation_button_library) {
|
|
ForEach(libraryItems, id: \.self) { menuItem in
|
|
Label(menuItem.name, systemImage: menuItem.icon)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(minWidth: 160)
|
|
.safeAreaInset(edge: .bottom) {
|
|
if paymentButtonLabel != nil && Brand.hideDonation != true {
|
|
SupportKiwixButton {
|
|
openWindow(id: "donation")
|
|
}
|
|
}
|
|
}
|
|
} detail: {
|
|
switch navigation.currentItem {
|
|
case .loading:
|
|
LoadingDataView()
|
|
case .tab(let tabID):
|
|
BrowserTab(tabID: tabID)
|
|
.modifier(SearchFocused(isSearchFocused: isSearchFocused))
|
|
case .bookmarks:
|
|
Bookmarks()
|
|
.modifier(SearchFocused(isSearchFocused: isSearchFocused))
|
|
case .opened:
|
|
let multiSelection = SelectedZimFileViewModel(isMultiSelection: true)
|
|
ZimFilesOpened(selection: multiSelection, dismiss: nil)
|
|
.modifier(LibraryZimFileMultiSelectDetailSidePanel(selection: multiSelection))
|
|
case .categories:
|
|
ZimFilesCategories(dismiss: nil)
|
|
.modifier(LibraryZimFileDetailSidePanel())
|
|
.modifier(SearchFocused(isSearchFocused: isSearchFocused))
|
|
case .downloads:
|
|
ZimFilesDownloads(dismiss: nil).modifier(LibraryZimFileDetailSidePanel())
|
|
case .new:
|
|
ZimFilesNew(dismiss: nil)
|
|
.modifier(LibraryZimFileDetailSidePanel())
|
|
.modifier(SearchFocused(isSearchFocused: isSearchFocused))
|
|
default:
|
|
EmptyView()
|
|
}
|
|
}
|
|
.frame(minWidth: 650, minHeight: 500)
|
|
.focusedSceneValue(\.navigationItem, $navigation.currentItem)
|
|
.modifier(AlertHandler())
|
|
.modifier(OpenFileHandler())
|
|
.modifier(SaveContentHandler())
|
|
.environmentObject(navigation)
|
|
.onChange(of: currentNavItem) { newValue in
|
|
navigation.currentItem = newValue?.navigationItem
|
|
}
|
|
.onChange(of: navigation.currentItem) { newValue in
|
|
guard let newValue else { return }
|
|
let navItem = MenuItem(from: newValue)
|
|
if currentNavItem != navItem {
|
|
currentNavItem = navItem
|
|
}
|
|
}
|
|
.onOpenURL { url in
|
|
if url.isFileURL {
|
|
// from opening an external file
|
|
let browser = BrowserViewModel.getCached(tabID: navigation.currentTabId)
|
|
browser.forceLoadingState()
|
|
NotificationCenter.openFiles([url], context: .file)
|
|
} else if url.isZIMURL {
|
|
// from deeplinks
|
|
let browser = BrowserViewModel.getCached(tabID: navigation.currentTabId)
|
|
browser.forceLoadingState()
|
|
NotificationCenter.openURL(url, context: .deepLink)
|
|
}
|
|
}
|
|
.onReceive(openURL) { notification in
|
|
guard let url = notification.userInfo?["url"] as? URL else {
|
|
return
|
|
}
|
|
switch notification.userInfo?["context"] as? OpenURLContext {
|
|
case .file, .deepLink:
|
|
// handle the opened ZIM file from Finder / DeepLink
|
|
// for which the system opens a new window,
|
|
// this part of the code, will be called on all possible windows, we need this though,
|
|
// otherwise it won't fire on app start, where we might not have a fully configured window yet.
|
|
// We need to filter it down the the last window
|
|
// (which is usually not the key window yet at this point),
|
|
// and load the content only within that
|
|
Task { @MainActor in
|
|
if windowTracker.isLastWindow() {
|
|
BrowserViewModel.getCached(tabID: navigation.currentTabId).load(url: url)
|
|
}
|
|
}
|
|
return
|
|
|
|
case .none:
|
|
break
|
|
}
|
|
guard controlActiveState == .key else { return }
|
|
let tabID = navigation.currentTabId
|
|
currentNavItem = .tab(objectID: tabID)
|
|
BrowserViewModel.getCached(tabID: tabID).load(url: url)
|
|
}
|
|
.onReceive(tabCloses) { publisher in
|
|
// closing one window either by CMD+W || red(X) close button
|
|
guard windowTracker.current == publisher.object as? NSWindow else {
|
|
// when exiting full screen video, we get the same notification
|
|
// but that's not comming from our window
|
|
return
|
|
}
|
|
windowTracker.current = nil // remove the reference to this window, see guard above
|
|
|
|
guard !navigation.isTerminating else {
|
|
// tab closed by app termination
|
|
return
|
|
}
|
|
let tabID = navigation.currentTabId
|
|
let browser = BrowserViewModel.getCached(tabID: tabID)
|
|
// tab closed by user
|
|
browser.pauseVideoWhenNotInPIP()
|
|
navigation.deleteTab(tabID: tabID)
|
|
}
|
|
.onReceive(keepOnlyTabs) {notification in
|
|
guard let tabsToKeep = notification.userInfo?["tabIds"] as? Set<NSManagedObjectID> else {
|
|
return
|
|
}
|
|
navigation.keepOnlyTabsBy(tabIds: tabsToKeep)
|
|
}
|
|
.onReceive(appTerminates) { _ in
|
|
// CMD+Q -> Quit Kiwix, this also closes the last window
|
|
navigation.isTerminating = true
|
|
}.onReceive(goForwardPublisher) { _ in
|
|
guard case .tab(let tabID) = navigation.currentItem else {
|
|
return
|
|
}
|
|
BrowserViewModel.getCached(tabID: tabID).webView.goForward()
|
|
}.onReceive(goBackPublisher) { [weak navigation] _ in
|
|
guard case .tab(let tabID) = navigation?.currentItem else {
|
|
return
|
|
}
|
|
BrowserViewModel.getCached(tabID: tabID).webView.goBack()
|
|
}.task {
|
|
switch AppType.current {
|
|
case .kiwix:
|
|
await LibraryOperations.reopen()
|
|
currentNavItem = .tab(objectID: navigation.currentTabId)
|
|
LibraryOperations.scanDirectory(URL.documentDirectory)
|
|
LibraryOperations.applyFileBackupSetting()
|
|
DownloadService.shared.restartHeartbeatIfNeeded()
|
|
case let .custom(zimFileURL):
|
|
await LibraryOperations.open(url: zimFileURL)
|
|
ZimMigration.forCustomApps()
|
|
currentNavItem = .tab(objectID: navigation.currentTabId)
|
|
}
|
|
// MARK: - payment button init
|
|
if Brand.hideDonation == false {
|
|
paymentButtonLabel = await Payment.paymentButtonTypeAsync()
|
|
}
|
|
|
|
// MARK: - migrations
|
|
if !ProcessInfo.processInfo.arguments.contains("testing") {
|
|
_ = MigrationService().migrateAll()
|
|
}
|
|
}
|
|
// special hook to trigger the zim file search in the nav bar, when a web view is opened
|
|
// and the cmd+f is triggering the search in page
|
|
.onReceive(NotificationCenter.default.publisher(for: .zimSearch)) { _ in
|
|
isSearchFocused.wrappedValue = true
|
|
}
|
|
.withHostingWindow { [weak windowTracker] hostWindow in
|
|
windowTracker?.current = hostWindow
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|