Updater UI (#703)

* Parse markdown oop

* Update UI.

* Tweaks.
This commit is contained in:
Max Goedjen 2025-09-14 01:20:10 -07:00 committed by GitHub
parent b308b10716
commit f76766a9d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 135 additions and 81 deletions

View File

@ -1,7 +1,8 @@
import Foundation import Foundation
import SwiftUI
/// A release is a representation of a downloadable update. /// A release is a representation of a downloadable update.
public struct Release: Codable, Sendable { public struct Release: Codable, Sendable, Hashable {
/// The user-facing name of the release. Typically "Secretive 1.2.3" /// The user-facing name of the release. Typically "Secretive 1.2.3"
public let name: String public let name: String
@ -15,6 +16,8 @@ public struct Release: Codable, Sendable {
/// A user-facing description of the contents of the update. /// A user-facing description of the contents of the update.
public let body: String public let body: String
public let attributedBody: AttributedString
/// Initializes a Release. /// Initializes a Release.
/// - Parameters: /// - Parameters:
/// - name: The user-facing name of the release. /// - name: The user-facing name of the release.
@ -26,6 +29,56 @@ public struct Release: Codable, Sendable {
self.prerelease = prerelease self.prerelease = prerelease
self.html_url = html_url self.html_url = html_url
self.body = body self.body = body
self.attributedBody = AttributedString(_markdown: body)
}
public init(_ release: GitHubRelease) {
self.name = release.name
self.prerelease = release.prerelease
self.html_url = release.html_url
self.body = release.body
self.attributedBody = AttributedString(_markdown: release.body)
}
}
public struct GitHubRelease: Codable, Sendable {
let name: String
let prerelease: Bool
let html_url: URL
let body: String
}
fileprivate extension AttributedString {
init(_markdown markdown: String) {
let split = markdown.split(whereSeparator: \.isNewline)
let lines = split
.compactMap {
try? AttributedString(markdown: String($0), options: .init(allowsExtendedAttributes: true, interpretedSyntax: .full))
}
.map { (string: AttributedString) in
guard case let .header(level) = string.runs.first?.presentationIntent?.components.first?.kind else { return string }
return AttributedString("\n") + string
.transformingAttributes(\.font) { font in
font.value = switch level {
case 2: .headline.bold()
case 3: .headline
default: .subheadline
}
}
.transformingAttributes(\.underlineStyle) { underline in
underline.value = switch level {
case 2: .single
default: .none
}
}
+ AttributedString("\n")
}
self = lines.reduce(into: AttributedString()) { partialResult, next in
partialResult.append(next)
partialResult.append(AttributedString("\n"))
}
} }
} }

View File

@ -34,7 +34,9 @@ public final class XPCServiceDelegate: NSObject, NSXPCListenerDelegate {
if let error = error as? Codable & Error { if let error = error as? Codable & Error {
reply(nil, NSError(error)) reply(nil, NSError(error))
} else { } else {
reply(nil, error) // Sending cast directly tries to serialize it and crashes XPCEncoder.
let cast = error as NSError
reply(nil, NSError(domain: cast.domain, code: cast.code, userInfo: [NSLocalizedDescriptionKey: error.localizedDescription]))
} }
} }
} }

View File

@ -24,7 +24,7 @@ extension View {
} }
struct MenuButtonModifier: ViewModifier { struct ToolbarCircleButtonModifier: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
if #available(macOS 26.0, *) { if #available(macOS 26.0, *) {
@ -40,8 +40,8 @@ struct MenuButtonModifier: ViewModifier {
extension View { extension View {
func menuButton() -> some View { func toolbarCircleButton() -> some View {
modifier(MenuButtonModifier()) modifier(ToolbarCircleButtonModifier())
} }
} }

View File

@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
struct ToolbarButtonStyle: ButtonStyle { struct ToolbarStatusButtonStyle: ButtonStyle {
private let lightColor: Color private let lightColor: Color
private let darkColor: Color private let darkColor: Color
@ -56,3 +56,24 @@ struct ToolbarButtonStyle: ButtonStyle {
} }
} }
} }
struct ToolbarButtonStyle: PrimitiveButtonStyle {
var tint: Color = .white.opacity(0.1)
func makeBody(configuration: Configuration) -> some View {
if #available(macOS 26.0, *) {
configuration
.label
.padding(.vertical, 10)
.padding(.horizontal, 12)
.glassEffect(.regular.interactive().tint(tint))
} else {
BorderedButtonStyle().makeBody(configuration: configuration)
.padding(EdgeInsets(top: 6, leading: 8, bottom: 6, trailing: 8))
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 5))
}
}
}

View File

@ -6,19 +6,21 @@ import Brief
struct ContentView: View { struct ContentView: View {
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
@State var showingCreation = false
@State var runningSetup = false
@State var showingAgentInfo = false
@State var activeSecret: AnySecret? @State var activeSecret: AnySecret?
@Environment(\.colorScheme) var colorScheme
@Environment(\.secretStoreList) private var storeList
@Environment(\.updater) private var updater: any UpdaterProtocol
@Environment(\.agentStatusChecker) private var agentStatusChecker: any AgentStatusCheckerProtocol
@State private var selectedUpdate: Release? @State private var selectedUpdate: Release?
@Environment(\.colorScheme) private var colorScheme
@Environment(\.openWindow) private var openWindow
@Environment(\.secretStoreList) private var storeList
@Environment(\.updater) private var updater
@Environment(\.agentStatusChecker) private var agentStatusChecker
@AppStorage("defaultsHasRunSetup") private var hasRunSetup = false
@State private var showingCreation = false
@State private var showingAppPathNotice = false @State private var showingAppPathNotice = false
@State private var runningSetup = false
@State private var showingAgentInfo = false
var body: some View { var body: some View {
VStack { VStack {
@ -102,27 +104,12 @@ extension ContentView {
.font(.headline) .font(.headline)
.foregroundColor(.white) .foregroundColor(.white)
}) })
.buttonStyle(ToolbarButtonStyle(color: color)) .buttonStyle(ToolbarStatusButtonStyle(color: color))
.sheet(item: $selectedUpdate) { update in .sheet(item: $selectedUpdate) { update in
VStack {
if updater.currentVersion.isTestBuild {
VStack {
if let description = updater.currentVersion.previewDescription {
Text(description)
}
Link(destination: URL(string: "https://github.com/maxgoedjen/secretive/actions/workflows/nightly.yml")!) {
Button(.updaterDownloadLatestNightlyButton) {}
.frame(maxWidth: .infinity)
.primaryButton()
}
}
.padding()
}
UpdateDetailView(update: update) UpdateDetailView(update: update)
} }
} }
} }
}
@ViewBuilder @ViewBuilder
var newItemView: some View { var newItemView: some View {
@ -130,7 +117,7 @@ extension ContentView {
Button(.appMenuNewSecretButton, systemImage: "plus") { Button(.appMenuNewSecretButton, systemImage: "plus") {
showingCreation = true showingCreation = true
} }
.menuButton() .toolbarCircleButton()
} }
} }
@ -157,7 +144,7 @@ extension ContentView {
} }
}) })
.buttonStyle( .buttonStyle(
ToolbarButtonStyle( ToolbarStatusButtonStyle(
lightColor: agentStatusChecker.running ? .black.opacity(0.05) : .red.opacity(0.75), lightColor: agentStatusChecker.running ? .black.opacity(0.05) : .red.opacity(0.75),
darkColor: agentStatusChecker.running ? .white.opacity(0.05) : .red.opacity(0.5), darkColor: agentStatusChecker.running ? .white.opacity(0.05) : .red.opacity(0.5),
) )
@ -179,7 +166,7 @@ extension ContentView {
.font(.headline) .font(.headline)
.foregroundColor(.white) .foregroundColor(.white)
}) })
.buttonStyle(ToolbarButtonStyle(color: .orange)) .buttonStyle(ToolbarStatusButtonStyle(color: .orange))
.popover(isPresented: $showingAppPathNotice, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { .popover(isPresented: $showingAppPathNotice, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) {
VStack { VStack {
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")

View File

@ -3,18 +3,13 @@ import Brief
struct UpdateDetailView: View { struct UpdateDetailView: View {
@Environment(\.updater) var updater: any UpdaterProtocol @Environment(\.updater) var updater
@Environment(\.openURL) var openURL
let update: Release let update: Release
var body: some View { var body: some View {
VStack { VStack(spacing: 0) {
Text(.updateVersionName(updateName: update.name)).font(.title)
GroupBox(label: Text(.updateReleaseNotesTitle)) {
ScrollView {
attributedBody
}
}
HStack { HStack {
if !update.critical { if !update.critical {
Button(.updateIgnoreButton) { Button(.updateIgnoreButton) {
@ -22,42 +17,36 @@ struct UpdateDetailView: View {
await updater.ignore(release: update) await updater.ignore(release: update)
} }
} }
.buttonStyle(ToolbarButtonStyle())
}
Spacer() Spacer()
if updater.currentVersion.isTestBuild {
Button(.updaterDownloadLatestNightlyButton) {
openURL(URL(string: "https://github.com/maxgoedjen/secretive/actions/workflows/nightly.yml")!)
}
.buttonStyle(ToolbarButtonStyle(tint: .accentColor))
} }
Button(.updateUpdateButton) { Button(.updateUpdateButton) {
NSWorkspace.shared.open(update.html_url) openURL(update.html_url)
} }
.buttonStyle(ToolbarButtonStyle(tint: .accentColor))
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
} }
}
.padding() .padding()
.frame(maxWidth: 500) Divider()
Form {
Section {
Text(update.attributedBody)
} header: {
Text(.updateVersionName(updateName: update.name)) .headerProminence(.increased)
} }
var attributedBody: Text {
var text = Text(verbatim: "")
for line in update.body.split(whereSeparator: \.isNewline) {
let attributed: Text
let split = line.split(separator: " ")
let unprefixed = split.dropFirst().joined(separator: " ")
if let prefix = split.first {
switch prefix {
case "#":
attributed = Text(unprefixed).font(.title) + Text(verbatim: "\n")
case "##":
attributed = Text(unprefixed).font(.title2) + Text(verbatim: "\n")
case "###":
attributed = Text(unprefixed).font(.title3) + Text(verbatim: "\n")
default:
attributed = Text(line) + Text(verbatim: "\n\n")
} }
} else { .formStyle(.grouped)
attributed = Text(line) + Text(verbatim: "\n\n")
} }
text = text + attributed
}
return text
} }
} }
#Preview {
UpdateDetailView(update: .init(name: "3.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Hello"))
}

View File

@ -11,7 +11,9 @@ final class SecretiveUpdater: NSObject, XPCProtocol {
func process(_: Data) async throws -> [Release] { func process(_: Data) async throws -> [Release] {
let (data, _) = try await URLSession.shared.data(from: Constants.updateURL) let (data, _) = try await URLSession.shared.data(from: Constants.updateURL)
return try JSONDecoder().decode([Release].self, from: data) return try JSONDecoder()
.decode([GitHubRelease].self, from: data)
.map(Release.init)
} }
} }