From db58b588fe42046f22690046f6c0352c66be2ecb Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Sun, 23 Apr 2023 14:41:16 -0700 Subject: [PATCH] home: parse and display release notes Detect if an update had taken place when the last viewed release notes version differs from the current version. Download the release notes from GitHub API and parse it to filter out items for other platforms. Display the notes in a sheet. --- Platform/Shared/ContentView.swift | 14 ++ Platform/Shared/UTMReleaseHelper.swift | 138 +++++++++++++++++ Platform/Shared/VMCommands.swift | 10 +- Platform/Shared/VMReleaseNotesView.swift | 188 +++++++++++++++++++++++ Platform/UTMExtensions.swift | 2 +- UTM.xcodeproj/project.pbxproj | 16 ++ 6 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 Platform/Shared/UTMReleaseHelper.swift create mode 100644 Platform/Shared/VMReleaseNotesView.swift diff --git a/Platform/Shared/ContentView.swift b/Platform/Shared/ContentView.swift index f14689e9e..ac3c05a87 100644 --- a/Platform/Shared/ContentView.swift +++ b/Platform/Shared/ContentView.swift @@ -29,6 +29,7 @@ let productName = "UTM" struct ContentView: View { @State private var editMode = false @EnvironmentObject private var data: UTMData + @StateObject private var releaseHelper = UTMReleaseHelper() @State private var newPopupPresented = false @State private var openSheetPresented = false @Environment(\.openURL) var openURL @@ -40,6 +41,16 @@ struct ContentView: View { .frame(minWidth: 800, idealWidth: 1200, minHeight: 600, idealHeight: 800) #endif .disabled(data.busy && !data.showNewVMSheet && !data.showSettingsModal) + .sheet(isPresented: $releaseHelper.isReleaseNotesShown, onDismiss: { + releaseHelper.closeReleaseNotes() + }, content: { + VMReleaseNotesView(helper: releaseHelper).padding() + }) + .onReceive(NSNotification.ShowReleaseNotes) { _ in + Task { + await releaseHelper.fetchReleaseNotes(force: true) + } + } .onOpenURL(perform: handleURL) .handlesExternalEvents(preferring: ["*"], allowing: ["*"]) .onReceive(NSNotification.NewVirtualMachine) { _ in @@ -57,6 +68,9 @@ struct ContentView: View { Task { await data.listRefresh() } + Task { + await releaseHelper.fetchReleaseNotes() + } #if os(macOS) NSWindow.allowsAutomaticWindowTabbing = false #else diff --git a/Platform/Shared/UTMReleaseHelper.swift b/Platform/Shared/UTMReleaseHelper.swift new file mode 100644 index 000000000..9223fce54 --- /dev/null +++ b/Platform/Shared/UTMReleaseHelper.swift @@ -0,0 +1,138 @@ +// +// Copyright © 2023 osy. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@MainActor +class UTMReleaseHelper: ObservableObject { + struct Section: Identifiable { + var title: String = "" + var body: [String] = [] + + let id: UUID = UUID() + + var isEmpty: Bool { + title.isEmpty && body.isEmpty + } + } + + private enum ReleaseError: Error { + case fetchFailed + } + + @Setting("ReleaseNotesLastVersion") private var releaseNotesLastVersion: String? = nil + + @Published var isReleaseNotesShown: Bool = false + @Published var releaseNotes: [Section] = [] + + var currentVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + } + + func fetchReleaseNotes(force: Bool = false) async { + guard force || releaseNotesLastVersion != currentVersion else { + return + } + let configuration = URLSessionConfiguration.ephemeral + configuration.allowsCellularAccess = false + configuration.allowsExpensiveNetworkAccess = false + configuration.allowsConstrainedNetworkAccess = false + configuration.waitsForConnectivity = true + configuration.httpAdditionalHeaders = ["Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28"] + let session = URLSession(configuration: configuration) + let url = "https://api.github.com/repos/utmapp/UTM/releases/tags/v\(currentVersion)" + do { + try await Task.detached(priority: .utility) { + let (data, _) = try await session.data(from: URL(string: url)!) + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], let body = json["body"] as? String { + await self.parseReleaseNotes(body) + } else { + throw ReleaseError.fetchFailed + } + }.value + } catch { + logger.error("Failed to download release notes: \(error.localizedDescription)") + if force { + updateReleaseNotes([]) + } else { + // do not try to download again for this release + releaseNotesLastVersion = currentVersion + } + } + } + + nonisolated func parseReleaseNotes(_ notes: String) async { + let lines = notes.split(whereSeparator: \.isNewline) + var sections = [Section]() + var currentSection = Section() + for line in lines { + let string = String(line) + let nsString = string as NSString + if line.hasPrefix("## ") { + if !currentSection.isEmpty { + sections.append(currentSection) + } + let index = line.index(line.startIndex, offsetBy: 3) + currentSection = Section(title: String(line[index...])) + } else if let regex = try? NSRegularExpression(pattern: #"^\* \(([^\)]+)\) "#), + let match = regex.firstMatch(in: string, range: NSRange(location: 0, length: nsString.length)), + match.numberOfRanges > 1 { + let range = match.range(at: 1) + let platform = nsString.substring(with: range) + let description = nsString.substring(from: match.range.location + match.range.length) + #if os(iOS) + #if WITH_QEMU_TCI + if platform == "iOS SE" { + currentSection.body.append(description) + } + #endif + if platform != "iOS SE" && platform.hasPrefix("iOS") { + // should we also parse versions? + currentSection.body.append(description) + } + #elseif os(macOS) + if platform.hasPrefix("macOS") { + currentSection.body.append(description) + } + #else + currentSection.body.append(description) + #endif + } else if line.hasPrefix("* ") { + let index = line.index(line.startIndex, offsetBy: 2) + currentSection.body.append(String(line[index...])) + } else { + currentSection.body.append(String(line)) + } + } + if !currentSection.isEmpty { + sections.append(currentSection) + } + if !sections.isEmpty { + await updateReleaseNotes(sections) + } + } + + private func updateReleaseNotes(_ sections: [Section]) { + releaseNotes = sections + isReleaseNotesShown = true + } + + func closeReleaseNotes() { + releaseNotesLastVersion = currentVersion + isReleaseNotesShown = false + } +} diff --git a/Platform/Shared/VMCommands.swift b/Platform/Shared/VMCommands.swift index 65714d372..54cc75970 100644 --- a/Platform/Shared/VMCommands.swift +++ b/Platform/Shared/VMCommands.swift @@ -32,15 +32,18 @@ struct VMCommands: Commands { SidebarCommands() ToolbarCommands() CommandGroup(replacing: .help) { + Button(action: { NotificationCenter.default.post(name: NSNotification.ShowReleaseNotes, object: nil) }, label: { + Text("What's New") + }).keyboardShortcut(KeyEquivalent("1"), modifiers: [.command, .control]) Button(action: { openLink("https://mac.getutm.app/gallery/") }, label: { Text("Virtual Machine Gallery") - }).keyboardShortcut(KeyEquivalent("1"), modifiers: [.command, .control]) + }).keyboardShortcut(KeyEquivalent("2"), modifiers: [.command, .control]) Button(action: { openLink("https://docs.getutm.app/") }, label: { Text("Support") - }).keyboardShortcut(KeyEquivalent("2"), modifiers: [.command, .control]) + }).keyboardShortcut(KeyEquivalent("3"), modifiers: [.command, .control]) Button(action: { openLink("https://mac.getutm.app/licenses/") }, label: { Text("License") - }).keyboardShortcut(KeyEquivalent("3"), modifiers: [.command, .control]) + }).keyboardShortcut(KeyEquivalent("4"), modifiers: [.command, .control]) } } @@ -52,4 +55,5 @@ struct VMCommands: Commands { extension NSNotification { static let NewVirtualMachine = NSNotification.Name("NewVirtualMachine") static let OpenVirtualMachine = NSNotification.Name("OpenVirtualMachine") + static let ShowReleaseNotes = NSNotification.Name("ShowReleaseNotes") } diff --git a/Platform/Shared/VMReleaseNotesView.swift b/Platform/Shared/VMReleaseNotesView.swift new file mode 100644 index 000000000..fdcf41bcf --- /dev/null +++ b/Platform/Shared/VMReleaseNotesView.swift @@ -0,0 +1,188 @@ +// +// Copyright © 2023 osy. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct VMReleaseNotesView: View { + @ObservedObject var helper: UTMReleaseHelper + @State private var isShowAll: Bool = false + @Environment(\.presentationMode) private var presentationMode: Binding + + let ignoreSections = ["Highlights", "Installation"] + + var body: some View { + VStack { + if helper.releaseNotes.count > 0 { + ScrollView { + Text("What's New") + .font(.largeTitle) + .padding(.bottom) + VStack(alignment: .leading) { + Notes(section: helper.releaseNotes.first!, isProminent: true) + .padding(.bottom, 0.5) + if isShowAll { + ForEach(helper.releaseNotes) { section in + if !ignoreSections.contains(section.title) { + Notes(section: section) + } + } + } + } + } + } else { + VStack { + Spacer() + HStack { + Spacer() + Text("No release notes found for version \(helper.currentVersion).") + .font(.headline) + Spacer() + } + Spacer() + } + } + Spacer() + Buttons { + if !isShowAll { + Button { + isShowAll = true + } label: { + Text("Show All") + #if os(iOS) + .frame(maxWidth: .infinity) + #endif + }.buttonStyle(ReleaseButtonStyle()) + } + Button { + presentationMode.wrappedValue.dismiss() + } label: { + Text("Continue") + #if os(iOS) + .frame(maxWidth: .infinity) + #endif + }.keyboardShortcut(.defaultAction) + .buttonStyle(ReleaseButtonStyle(isProminent: true)) + } + } + #if os(macOS) + .frame(width: 450, height: 450) + #endif + .onAppear { + if helper.releaseNotes.count == 0 { + isShowAll = true + } else if helper.releaseNotes.first!.body.count == 0 { + //isShowAll = true + } + } + } +} + +private struct Notes: View { + let section: UTMReleaseHelper.Section + @State var isProminent: Bool = false + + private var hasBullet: Bool { + !isProminent && section.body.count > 1 + } + + var body: some View { + if !isProminent { + Text(section.title) + .font(.title2) + .padding([.top, .bottom]) + } + ForEach(section.body) { description in + HStack(alignment: .top) { + if hasBullet { + Text("\u{2022} ") + } + if #available(iOS 15, macOS 12, *), let attributed = try? AttributedString(markdown: description) { + Text(attributed) + } else { + Text(description) + } + } + } + } +} + +private struct Buttons: View where Content: View { + var content: () -> Content + + init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + var body: some View { + #if os(macOS) + HStack { + Spacer() + content() + } + #else + VStack { + if #available(iOS 15, *) { + content() + .buttonStyle(.bordered) + } else { + content() + } + } + #endif + } +} + +private struct ReleaseButtonStyle: PrimitiveButtonStyle { + private let isProminent: Bool + private let backgroundColor: Color + private let foregroundColor: Color + + init(isProminent: Bool = false) { + self.isProminent = isProminent + self.backgroundColor = isProminent ? .accentColor : .gray + self.foregroundColor = isProminent ? .white : .white + } + + func makeBody(configuration: Self.Configuration) -> some View { + #if os(macOS) + DefaultButtonStyle().makeBody(configuration: configuration) + #else + if #available(iOS 15, *) { + if isProminent { + BorderedProminentButtonStyle().makeBody(configuration: configuration) + } else { + BorderedButtonStyle().makeBody(configuration: configuration) + } + } else { + DefaultButtonStyle().makeBody(configuration: configuration) + .padding() + .foregroundColor(foregroundColor) + .background(backgroundColor) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(foregroundColor, lineWidth: 1) + ) + } + #endif + } +} + +struct VMReleaseNotesView_Previews: PreviewProvider { + static var previews: some View { + VMReleaseNotesView(helper: UTMReleaseHelper()) + } +} diff --git a/Platform/UTMExtensions.swift b/Platform/UTMExtensions.swift index 1500c8bd8..3146266d3 100644 --- a/Platform/UTMExtensions.swift +++ b/Platform/UTMExtensions.swift @@ -294,6 +294,7 @@ extension NSImage { } } } +#endif @propertyWrapper struct Setting { @@ -320,4 +321,3 @@ struct Setting { self.keyName = keyName } } -#endif diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index 5a3c1efe5..879abafd4 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -897,6 +897,12 @@ CE5451A826AF5F10008594E5 /* epoxy.0.framework in Embed Libraries */ = {isa = PBXBuildFile; fileRef = CE5451A226AF5F0F008594E5 /* epoxy.0.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; CE5451AA26AF5F10008594E5 /* GLESv2.framework in Embed Libraries */ = {isa = PBXBuildFile; fileRef = CE5451A326AF5F0F008594E5 /* GLESv2.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; CE5451AC26AF5F10008594E5 /* EGL.framework in Embed Libraries */ = {isa = PBXBuildFile; fileRef = CE5451A426AF5F0F008594E5 /* EGL.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + CE611BE729F50CAD001817BC /* UTMReleaseHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE611BE629F50CAD001817BC /* UTMReleaseHelper.swift */; }; + CE611BE829F50CAD001817BC /* UTMReleaseHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE611BE629F50CAD001817BC /* UTMReleaseHelper.swift */; }; + CE611BE929F50CAD001817BC /* UTMReleaseHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE611BE629F50CAD001817BC /* UTMReleaseHelper.swift */; }; + CE611BEB29F50D3E001817BC /* VMReleaseNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE611BEA29F50D3E001817BC /* VMReleaseNotesView.swift */; }; + CE611BEC29F50D3E001817BC /* VMReleaseNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE611BEA29F50D3E001817BC /* VMReleaseNotesView.swift */; }; + CE611BED29F50D3E001817BC /* VMReleaseNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE611BEA29F50D3E001817BC /* VMReleaseNotesView.swift */; }; CE612AC624D3B50700FA6300 /* VMDisplayWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE612AC524D3B50700FA6300 /* VMDisplayWindowController.swift */; }; CE65BABF26A4D8DD0001BD6B /* VMConfigDisplayConsoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8401FDA5269D44E400265F0D /* VMConfigDisplayConsoleView.swift */; }; CE65BAC026A4D8DE0001BD6B /* VMConfigDisplayConsoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8401FDA5269D44E400265F0D /* VMConfigDisplayConsoleView.swift */; }; @@ -2241,6 +2247,8 @@ CE5451A426AF5F0F008594E5 /* EGL.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = EGL.framework; path = "$(SYSROOT_DIR)/Frameworks/EGL.framework"; sourceTree = ""; }; CE550BD52259479D0063E575 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; CE5F165B2261395000F3D56B /* UTMVirtualMachine.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UTMVirtualMachine.m; sourceTree = ""; }; + CE611BE629F50CAD001817BC /* UTMReleaseHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMReleaseHelper.swift; sourceTree = ""; }; + CE611BEA29F50D3E001817BC /* VMReleaseNotesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMReleaseNotesView.swift; sourceTree = ""; }; CE612AC524D3B50700FA6300 /* VMDisplayWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayWindowController.swift; sourceTree = ""; }; CE66450C2269313200B0849A /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; }; CE6B240A25F1F3CE0020D43E /* main.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = main.c; sourceTree = ""; }; @@ -3406,6 +3414,7 @@ CEF0305926A2AFDE00667B63 /* VMWizardStartView.swift */, CEF0305526A2AFDD00667B63 /* VMWizardState.swift */, CEBE820A26A4C8E0007AAB12 /* VMWizardSummaryView.swift */, + CE611BEA29F50D3E001817BC /* VMReleaseNotesView.swift */, 83A004B826A8CC95001AC09E /* UTMDownloadTask.swift */, 84B36D2427B704C200C22685 /* UTMDownloadVMTask.swift */, 844EC0FA2773EE49003C104A /* UTMDownloadIPSWTask.swift */, @@ -3413,6 +3422,7 @@ 83034C0626AB630F006B4BAF /* UTMPendingVMView.swift */, 84909A8C27CACD5C005605F1 /* UTMPlaceholderVMView.swift */, 84909A9027CADAE0005605F1 /* UTMUnavailableVMView.swift */, + CE611BE629F50CAD001817BC /* UTMReleaseHelper.swift */, ); path = Shared; sourceTree = ""; @@ -3891,6 +3901,7 @@ CE25123929BD46B4000790AB /* UTMQemuMonitor.m in Sources */, CE2D929724AD46670059923A /* qapi-commands-error.c in Sources */, CE2D929924AD46670059923A /* UTMJSONStream.m in Sources */, + CE611BE729F50CAD001817BC /* UTMReleaseHelper.swift in Sources */, CE2D958324AD4F990059923A /* VMConfigNetworkView.swift in Sources */, CE2D929A24AD46670059923A /* qapi-commands-sockets.c in Sources */, CE2D929C24AD46670059923A /* UTMLegacyViewState.m in Sources */, @@ -3935,6 +3946,7 @@ 842B9F8D28CC58B700031EE7 /* UTMPatches.swift in Sources */, CE2D92B524AD46670059923A /* qapi-commands-tpm.c in Sources */, CE19392626DCB094005CEC17 /* RAMSlider.swift in Sources */, + CE611BEB29F50D3E001817BC /* VMReleaseNotesView.swift in Sources */, CE2D92B724AD46670059923A /* qapi-visit-common.c in Sources */, CE2D92B824AD46670059923A /* qapi-events-trace.c in Sources */, 84909A8927CABA54005605F1 /* UTMWrappedVirtualMachine.swift in Sources */, @@ -4168,6 +4180,7 @@ CE0B6D2324AD57FC00FE012D /* qapi-commands-crypto.c in Sources */, 841619AC284315F9000034B2 /* UTMConfigurationInfo.swift in Sources */, CEF0305326A2AFBF00667B63 /* Spinner.swift in Sources */, + CE611BED29F50D3E001817BC /* VMReleaseNotesView.swift in Sources */, 84F90A01289488F90008DBE2 /* MenuLabel.swift in Sources */, CE0B6CFA24AD568400FE012D /* UTMLegacyQemuConfiguration+System.m in Sources */, CEBBF1A824B921F000C15049 /* VMDetailsView.swift in Sources */, @@ -4264,6 +4277,7 @@ CE0B6D7924AD584D00FE012D /* qapi-visit-rdma.c in Sources */, CE8813D624CD265700532628 /* VMShareFileModifier.swift in Sources */, CEC794BC2949663C00121A9F /* UTMScripting.swift in Sources */, + CE611BE929F50CAD001817BC /* UTMReleaseHelper.swift in Sources */, 84909A9327CADAE0005605F1 /* UTMUnavailableVMView.swift in Sources */, CE0B6D4B24AD584C00FE012D /* qapi-visit-rocker.c in Sources */, 8443EFFC28456F3B00B2E6E2 /* UTMQemuConfigurationSharing.swift in Sources */, @@ -4659,6 +4673,8 @@ CEA45EE3263519B5002FA97D /* qapi-types-crypto.c in Sources */, CEA45EE4263519B5002FA97D /* qapi-types-qom.c in Sources */, CEA45EE7263519B5002FA97D /* qapi-commands-rdma.c in Sources */, + CE611BEC29F50D3E001817BC /* VMReleaseNotesView.swift in Sources */, + CE611BE829F50CAD001817BC /* UTMReleaseHelper.swift in Sources */, CEA45EE8263519B5002FA97D /* VMDisplayMetalViewController+Keyboard.m in Sources */, CEA45EE9263519B5002FA97D /* qapi-builtin-types.c in Sources */, CEA45EEA263519B5002FA97D /* UTMExtensions.swift in Sources */,