diff --git a/Kiwix.xcodeproj/project.pbxproj b/Kiwix.xcodeproj/project.pbxproj index 90a6e46f..dc2b05fc 100644 --- a/Kiwix.xcodeproj/project.pbxproj +++ b/Kiwix.xcodeproj/project.pbxproj @@ -310,7 +310,7 @@ 97F425C227151A0D00D0F738 /* QuickLook.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97F425C127151A0D00D0F738 /* QuickLook.framework */; }; 97F425C527151A0D00D0F738 /* PreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97F425C427151A0D00D0F738 /* PreviewViewController.swift */; }; 97F425CA27151A0D00D0F738 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97F425C827151A0D00D0F738 /* MainInterface.storyboard */; }; - 97F425CE27151A0D00D0F738 /* QuickLookPreview.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 97F425C027151A0D00D0F738 /* QuickLookPreview.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 97F425CE27151A0D00D0F738 /* QuickLookPreview.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 97F425C027151A0D00D0F738 /* QuickLookPreview.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 97F425D327151E9C00D0F738 /* ZimFileService.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9779A5A92456793500F6F6FF /* ZimFileService.mm */; }; 97F425D427151E9C00D0F738 /* ZimFileService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9779A5AA2456793500F6F6FF /* ZimFileService.swift */; }; 97F425D527151EF800D0F738 /* ZimFileMetaData.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9779A5AC2456793600F6F6FF /* ZimFileMetaData.mm */; }; @@ -374,15 +374,15 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; - 97C57602202CB0E900E37502 /* Embed App Extensions */ = { + 97C57602202CB0E900E37502 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = ( - 97F425CE27151A0D00D0F738 /* QuickLookPreview.appex in Embed App Extensions */, + 97F425CE27151A0D00D0F738 /* QuickLookPreview.appex in Embed Foundation Extensions */, ); - name = "Embed App Extensions"; + name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ @@ -1206,7 +1206,7 @@ 97A36C261F8C21210079B452 /* Sources */, 97A36C341F8C21210079B452 /* Frameworks */, 97A36C431F8C21210079B452 /* Resources */, - 97C57602202CB0E900E37502 /* Embed App Extensions */, + 97C57602202CB0E900E37502 /* Embed Foundation Extensions */, 977BD604276EA929004D2C32 /* Embed Frameworks */, ); buildRules = ( @@ -1287,7 +1287,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1330; - LastUpgradeCheck = 1310; + LastUpgradeCheck = 1410; ORGANIZATIONNAME = "Chris Li"; TargetAttributes = { 970EC3F023BE8E20008DCA27 = { @@ -1900,7 +1900,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 99; + CURRENT_PROJECT_VERSION = 107; DEVELOPMENT_ASSET_PATHS = "\"SwiftUI/Support\""; DEVELOPMENT_TEAM = L7HWM3SP3L; ENABLE_PREVIEWS = YES; @@ -1918,7 +1918,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0; + MARKETING_VERSION = 3.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = self.Kiwix; @@ -1940,7 +1940,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 99; + CURRENT_PROJECT_VERSION = 107; DEVELOPMENT_ASSET_PATHS = "\"SwiftUI/Support\""; DEVELOPMENT_TEAM = L7HWM3SP3L; ENABLE_PREVIEWS = YES; @@ -1958,7 +1958,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0; + MARKETING_VERSION = 3.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = self.Kiwix; PRODUCT_NAME = Kiwix; @@ -1987,7 +1987,7 @@ "$(LD_RUNPATH_SEARCH_PATHS_$(IS_MACCATALYST))", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.13; + MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)"; MARKETING_VERSION = 2.1.2; OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = org.kiwix.KiwixMacOS; @@ -2019,7 +2019,7 @@ "$(LD_RUNPATH_SEARCH_PATHS_$(IS_MACCATALYST))", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.13; + MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)"; MARKETING_VERSION = 2.1.2; OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = org.kiwix.KiwixMacOS; @@ -2080,8 +2080,8 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MACOSX_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; OTHER_SWIFT_FLAGS = ""; @@ -2135,8 +2135,8 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MACOSX_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; @@ -2287,7 +2287,8 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 99; + CURRENT_PROJECT_VERSION = 107; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"SwiftUI/Support\""; DEVELOPMENT_TEAM = L7HWM3SP3L; ENABLE_HARDENED_RUNTIME = YES; @@ -2301,7 +2302,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 2.0; + MARKETING_VERSION = 3.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = self.Kiwix; @@ -2327,7 +2328,8 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 99; + CURRENT_PROJECT_VERSION = 107; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"SwiftUI/Support\""; DEVELOPMENT_TEAM = L7HWM3SP3L; ENABLE_HARDENED_RUNTIME = YES; @@ -2341,7 +2343,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 2.0; + MARKETING_VERSION = 3.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = self.Kiwix; PRODUCT_NAME = Kiwix; diff --git a/Kiwix.xcodeproj/xcshareddata/xcschemes/Kiwix for iOS (SwiftUI).xcscheme b/Kiwix.xcodeproj/xcshareddata/xcschemes/Kiwix for iOS (SwiftUI).xcscheme index db4c5866..a65420db 100644 --- a/Kiwix.xcodeproj/xcshareddata/xcschemes/Kiwix for iOS (SwiftUI).xcscheme +++ b/Kiwix.xcodeproj/xcshareddata/xcschemes/Kiwix for iOS (SwiftUI).xcscheme @@ -1,6 +1,6 @@ () - private let activeRequestsSemaphore = DispatchSemaphore(value: 1) - private let dataFetchingSemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount) + private var urls = Set() + private var queue = DispatchQueue(label: "org.kiwix.webContent", qos: .userInitiated) func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { - // unpack zimFileID and content path from the url - guard let url = urlSchemeTask.request.url, - url.isKiwixURL else { + queue.async { + // unpack zimFileID and content path from the url + guard let url = urlSchemeTask.request.url, url.isKiwixURL else { urlSchemeTask.didFailWithError(URLError(.unsupportedURL)) return - } - - // remember this active url scheme task - activeRequestsSemaphore.wait() - activeRequests.insert(urlSchemeTask.request) - activeRequestsSemaphore.signal() - - // fetch data and send response - DispatchQueue.global(qos: .userInitiated).async { - // fetch data - self.dataFetchingSemaphore.wait() - let content = ZimFileService.shared.getURLContent(url: url) - self.dataFetchingSemaphore.signal() - - // check the url scheme task is not stopped - self.activeRequestsSemaphore.wait() - guard let _ = self.activeRequests.remove(urlSchemeTask.request) else { - self.activeRequestsSemaphore.signal() - return } - self.activeRequestsSemaphore.signal() - // assemble and send response - if let content = content, - let response = HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: "HTTP/1.1", - headerFields: ["Content-Type": content.mime, "Content-Length": "\(content.length)"]) - { - urlSchemeTask.didReceive(response) - urlSchemeTask.didReceive(content.data) - urlSchemeTask.didFinish() - } else { - os_log("Resource not found for url: %s.", log: Log.URLSchemeHandler, type: .info, url.absoluteString) - urlSchemeTask.didFailWithError(URLError(.resourceUnavailable, userInfo: ["url": url])) + // fetch url content and return data to the task + self.urls.insert(url) + DispatchQueue.global(qos: .userInitiated).async { + let content = ZimFileService.shared.getURLContent(url: url) + self.queue.async { + guard self.urls.contains(url) else { return } + if let content = content, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": content.mime, "Content-Length": "\(content.length)"]) + { + objCTryBlock { + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(content.data) + urlSchemeTask.didFinish() + } + } else { + os_log( + "Resource not found for url: %s.", + log: Log.URLSchemeHandler, + type: .info, + url.absoluteString + ) + urlSchemeTask.didFailWithError(URLError(.resourceUnavailable, userInfo: ["url": url])) + } + self.urls.remove(url) + } } } } func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { - activeRequestsSemaphore.wait() - activeRequests.remove(urlSchemeTask.request) - activeRequestsSemaphore.signal() + queue.async { + guard let url = urlSchemeTask.request.url else { return } + self.urls.remove(url) + } } } diff --git a/SwiftUI/Library/ZimFileDetail.swift b/SwiftUI/Library/ZimFileDetail.swift index df249ee9..787b0f35 100644 --- a/SwiftUI/Library/ZimFileDetail.swift +++ b/SwiftUI/Library/ZimFileDetail.swift @@ -16,6 +16,7 @@ import Defaults struct ZimFileDetail: View { @Binding var url: URL? @Default(.downloadUsingCellular) private var downloadUsingCellular + @Environment(\.presentationMode) var presentationMode @EnvironmentObject private var viewModel: ViewModel @EnvironmentObject private var libraryViewModel: LibraryViewModel @ObservedObject var zimFile: ZimFile @@ -126,6 +127,9 @@ struct ZimFileDetail: View { """), primaryButton: .destructive(Text("Unlink")) { LibraryOperations.unlink(zimFileID: zimFile.fileID) + #if os(iOS) + presentationMode.wrappedValue.dismiss() + #endif }, secondaryButton: .cancel() ) @@ -141,6 +145,9 @@ struct ZimFileDetail: View { message: Text("The zim file and all bookmarked articles linked to this zim file will be deleted."), primaryButton: .destructive(Text("Delete")) { LibraryOperations.delete(zimFileID: zimFile.fileID) + #if os(iOS) + presentationMode.wrappedValue.dismiss() + #endif }, secondaryButton: .cancel() ) diff --git a/SwiftUI/Model/Entities.swift b/SwiftUI/Model/Entities.swift index 6d69d791..e62540d4 100644 --- a/SwiftUI/Model/Entities.swift +++ b/SwiftUI/Model/Entities.swift @@ -80,7 +80,7 @@ class ZimFile: NSManagedObject, Identifiable { @NSManaged var size: Int64 @NSManaged var downloadTask: DownloadTask? - @NSManaged var bookmarks: [Bookmark] + @NSManaged var bookmarks: Set static var openedPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ NSPredicate(format: "fileURLBookmark != nil"), diff --git a/SwiftUI/Model/LibraryOperations.swift b/SwiftUI/Model/LibraryOperations.swift index 7fd8ea96..747a6789 100644 --- a/SwiftUI/Model/LibraryOperations.swift +++ b/SwiftUI/Model/LibraryOperations.swift @@ -94,7 +94,7 @@ struct LibraryOperations { /// Configure a zim file object based on its metadata. static func configureZimFile(_ zimFile: ZimFile, metadata: ZimFileMetaData) { zimFile.articleCount = metadata.articleCount.int64Value - zimFile.category = metadata.category + zimFile.category = (Category(rawValue: metadata.category) ?? .other).rawValue zimFile.created = metadata.creationDate zimFile.fileDescription = metadata.fileDescription zimFile.fileID = metadata.fileID @@ -116,20 +116,22 @@ struct LibraryOperations { //MARK: - Deletion - /// Unlink a zim file from library, and delete the file. + /// Unlink a zim file from library, delete associated bookmarks, and delete the file. /// - Parameter zimFile: the zim file to delete static func delete(zimFileID: UUID) { + guard let url = ZimFileService.shared.getFileURL(zimFileID: zimFileID) else { return } + defer { try? FileManager.default.removeItem(at: url) } LibraryOperations.unlink(zimFileID: zimFileID) } - /// Unlink a zim file from library, but don't delete the file. + /// Unlink a zim file from library, delete associated bookmarks, but don't delete the file. /// - Parameter zimFile: the zim file to unlink static func unlink(zimFileID: UUID) { ZimFileService.shared.close(fileID: zimFileID) - Database.shared.container.performBackgroundTask { context in context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump guard let zimFile = try? ZimFile.fetchRequest(fileID: zimFileID).execute().first else { return } + zimFile.bookmarks.forEach { context.delete($0) } if zimFile.downloadURL == nil { context.delete(zimFile) } else { diff --git a/SwiftUI/Model/SearchOperation/SearchOperation.swift b/SwiftUI/Model/SearchOperation/SearchOperation.swift index adba836a..1d0a45bc 100644 --- a/SwiftUI/Model/SearchOperation/SearchOperation.swift +++ b/SwiftUI/Model/SearchOperation/SearchOperation.swift @@ -59,14 +59,17 @@ extension SearchOperation { } } + // sort the results guard !isCancelled else { return } __results.sort { lhs, rhs in - guard let lhs = (lhs as? SearchResult)?.score?.doubleValue, - let rhs = (rhs as? SearchResult)?.score?.doubleValue else { return .orderedSame } - if lhs < rhs { - return .orderedAscending - } else if lhs > rhs { - return .orderedDescending + guard let lhs = lhs as? SearchResult, + let rhs = rhs as? SearchResult, + let lhsScore = lhs.score?.doubleValue, + let rhsScore = rhs.score?.doubleValue else { return .orderedSame } + if lhsScore != rhsScore { + return lhsScore < rhsScore ? .orderedAscending : .orderedDescending + } else if let lhsSnippet = lhs.snippet, let rhsSnippet = rhs.snippet { + return lhsSnippet.length > rhsSnippet.length ? .orderedAscending : .orderedDescending } else { return .orderedSame } diff --git a/SwiftUI/RootViewV1.swift b/SwiftUI/RootViewV1.swift index cd38fb62..7dafc879 100644 --- a/SwiftUI/RootViewV1.swift +++ b/SwiftUI/RootViewV1.swift @@ -75,6 +75,8 @@ struct RootViewV1: UIViewControllerRepresentable { if isSearchActive { searchBar.text = searchViewModel.searchText } else { + searchBar.text = "" + // Triggers "AttributeGraph: cycle detected through attribute" if not dispatched (iOS 16.0 SDK) DispatchQueue.main.async { searchBar.resignFirstResponder() @@ -102,11 +104,6 @@ struct RootViewV1: UIViewControllerRepresentable { rootView.isSearchActive = true } } - - func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { - searchBar.text = "" - rootView.searchViewModel.searchText = "" - } } } @@ -114,7 +111,7 @@ private struct Content: View { @Binding var isSearchActive: Bool @Binding var url: URL? @Environment(\.horizontalSizeClass) var horizontalSizeClass - @EnvironmentObject var viewModel: ReadingViewModel + @EnvironmentObject var searchViewModel: SearchViewModel var body: some View { Group { @@ -155,6 +152,7 @@ private struct Content: View { SettingsButton() } else if isSearchActive { Button("Cancel") { + searchViewModel.searchText = "" isSearchActive = false } } diff --git a/SwiftUI/Support/iOS-Info.plist b/SwiftUI/Support/iOS-Info.plist index 239c3753..caff00aa 100644 --- a/SwiftUI/Support/iOS-Info.plist +++ b/SwiftUI/Support/iOS-Info.plist @@ -2,6 +2,8 @@ + + BGTaskSchedulerPermittedIdentifiers org.kiwix.library_refresh @@ -32,6 +34,13 @@ ITSAppUsesNonExemptEncryption + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIBackgroundModes fetch diff --git a/SwiftUI/Support/macOS-Info.plist b/SwiftUI/Support/macOS-Info.plist index 35e19e9d..11df3de7 100644 --- a/SwiftUI/Support/macOS-Info.plist +++ b/SwiftUI/Support/macOS-Info.plist @@ -40,6 +40,8 @@ + ITSAppUsesNonExemptEncryption + UTExportedTypeDeclarations diff --git a/SwiftUI/ViewModels/SearchViewModel.swift b/SwiftUI/ViewModels/SearchViewModel.swift index bb1b5ece..be7a5373 100644 --- a/SwiftUI/ViewModels/SearchViewModel.swift +++ b/SwiftUI/ViewModels/SearchViewModel.swift @@ -44,7 +44,7 @@ class SearchViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDel fetchedResultsController.delegate = self // subscribers - searchSubscriber = Publishers.CombineLatest($searchText, $zimFiles) + searchSubscriber = Publishers.CombineLatest($searchText.removeDuplicates(), $zimFiles) .map { [unowned self] searchText, zimFiles in self.inProgress = true return (searchText, zimFiles) diff --git a/SwiftUI/ViewModels/ViewModel.swift b/SwiftUI/ViewModels/ViewModel.swift index 6b2bc66a..76a952c6 100644 --- a/SwiftUI/ViewModels/ViewModel.swift +++ b/SwiftUI/ViewModels/ViewModel.swift @@ -61,6 +61,8 @@ class ViewModel: NSObject, ObservableObject, WKNavigationDelegate, WKUIDelegate } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + let error = error as NSError + guard error.code != NSURLErrorCancelled else { return } activeAlert = .articleFailedToLoad } diff --git a/SwiftUI/Views/BuildingBlocks/Buttons.swift b/SwiftUI/Views/BuildingBlocks/Buttons.swift index 23e5f367..a13e790b 100644 --- a/SwiftUI/Views/BuildingBlocks/Buttons.swift +++ b/SwiftUI/Views/BuildingBlocks/Buttons.swift @@ -132,6 +132,7 @@ struct LibraryButton: View { struct MainArticleButton: View { @Binding var url: URL? + @FetchRequest(sortDescriptors: [], predicate: ZimFile.openedPredicate) private var zimFiles: FetchedResults var body: some View { Button { @@ -139,7 +140,9 @@ struct MainArticleButton: View { url = ZimFileService.shared.getMainPageURL(zimFileID: zimFileID) } label: { Label("Main Article", systemImage: "house") - }.help("Show main article") + } + .disabled(zimFiles.isEmpty) + .help("Show main article") } } @@ -163,7 +166,9 @@ struct MainArticleMenu: View { } primaryAction: { let zimFileID = UUID(uuidString: url?.host ?? "") url = ZimFileService.shared.getMainPageURL(zimFileID: zimFileID) - }.help("Show main article") + } + .disabled(zimFiles.isEmpty) + .help("Show main article") } } @@ -289,6 +294,7 @@ struct PageZoomButtons: View { struct RandomArticleButton: View { @Binding var url: URL? + @FetchRequest(sortDescriptors: [], predicate: ZimFile.openedPredicate) private var zimFiles: FetchedResults var body: some View { Button { @@ -296,7 +302,9 @@ struct RandomArticleButton: View { url = ZimFileService.shared.getRandomPageURL(zimFileID: zimFileID) } label: { Label("Random Article", systemImage: "die.face.5") - }.help("Show random article") + } + .disabled(zimFiles.isEmpty) + .help("Show random article") } } @@ -320,7 +328,9 @@ struct RandomArticleMenu: View { } primaryAction: { let zimFileID = UUID(uuidString: url?.host ?? "") url = ZimFileService.shared.getRandomPageURL(zimFileID: zimFileID) - }.help("Show random article") + } + .disabled(zimFiles.isEmpty) + .help("Show random article") } } @@ -354,17 +364,17 @@ struct SidebarNavigationItemButtons: View { @FocusedBinding(\.navigationItem) var navigationItem: NavigationItem?? var body: some View { - buildButtons([.reading, .bookmarks], keyboardShortcutOffset: 1) + buildButtons([.reading, .bookmarks], modifiers: [.command]) Divider() - buildButtons([.opened, .categories, .downloads, .new], keyboardShortcutOffset: 3) + buildButtons([.opened, .categories, .downloads, .new], modifiers: [.command, .control]) } - private func buildButtons(_ navigationItems: [NavigationItem], keyboardShortcutOffset: Int) -> some View { + private func buildButtons(_ navigationItems: [NavigationItem], modifiers: EventModifiers = []) -> some View { ForEach(Array(navigationItems.enumerated()), id: \.element) { index, item in Button(item.name) { navigationItem = item } - .keyboardShortcut(KeyEquivalent(Character("\(index + keyboardShortcutOffset)"))) + .keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: modifiers) .disabled(navigationItem == nil) } }