diff --git a/Sources/Packages/Sources/Brief/Release.swift b/Sources/Packages/Sources/Brief/Release.swift index ffc3293..248fcd7 100644 --- a/Sources/Packages/Sources/Brief/Release.swift +++ b/Sources/Packages/Sources/Brief/Release.swift @@ -1,7 +1,8 @@ import Foundation +import SwiftUI /// 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" public let name: String @@ -15,6 +16,8 @@ public struct Release: Codable, Sendable { /// A user-facing description of the contents of the update. public let body: String + public let attributedBody: AttributedString + /// Initializes a Release. /// - Parameters: /// - name: The user-facing name of the release. @@ -26,6 +29,56 @@ public struct Release: Codable, Sendable { self.prerelease = prerelease self.html_url = html_url 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")) + } } } diff --git a/Sources/Packages/Sources/XPCWrappers/XPCServiceDelegate.swift b/Sources/Packages/Sources/XPCWrappers/XPCServiceDelegate.swift index 5108ed2..9fd9216 100644 --- a/Sources/Packages/Sources/XPCWrappers/XPCServiceDelegate.swift +++ b/Sources/Packages/Sources/XPCWrappers/XPCServiceDelegate.swift @@ -34,7 +34,9 @@ public final class XPCServiceDelegate: NSObject, NSXPCListenerDelegate { if let error = error as? Codable & Error { reply(nil, NSError(error)) } 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])) } } } diff --git a/Sources/Secretive/Views/Modifiers/ActionButtonStyle.swift b/Sources/Secretive/Views/Modifiers/ActionButtonStyle.swift index 74284a7..70ab463 100644 --- a/Sources/Secretive/Views/Modifiers/ActionButtonStyle.swift +++ b/Sources/Secretive/Views/Modifiers/ActionButtonStyle.swift @@ -24,7 +24,7 @@ extension View { } -struct MenuButtonModifier: ViewModifier { +struct ToolbarCircleButtonModifier: ViewModifier { func body(content: Content) -> some View { if #available(macOS 26.0, *) { @@ -40,8 +40,8 @@ struct MenuButtonModifier: ViewModifier { extension View { - func menuButton() -> some View { - modifier(MenuButtonModifier()) + func toolbarCircleButton() -> some View { + modifier(ToolbarCircleButtonModifier()) } } diff --git a/Sources/Secretive/Views/Modifiers/ToolbarButtonStyle.swift b/Sources/Secretive/Views/Modifiers/ToolbarButtonStyle.swift index e175970..99ada75 100644 --- a/Sources/Secretive/Views/Modifiers/ToolbarButtonStyle.swift +++ b/Sources/Secretive/Views/Modifiers/ToolbarButtonStyle.swift @@ -1,6 +1,6 @@ import SwiftUI -struct ToolbarButtonStyle: ButtonStyle { +struct ToolbarStatusButtonStyle: ButtonStyle { private let lightColor: 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)) + } + } +} + diff --git a/Sources/Secretive/Views/Views/ContentView.swift b/Sources/Secretive/Views/Views/ContentView.swift index 76c9226..c461141 100644 --- a/Sources/Secretive/Views/Views/ContentView.swift +++ b/Sources/Secretive/Views/Views/ContentView.swift @@ -6,19 +6,21 @@ import Brief struct ContentView: View { - @AppStorage("defaultsHasRunSetup") var hasRunSetup = false - @State var showingCreation = false - @State var runningSetup = false - @State var showingAgentInfo = false @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? + + @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 runningSetup = false + @State private var showingAgentInfo = false var body: some View { VStack { @@ -102,24 +104,9 @@ extension ContentView { .font(.headline) .foregroundColor(.white) }) - .buttonStyle(ToolbarButtonStyle(color: color)) + .buttonStyle(ToolbarStatusButtonStyle(color: color)) .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) } } } @@ -130,7 +117,7 @@ extension ContentView { Button(.appMenuNewSecretButton, systemImage: "plus") { showingCreation = true } - .menuButton() + .toolbarCircleButton() } } @@ -157,7 +144,7 @@ extension ContentView { } }) .buttonStyle( - ToolbarButtonStyle( + ToolbarStatusButtonStyle( lightColor: agentStatusChecker.running ? .black.opacity(0.05) : .red.opacity(0.75), darkColor: agentStatusChecker.running ? .white.opacity(0.05) : .red.opacity(0.5), ) @@ -179,7 +166,7 @@ extension ContentView { .font(.headline) .foregroundColor(.white) }) - .buttonStyle(ToolbarButtonStyle(color: .orange)) + .buttonStyle(ToolbarStatusButtonStyle(color: .orange)) .popover(isPresented: $showingAppPathNotice, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { VStack { Image(systemName: "exclamationmark.triangle") diff --git a/Sources/Secretive/Views/Views/UpdateView.swift b/Sources/Secretive/Views/Views/UpdateView.swift index 810e0e8..91f8513 100644 --- a/Sources/Secretive/Views/Views/UpdateView.swift +++ b/Sources/Secretive/Views/Views/UpdateView.swift @@ -3,61 +3,50 @@ import Brief struct UpdateDetailView: View { - @Environment(\.updater) var updater: any UpdaterProtocol + @Environment(\.updater) var updater + @Environment(\.openURL) var openURL let update: Release var body: some View { - VStack { - Text(.updateVersionName(updateName: update.name)).font(.title) - GroupBox(label: Text(.updateReleaseNotesTitle)) { - ScrollView { - attributedBody - } - } - HStack { - if !update.critical { - Button(.updateIgnoreButton) { - Task { - await updater.ignore(release: update) + VStack(spacing: 0) { + HStack { + if !update.critical { + Button(.updateIgnoreButton) { + Task { + await updater.ignore(release: update) + } } + .buttonStyle(ToolbarButtonStyle()) } 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) { + openURL(update.html_url) + } + .buttonStyle(ToolbarButtonStyle(tint: .accentColor)) + .keyboardShortcut(.defaultAction) } - Button(.updateUpdateButton) { - NSWorkspace.shared.open(update.html_url) + .padding() + Divider() + Form { + Section { + Text(update.attributedBody) + } header: { + Text(.updateVersionName(updateName: update.name)) .headerProminence(.increased) + } } - .keyboardShortcut(.defaultAction) - } - + .formStyle(.grouped) } - .padding() - .frame(maxWidth: 500) - } - - 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 { - 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")) +} diff --git a/Sources/SecretiveUpdater/SecretiveUpdater.swift b/Sources/SecretiveUpdater/SecretiveUpdater.swift index 998fd31..eab2587 100644 --- a/Sources/SecretiveUpdater/SecretiveUpdater.swift +++ b/Sources/SecretiveUpdater/SecretiveUpdater.swift @@ -11,7 +11,9 @@ final class SecretiveUpdater: NSObject, XPCProtocol { func process(_: Data) async throws -> [Release] { 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) } }