From 2b01ff659d83250f14323f5cef17a0b9e5e8580c Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 20:24:27 -0600 Subject: [PATCH 01/17] Fix outline-toggle icon vanishing after window focus cycle --- Sources/Markee/MarkeeTitlebar.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Sources/Markee/MarkeeTitlebar.swift b/Sources/Markee/MarkeeTitlebar.swift index f70698b..ddd2364 100644 --- a/Sources/Markee/MarkeeTitlebar.swift +++ b/Sources/Markee/MarkeeTitlebar.swift @@ -30,7 +30,7 @@ struct MarkeeTitlebar: View { Button(action: onToggleOutline) { Image(systemName: "sidebar.left") .font(.system(size: 14, weight: .regular)) - .foregroundStyle(Color.secondary) + .foregroundStyle(toggleIconColor) .frame(width: 24, height: 24) .contentShape(Rectangle()) } @@ -73,6 +73,19 @@ struct MarkeeTitlebar: View { }) } + /// Explicit toggle-icon color. Does NOT use `Color.secondary`: that + /// semantic color tracks window active-state and fails to recompute on the + /// resign-key → become-key cycle, rendering the glyph transparent. + private var toggleIconColor: Color { + Color(nsColor: NSColor(name: nil) { appearance in + if appearance.bestMatch(from: [.darkAqua, .vibrantDark, .accessibilityHighContrastDarkAqua]) != nil { + return NSColor(red: 0x8c/255.0, green: 0x8e/255.0, blue: 0x97/255.0, alpha: 1) + } else { + return NSColor(red: 0x6a/255.0, green: 0x6c/255.0, blue: 0x74/255.0, alpha: 1) + } + }) + } + private var titlebarTopColor: Color { Color(nsColor: NSColor(name: nil) { appearance in if appearance.bestMatch(from: [.darkAqua, .vibrantDark, .accessibilityHighContrastDarkAqua]) != nil { From 4c5828d8a35a8a1e07b27049be8ff18786b17ee8 Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 20:27:10 -0600 Subject: [PATCH 02/17] Add Copy Markdown Source and Reveal in Finder commands --- Sources/Markee/MarkeeApp.swift | 11 ++++++++ Sources/Markee/PreviewController.swift | 38 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/Sources/Markee/MarkeeApp.swift b/Sources/Markee/MarkeeApp.swift index 3db9b7b..8084ac2 100644 --- a/Sources/Markee/MarkeeApp.swift +++ b/Sources/Markee/MarkeeApp.swift @@ -60,6 +60,15 @@ struct MarkeeApp: App { NotificationCenter.default.post(name: .openInEditor, object: nil) } .keyboardShortcut("E", modifiers: [.command, .option]) + Divider() + Button("Copy Markdown Source") { + NotificationCenter.default.post(name: .copyMarkdownSource, object: nil) + } + .keyboardShortcut("C", modifiers: [.command, .shift]) + Button("Reveal in Finder") { + NotificationCenter.default.post(name: .revealInFinder, object: nil) + } + .keyboardShortcut("R", modifiers: [.command, .shift]) } CommandGroup(replacing: .printItem) { Button("Print…") { @@ -98,6 +107,8 @@ extension Notification.Name { static let findNext = Notification.Name("MarkeeFindNext") static let findPrevious = Notification.Name("MarkeeFindPrevious") static let reloadFile = Notification.Name("MarkeeReloadFile") + static let copyMarkdownSource = Notification.Name("MarkeeCopyMarkdownSource") + static let revealInFinder = Notification.Name("MarkeeRevealInFinder") } final class AppDelegate: NSObject, NSApplicationDelegate { diff --git a/Sources/Markee/PreviewController.swift b/Sources/Markee/PreviewController.swift index 8accc0c..10ded0b 100644 --- a/Sources/Markee/PreviewController.swift +++ b/Sources/Markee/PreviewController.swift @@ -116,6 +116,12 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle NotificationCenter.default.addObserver( self, selector: #selector(handleReload), name: .reloadFile, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(handleCopyMarkdownSource), + name: .copyMarkdownSource, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(handleRevealInFinder), + name: .revealInFinder, object: nil) } deinit { @@ -235,6 +241,38 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle loadFromDisk(reason: "manual") } + /// ⌘⇧C — copy the file's raw Markdown to the clipboard. Reads fresh from + /// disk via the renderer's read path so the clipboard never holds a stale + /// snapshot. Public so MarkeeWebView's context menu can call it directly. + func copyMarkdownSource() { + let source: String + do { + source = try Self.readFileWithFallback(at: fileURL) + } catch { + self.errorBanner = "Couldn't read \(fileURL.lastPathComponent): \(error.localizedDescription)" + return + } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(source, forType: .string) + } + + /// ⌘⇧R — reveal the current file in Finder. Public so MarkeeWebView's + /// context menu can call it directly. + func revealInFinder() { + NSWorkspace.shared.activateFileViewerSelecting([fileURL]) + } + + @objc private func handleCopyMarkdownSource() { + guard webView.window?.isKeyWindow == true else { return } + copyMarkdownSource() + } + + @objc private func handleRevealInFinder() { + guard webView.window?.isKeyWindow == true else { return } + revealInFinder() + } + /// ⌘⇧G — mirror of handleFindNext, searching backward. @objc private func handleFindPrevious() { guard webView.window?.isKeyWindow == true else { return } From ddb8429bee7902724b8685787a93dca5f31518c1 Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 20:29:34 -0600 Subject: [PATCH 03/17] Add preview right-click context menu via MarkeeWebView --- Sources/Markee/MarkeeWebView.swift | 35 ++++++++++++++++++++++++++ Sources/Markee/PreviewController.swift | 6 +++-- 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 Sources/Markee/MarkeeWebView.swift diff --git a/Sources/Markee/MarkeeWebView.swift b/Sources/Markee/MarkeeWebView.swift new file mode 100644 index 0000000..d167ca4 --- /dev/null +++ b/Sources/Markee/MarkeeWebView.swift @@ -0,0 +1,35 @@ +import WebKit + +/// WKWebView subclass that appends Markee's own items to the native +/// right-click context menu. WKWebView owns its context menu, so SwiftUI's +/// `.contextMenu` cannot reach it — we extend `willOpenMenu` instead. +final class MarkeeWebView: WKWebView { + weak var controller: PreviewController? + + override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) { + super.willOpenMenu(menu, with: event) + menu.addItem(.separator()) + + let copyItem = NSMenuItem( + title: "Copy Markdown Source", + action: #selector(copyMarkdownSourceAction), + keyEquivalent: "") + copyItem.target = self + menu.addItem(copyItem) + + let revealItem = NSMenuItem( + title: "Reveal in Finder", + action: #selector(revealInFinderAction), + keyEquivalent: "") + revealItem.target = self + menu.addItem(revealItem) + } + + @objc private func copyMarkdownSourceAction() { + controller?.copyMarkdownSource() + } + + @objc private func revealInFinderAction() { + controller?.revealInFinder() + } +} diff --git a/Sources/Markee/PreviewController.swift b/Sources/Markee/PreviewController.swift index 10ded0b..c0902c8 100644 --- a/Sources/Markee/PreviewController.swift +++ b/Sources/Markee/PreviewController.swift @@ -40,7 +40,7 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle @Published var findQuery: String = "" @Published var findNotFound: Bool = false - let webView: WKWebView + let webView: MarkeeWebView let bundleHandler = BundleSchemeHandler() let docHandler: DocSchemeHandler @@ -69,9 +69,11 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle config.setURLSchemeHandler(bundleHandler, forURLScheme: BundleSchemeHandler.scheme) config.setURLSchemeHandler(docHandler, forURLScheme: DocSchemeHandler.scheme) - self.webView = WKWebView(frame: .zero, configuration: config) + self.webView = MarkeeWebView(frame: .zero, configuration: config) super.init() + self.webView.controller = self + userContent.add(self, name: "markee") self.webView.navigationDelegate = self self.webView.allowsBackForwardNavigationGestures = false From 4347d997e440995c6ed53cbd87a1456a2bd8d19e Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 20:32:06 -0600 Subject: [PATCH 04/17] Document MarkeeWebView weak reference and context-menu focus assumption --- Sources/Markee/MarkeeWebView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/Markee/MarkeeWebView.swift b/Sources/Markee/MarkeeWebView.swift index d167ca4..003e8fd 100644 --- a/Sources/Markee/MarkeeWebView.swift +++ b/Sources/Markee/MarkeeWebView.swift @@ -4,10 +4,14 @@ import WebKit /// right-click context menu. WKWebView owns its context menu, so SwiftUI's /// `.contextMenu` cannot reach it — we extend `willOpenMenu` instead. final class MarkeeWebView: WKWebView { + /// Weak to avoid a retain cycle: PreviewController owns this webView. weak var controller: PreviewController? override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) { super.willOpenMenu(menu, with: event) + + // No isKeyWindow guard needed: a right-click targets this exact + // window directly (unlike the broadcast-to-all-windows menu commands). menu.addItem(.separator()) let copyItem = NSMenuItem( From 08167126e88d7279c07e6a8451c0c5ec0c1cdf24 Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 20:33:40 -0600 Subject: [PATCH 05/17] Add AppVersion semver comparison Co-Authored-By: Claude Sonnet 4.6 --- Sources/Markee/Updater.swift | 43 ++++++++++++++++++++++++++++ Tests/MarkeeTests/UpdaterTests.swift | 33 +++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 Sources/Markee/Updater.swift create mode 100644 Tests/MarkeeTests/UpdaterTests.swift diff --git a/Sources/Markee/Updater.swift b/Sources/Markee/Updater.swift new file mode 100644 index 0000000..b34993b --- /dev/null +++ b/Sources/Markee/Updater.swift @@ -0,0 +1,43 @@ +import Foundation + +/// A dotted numeric version (e.g. "0.4.0"), tolerant of a leading "v" and of a +/// pre-release/build suffix. Used only to compare the running app against the +/// latest GitHub release. +struct AppVersion: Comparable, Sendable { + let components: [Int] + + init?(_ string: String) { + var s = string.trimmingCharacters(in: .whitespaces) + if s.hasPrefix("v") || s.hasPrefix("V") { s.removeFirst() } + if let dash = s.firstIndex(of: "-") { s = String(s[..= 0 else { return nil } + nums.append(n) + } + self.components = nums + } + + static func == (lhs: AppVersion, rhs: AppVersion) -> Bool { + let count = max(lhs.components.count, rhs.components.count) + for i in 0.. Bool { + let count = max(lhs.components.count, rhs.components.count) + for i in 0.. AppVersion("0.4.0")!) + XCTAssertTrue(AppVersion("0.4.1")! > AppVersion("0.4.0")!) + XCTAssertTrue(AppVersion("1.0.0")! > AppVersion("0.9.9")!) + } + + func test_missingTrailingComponentsTreatedAsZero() { + XCTAssertEqual(AppVersion("0.4"), AppVersion("0.4.0")) + XCTAssertFalse(AppVersion("0.4")! > AppVersion("0.4.0")!) + } + + func test_rejectsNonNumeric() { + XCTAssertNil(AppVersion("not-a-version")) + XCTAssertNil(AppVersion("")) + XCTAssertNil(AppVersion("1.x.0")) + } +} From e747218aabcf3dd1ee474ce0e6b51f828a2d5519 Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 20:36:22 -0600 Subject: [PATCH 06/17] Document AppVersion prerelease-suffix handling --- Sources/Markee/Updater.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Markee/Updater.swift b/Sources/Markee/Updater.swift index b34993b..3e6d16f 100644 --- a/Sources/Markee/Updater.swift +++ b/Sources/Markee/Updater.swift @@ -9,6 +9,8 @@ struct AppVersion: Comparable, Sendable { init?(_ string: String) { var s = string.trimmingCharacters(in: .whitespaces) if s.hasPrefix("v") || s.hasPrefix("V") { s.removeFirst() } + // Drop any pre-release suffix ("1.0.0-rc1" -> "1.0.0"). Harmless: + // the updater only reads /releases/latest, which excludes pre-releases. if let dash = s.firstIndex(of: "-") { s = String(s[.. Date: Mon, 18 May 2026 20:37:53 -0600 Subject: [PATCH 07/17] Add GitHubRelease JSON parsing --- Sources/Markee/Updater.swift | 33 +++++++++++++++++++++++ Tests/MarkeeTests/UpdaterTests.swift | 39 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/Sources/Markee/Updater.swift b/Sources/Markee/Updater.swift index 3e6d16f..2ca7992 100644 --- a/Sources/Markee/Updater.swift +++ b/Sources/Markee/Updater.swift @@ -43,3 +43,36 @@ struct AppVersion: Comparable, Sendable { return false } } + +/// The subset of a GitHub Release that Markee uses, decoded from the +/// `/releases/latest` API response. +struct GitHubRelease: Sendable { + let version: AppVersion + let tagName: String + let pageURL: URL + let zipURL: URL + let notes: String + + /// Parse the GitHub `/releases/latest` JSON. Returns nil if the payload is + /// missing required fields or has no `Markee.app.zip` asset. + init?(json data: Data) { + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let tag = obj["tag_name"] as? String, + let version = AppVersion(tag), + let htmlURLString = obj["html_url"] as? String, + let pageURL = URL(string: htmlURLString), + let assets = obj["assets"] as? [[String: Any]] + else { return nil } + + guard let zip = assets.first(where: { ($0["name"] as? String) == "Markee.app.zip" }), + let zipURLString = zip["browser_download_url"] as? String, + let zipURL = URL(string: zipURLString) + else { return nil } + + self.version = version + self.tagName = tag + self.pageURL = pageURL + self.zipURL = zipURL + self.notes = (obj["body"] as? String) ?? "" + } +} diff --git a/Tests/MarkeeTests/UpdaterTests.swift b/Tests/MarkeeTests/UpdaterTests.swift index a7a54bf..09a1c83 100644 --- a/Tests/MarkeeTests/UpdaterTests.swift +++ b/Tests/MarkeeTests/UpdaterTests.swift @@ -31,3 +31,42 @@ final class AppVersionTests: XCTestCase { XCTAssertNil(AppVersion("1.x.0")) } } + +final class GitHubReleaseTests: XCTestCase { + private func payload(includeZip: Bool) -> Data { + let asset = includeZip + ? """ + { "name": "Markee.app.zip", + "browser_download_url": "https://example.com/dl/Markee.app.zip" } + """ + : """ + { "name": "SomethingElse.txt", + "browser_download_url": "https://example.com/dl/other.txt" } + """ + return """ + { + "tag_name": "v0.5.0", + "html_url": "https://github.com/sethbang/markee/releases/tag/v0.5.0", + "body": "Release notes here.", + "assets": [ \(asset) ] + } + """.data(using: .utf8)! + } + + func test_parsesValidPayload() { + let release = GitHubRelease(json: payload(includeZip: true)) + XCTAssertNotNil(release) + XCTAssertEqual(release?.tagName, "v0.5.0") + XCTAssertEqual(release?.version, AppVersion("0.5.0")) + XCTAssertEqual(release?.zipURL.absoluteString, "https://example.com/dl/Markee.app.zip") + XCTAssertEqual(release?.notes, "Release notes here.") + } + + func test_nilWhenZipAssetMissing() { + XCTAssertNil(GitHubRelease(json: payload(includeZip: false))) + } + + func test_nilWhenMalformedJSON() { + XCTAssertNil(GitHubRelease(json: Data("not json".utf8))) + } +} From 8ead489ee174f1c49ce8c9ca7261a7af444a0cfe Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 20:41:23 -0600 Subject: [PATCH 08/17] Strengthen GitHubRelease tests: pageURL assertion, missing-body default --- Tests/MarkeeTests/UpdaterTests.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Tests/MarkeeTests/UpdaterTests.swift b/Tests/MarkeeTests/UpdaterTests.swift index 09a1c83..089b8d6 100644 --- a/Tests/MarkeeTests/UpdaterTests.swift +++ b/Tests/MarkeeTests/UpdaterTests.swift @@ -60,6 +60,8 @@ final class GitHubReleaseTests: XCTestCase { XCTAssertEqual(release?.version, AppVersion("0.5.0")) XCTAssertEqual(release?.zipURL.absoluteString, "https://example.com/dl/Markee.app.zip") XCTAssertEqual(release?.notes, "Release notes here.") + XCTAssertEqual(release?.pageURL.absoluteString, + "https://github.com/sethbang/markee/releases/tag/v0.5.0") } func test_nilWhenZipAssetMissing() { @@ -69,4 +71,20 @@ final class GitHubReleaseTests: XCTestCase { func test_nilWhenMalformedJSON() { XCTAssertNil(GitHubRelease(json: Data("not json".utf8))) } + + func test_notesEmptyWhenBodyMissing() { + let json = """ + { + "tag_name": "v0.5.0", + "html_url": "https://github.com/sethbang/markee/releases/tag/v0.5.0", + "assets": [ + { "name": "Markee.app.zip", + "browser_download_url": "https://example.com/dl/Markee.app.zip" } + ] + } + """.data(using: .utf8)! + let release = GitHubRelease(json: json) + XCTAssertNotNil(release) + XCTAssertEqual(release?.notes, "") + } } From 2977e1b4e49c46cc2c18b73e802b5d830b1b328e Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 20:43:24 -0600 Subject: [PATCH 09/17] Add Updater fetch and version-decision logic --- Sources/Markee/Updater.swift | 110 +++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/Sources/Markee/Updater.swift b/Sources/Markee/Updater.swift index 2ca7992..9fa9a1a 100644 --- a/Sources/Markee/Updater.swift +++ b/Sources/Markee/Updater.swift @@ -76,3 +76,113 @@ struct GitHubRelease: Sendable { self.notes = (obj["body"] as? String) ?? "" } } + +enum UpdaterError: LocalizedError { + case badResponse + case unparseable + case downloadFailed + case subprocessFailed + case invalidBundle + + var errorDescription: String? { + switch self { + case .badResponse: return "GitHub returned an unexpected response." + case .unparseable: return "Couldn't read the release information." + case .downloadFailed: return "The update download failed." + case .subprocessFailed: return "Unpacking the update failed." + case .invalidBundle: return "The downloaded update looked invalid." + } + } +} + +import AppKit + +@MainActor +final class Updater { + static let shared = Updater() + + private let repo = "sethbang/markee" + private var isRunning = false + + private static let lastCheckKey = "MarkeeLastUpdateCheck" + private static let skippedVersionKey = "MarkeeSkippedVersion" + + private init() {} + + static var currentVersionString: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0" + } + + private var currentVersion: AppVersion { + AppVersion(Self.currentVersionString) ?? AppVersion("0.0.0")! + } + + /// Once-a-day silent launch check. Throttled by a UserDefaults timestamp. + func checkOnLaunch() { + let now = Date() + if let last = UserDefaults.standard.object(forKey: Self.lastCheckKey) as? Date, + now.timeIntervalSince(last) < 24 * 60 * 60 { + return + } + UserDefaults.standard.set(now, forKey: Self.lastCheckKey) + Task { await check(userInitiated: false) } + } + + /// "Check for Updates…" menu action. Always reports its result. + func checkForUpdatesMenuAction() { + UserDefaults.standard.set(Date(), forKey: Self.lastCheckKey) + Task { await check(userInitiated: true) } + } + + private func check(userInitiated: Bool) async { + guard !isRunning else { return } + isRunning = true + defer { isRunning = false } + + let release: GitHubRelease + do { + release = try await Self.fetchLatestRelease(repo: repo) + } catch { + if userInitiated { + presentError(error.localizedDescription) + } + return + } + + guard release.version > currentVersion else { + if userInitiated { presentUpToDate() } + return + } + + if !userInitiated, + UserDefaults.standard.string(forKey: Self.skippedVersionKey) == release.tagName { + return + } + + presentUpdateAvailable(release) + } + + nonisolated static func fetchLatestRelease(repo: String) async throws -> GitHubRelease { + var request = URLRequest(url: URL(string: "https://api.github.com/repos/\(repo)/releases/latest")!) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw UpdaterError.badResponse + } + guard let release = GitHubRelease(json: data) else { + throw UpdaterError.unparseable + } + return release + } + + // MARK: - Temporary stubs (replaced in Tasks 7 and 9) + + func presentUpdateAvailable(_ release: GitHubRelease) {} + func presentUpToDate() {} + func presentError(_ message: String) {} + func installUpdate(_ release: GitHubRelease) async {} + + static func storeSkippedVersion(_ tag: String) { + UserDefaults.standard.set(tag, forKey: skippedVersionKey) + } +} From 18b11e7e9d15b9124ded0da212f5abfa1212db5d Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 20:47:28 -0600 Subject: [PATCH 10/17] Add Updater alert UI Co-Authored-By: Claude Sonnet 4.6 --- Sources/Markee/Updater.swift | 61 +++++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/Sources/Markee/Updater.swift b/Sources/Markee/Updater.swift index 9fa9a1a..bdfb37f 100644 --- a/Sources/Markee/Updater.swift +++ b/Sources/Markee/Updater.swift @@ -175,14 +175,61 @@ final class Updater { return release } - // MARK: - Temporary stubs (replaced in Tasks 7 and 9) + // MARK: - UI + + func presentUpdateAvailable(_ release: GitHubRelease) { + let alert = NSAlert() + alert.messageText = "A new version of Markee is available" + var info = "Markee \(release.tagName) is available — you have \(Self.currentVersionString)." + if !release.notes.isEmpty { + let notes = release.notes.count > 500 + ? String(release.notes.prefix(500)) + "…" + : release.notes + info += "\n\n" + notes + } + alert.informativeText = info + alert.addButton(withTitle: "Update Now") + alert.addButton(withTitle: "Remind Me Later") + alert.addButton(withTitle: "Skip This Version") + + switch alert.runModal() { + case .alertFirstButtonReturn: + Task { await installUpdate(release) } + case .alertThirdButtonReturn: + UserDefaults.standard.set(release.tagName, forKey: Self.skippedVersionKey) + default: + break // Remind Me Later — nothing persisted; the next check re-offers. + } + } - func presentUpdateAvailable(_ release: GitHubRelease) {} - func presentUpToDate() {} - func presentError(_ message: String) {} - func installUpdate(_ release: GitHubRelease) async {} + func presentUpToDate() { + let alert = NSAlert() + alert.messageText = "You're up to date" + alert.informativeText = "Markee \(Self.currentVersionString) is the latest version." + alert.runModal() + } - static func storeSkippedVersion(_ tag: String) { - UserDefaults.standard.set(tag, forKey: skippedVersionKey) + func presentError(_ message: String) { + let alert = NSAlert() + alert.messageText = "Couldn't check for updates" + alert.informativeText = message + alert.runModal() } + + /// Used when self-replace can't proceed (read-only location, bad download). + /// Sends the user to the release page to update by hand. + func presentManualFallback(_ release: GitHubRelease, reason: String) { + let alert = NSAlert() + alert.messageText = "Update Markee manually" + alert.informativeText = "\(reason)\n\nOpen the download page to get \(release.tagName) in your browser." + alert.addButton(withTitle: "Open Download Page") + alert.addButton(withTitle: "Cancel") + if alert.runModal() == .alertFirstButtonReturn { + NSWorkspace.shared.open(release.pageURL) + } + } + + // MARK: - Install (implemented in Task 9) + + func installUpdate(_ release: GitHubRelease) async {} } From 619b75a60cce51fa07176dfeba57c2f9b26be6f0 Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 20:52:08 -0600 Subject: [PATCH 11/17] Add Updater download, unzip, and bundle validation Co-Authored-By: Claude Sonnet 4.6 --- Sources/Markee/Updater.swift | 45 ++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/Sources/Markee/Updater.swift b/Sources/Markee/Updater.swift index bdfb37f..57048bd 100644 --- a/Sources/Markee/Updater.swift +++ b/Sources/Markee/Updater.swift @@ -229,6 +229,51 @@ final class Updater { } } + // MARK: - Download / stage + + /// Run a subprocess to completion; throw if it exits non-zero. + nonisolated static func runProcess(_ launchPath: String, _ args: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: launchPath) + process.arguments = args + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { throw UpdaterError.subprocessFailed } + } + + /// Download `Markee.app.zip`, unzip it with `ditto`, and validate the + /// result. Returns the URL of the staged, validated `Markee.app`. Runs off + /// the main actor — the unzip is blocking work. + nonisolated static func downloadAndStage(_ release: GitHubRelease) async throws -> URL { + let (tempZip, response) = try await URLSession.shared.download(from: release.zipURL) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw UpdaterError.downloadFailed + } + + let work = FileManager.default.temporaryDirectory + .appendingPathComponent("markee-update-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) + let zipURL = work.appendingPathComponent("Markee.app.zip") + try FileManager.default.moveItem(at: tempZip, to: zipURL) + + // `ditto` unzips reliably, preserving the bundle's symlinks/structure. + try runProcess("/usr/bin/ditto", ["-x", "-k", zipURL.path, work.path]) + + let bundle = work.appendingPathComponent("Markee.app") + let exec = bundle.appendingPathComponent("Contents/MacOS/Markee") + guard FileManager.default.isExecutableFile(atPath: exec.path) else { + throw UpdaterError.invalidBundle + } + let infoPlist = bundle.appendingPathComponent("Contents/Info.plist") + guard let info = NSDictionary(contentsOf: infoPlist), + let versionString = info["CFBundleShortVersionString"] as? String, + let parsed = AppVersion(versionString), + parsed == release.version else { + throw UpdaterError.invalidBundle + } + return bundle + } + // MARK: - Install (implemented in Task 9) func installUpdate(_ release: GitHubRelease) async {} From 220faa76e0892c82850239d75f787f2967f7bfc4 Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 20:56:30 -0600 Subject: [PATCH 12/17] Add Updater quarantine strip, bundle swap, and install flow Co-Authored-By: Claude Sonnet 4.6 --- Sources/Markee/Updater.swift | 114 ++++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 2 deletions(-) diff --git a/Sources/Markee/Updater.swift b/Sources/Markee/Updater.swift index 57048bd..c4fabcb 100644 --- a/Sources/Markee/Updater.swift +++ b/Sources/Markee/Updater.swift @@ -97,6 +97,39 @@ enum UpdaterError: LocalizedError { import AppKit +/// A minimal modal-free panel shown while an update downloads and installs. +@MainActor +final class UpdateProgressPanel { + private let panel: NSPanel + private let label: NSTextField + + init() { + label = NSTextField(labelWithString: "Downloading update…") + label.alignment = .center + + let spinner = NSProgressIndicator() + spinner.style = .spinning + spinner.isIndeterminate = true + spinner.startAnimation(nil) + + let stack = NSStackView(views: [spinner, label]) + stack.orientation = .vertical + stack.spacing = 14 + stack.edgeInsets = NSEdgeInsets(top: 24, left: 32, bottom: 24, right: 32) + + panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 280, height: 130), + styleMask: [.titled], backing: .buffered, defer: false) + panel.title = "Markee" + panel.contentView = stack + panel.center() + } + + func show() { panel.makeKeyAndOrderFront(nil) } + func setMessage(_ text: String) { label.stringValue = text } + func close() { panel.close() } +} + @MainActor final class Updater { static let shared = Updater() @@ -274,7 +307,84 @@ final class Updater { return bundle } - // MARK: - Install (implemented in Task 9) + // MARK: - Install + + func installUpdate(_ release: GitHubRelease) async { + let installPath = Bundle.main.bundlePath + let parent = (installPath as NSString).deletingLastPathComponent + guard FileManager.default.isWritableFile(atPath: parent) else { + presentManualFallback(release, reason: "Markee can't replace itself from this location.") + return + } + + let progress = UpdateProgressPanel() + progress.show() + + let stagedBundle: URL + do { + stagedBundle = try await Self.downloadAndStage(release) + } catch { + progress.close() + presentManualFallback(release, reason: error.localizedDescription) + return + } - func installUpdate(_ release: GitHubRelease) async {} + progress.setMessage("Installing update…") + do { + try Self.stripQuarantine(stagedBundle) + try Self.launchSwapHelper(newBundle: stagedBundle, installPath: installPath) + } catch { + progress.close() + presentManualFallback(release, reason: error.localizedDescription) + return + } + + // The helper waits for this process to exit, then swaps and relaunches. + NSApp.terminate(nil) + } + + nonisolated static func stripQuarantine(_ bundle: URL) throws { + try runProcess("/usr/bin/xattr", ["-dr", "com.apple.quarantine", bundle.path]) + } + + /// Write a detached shell helper that waits for this process to quit, swaps + /// the bundle in place (keeping a `.old` backup until success), and + /// relaunches. The helper outlives this process by design — not waited on. + nonisolated static func launchSwapHelper(newBundle: URL, installPath: String) throws { + let script = """ + #!/bin/bash + PID="$1"; NEW="$2"; DEST="$3" + while kill -0 "$PID" 2>/dev/null; do sleep 0.2; done + BACKUP="${DEST}.old" + rm -rf "$BACKUP" + if ! mv "$DEST" "$BACKUP"; then + open "$DEST" 2>/dev/null || true + exit 1 + fi + if ditto "$NEW" "$DEST"; then + rm -rf "$BACKUP" + rm -rf "$(dirname "$NEW")" + open "$DEST" + else + rm -rf "$DEST" + mv "$BACKUP" "$DEST" + open "$DEST" + exit 1 + fi + """ + let scriptURL = FileManager.default.temporaryDirectory + .appendingPathComponent("markee-swap-\(UUID().uuidString).sh") + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = [ + scriptURL.path, + String(ProcessInfo.processInfo.processIdentifier), + newBundle.path, + installPath, + ] + try process.run() + // Intentionally not waited on — it must outlive this process. + } } From 99cb373aa4bc62b7986c8cd8dd1776b6cc1da0e9 Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 21:03:23 -0600 Subject: [PATCH 13/17] Harden updater swap helper: rollback guard, absolute ditto path, temp cleanup, wait cap, non-dismissable panel Co-Authored-By: Claude Sonnet 4.6 --- Sources/Markee/Updater.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/Markee/Updater.swift b/Sources/Markee/Updater.swift index c4fabcb..345b342 100644 --- a/Sources/Markee/Updater.swift +++ b/Sources/Markee/Updater.swift @@ -123,6 +123,7 @@ final class UpdateProgressPanel { panel.title = "Markee" panel.contentView = stack panel.center() + panel.standardWindowButton(.closeButton)?.isEnabled = false } func show() { panel.makeKeyAndOrderFront(nil) } @@ -354,20 +355,29 @@ final class Updater { let script = """ #!/bin/bash PID="$1"; NEW="$2"; DEST="$3" - while kill -0 "$PID" 2>/dev/null; do sleep 0.2; done + trap 'rm -f "$0"' EXIT + WAITED=0 + while kill -0 "$PID" 2>/dev/null; do + sleep 0.2 + WAITED=$((WAITED + 1)) + if [ "$WAITED" -ge 300 ]; then break; fi + done BACKUP="${DEST}.old" rm -rf "$BACKUP" if ! mv "$DEST" "$BACKUP"; then open "$DEST" 2>/dev/null || true exit 1 fi - if ditto "$NEW" "$DEST"; then + if /usr/bin/ditto "$NEW" "$DEST"; then rm -rf "$BACKUP" rm -rf "$(dirname "$NEW")" open "$DEST" else rm -rf "$DEST" - mv "$BACKUP" "$DEST" + if ! mv "$BACKUP" "$DEST"; then + osascript -e "display alert \\"Markee update failed\\" message \\"Could not restore Markee. Your previous version is saved at: $BACKUP\\"" 2>/dev/null || true + exit 2 + fi open "$DEST" exit 1 fi From f8e4449100f153906f8373195d099bab9658b2bc Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 21:06:41 -0600 Subject: [PATCH 14/17] Wire updater into app: launch check and Check for Updates menu item --- Sources/Markee/MarkeeApp.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/Markee/MarkeeApp.swift b/Sources/Markee/MarkeeApp.swift index 8084ac2..fe44ec8 100644 --- a/Sources/Markee/MarkeeApp.swift +++ b/Sources/Markee/MarkeeApp.swift @@ -12,6 +12,11 @@ struct MarkeeApp: App { } .defaultSize(width: 1000, height: 800) .commands { + CommandGroup(after: .appInfo) { + Button("Check for Updates…") { + Updater.shared.checkForUpdatesMenuAction() + } + } CommandGroup(after: .newItem) { Divider() Button("Install Command Line Tool…") { @@ -120,6 +125,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { false } + func applicationDidFinishLaunching(_ notification: Notification) { + Updater.shared.checkOnLaunch() + } + static func installCLI() { guard let bundleCLI = Bundle.main.url(forResource: "cli/markee", withExtension: nil) else { NSSound.beep(); return From f29e89dfea19b0c855bd3180622b620108c1f0ce Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 21:14:46 -0600 Subject: [PATCH 15/17] Harden updater: throttle on successful fetch only, pin GitHub API version Co-Authored-By: Claude Sonnet 4.6 --- Sources/Markee/Updater.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/Markee/Updater.swift b/Sources/Markee/Updater.swift index 345b342..05b0265 100644 --- a/Sources/Markee/Updater.swift +++ b/Sources/Markee/Updater.swift @@ -123,7 +123,8 @@ final class UpdateProgressPanel { panel.title = "Markee" panel.contentView = stack panel.center() - panel.standardWindowButton(.closeButton)?.isEnabled = false + // No close button: styleMask is `.titled` only (no `.closable`), so the + // panel can't be dismissed mid-update — which would orphan the download. } func show() { panel.makeKeyAndOrderFront(nil) } @@ -153,18 +154,15 @@ final class Updater { /// Once-a-day silent launch check. Throttled by a UserDefaults timestamp. func checkOnLaunch() { - let now = Date() if let last = UserDefaults.standard.object(forKey: Self.lastCheckKey) as? Date, - now.timeIntervalSince(last) < 24 * 60 * 60 { + Date().timeIntervalSince(last) < 24 * 60 * 60 { return } - UserDefaults.standard.set(now, forKey: Self.lastCheckKey) Task { await check(userInitiated: false) } } /// "Check for Updates…" menu action. Always reports its result. func checkForUpdatesMenuAction() { - UserDefaults.standard.set(Date(), forKey: Self.lastCheckKey) Task { await check(userInitiated: true) } } @@ -182,6 +180,10 @@ final class Updater { } return } + // Stamp the throttle only after a successful fetch, so a check that + // fails (offline, GitHub down) is retried on the next launch rather + // than suppressed for 24h. + UserDefaults.standard.set(Date(), forKey: Self.lastCheckKey) guard release.version > currentVersion else { if userInitiated { presentUpToDate() } @@ -199,6 +201,7 @@ final class Updater { nonisolated static func fetchLatestRelease(repo: String) async throws -> GitHubRelease { var request = URLRequest(url: URL(string: "https://api.github.com/repos/\(repo)/releases/latest")!) request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { throw UpdaterError.badResponse From 1eb08d7a13099ed50ef820eed670ca202f7556c6 Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 22:07:02 -0600 Subject: [PATCH 16/17] Fix custom titlebar vanishing after window focus loss/regain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppKit resets `titlebarAppearsTransparent` to `false` during window reactivation — a beat after the didBecomeKey/didBecomeMain notifications fire — which lets the opaque system titlebar overdraw the custom one. Re-applying on those notifications loses the race. WindowAccessor now KVO-observes `titlebarAppearsTransparent` and re-applies the window config synchronously whenever the property is knocked back to `false`, regardless of when AppKit does it. KVO callbacks run synchronously inside the property-set, so the value is corrected within the same runloop turn, before the window draws. Also: make `configureWindow` skip reassigning `styleMask` when it is already correct (avoids needless NSThemeFrame rebuilds now that the config re-applies on activation). Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Markee/MarkeeTitlebar.swift | 5 +- Sources/Markee/PreviewView.swift | 6 ++- Sources/Markee/WindowAccessor.swift | 54 ++++++++++++++++++--- Tests/MarkeeTests/WindowAccessorTests.swift | 48 ++++++++++++++++++ 4 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 Tests/MarkeeTests/WindowAccessorTests.swift diff --git a/Sources/Markee/MarkeeTitlebar.swift b/Sources/Markee/MarkeeTitlebar.swift index ddd2364..7fb202a 100644 --- a/Sources/Markee/MarkeeTitlebar.swift +++ b/Sources/Markee/MarkeeTitlebar.swift @@ -73,9 +73,8 @@ struct MarkeeTitlebar: View { }) } - /// Explicit toggle-icon color. Does NOT use `Color.secondary`: that - /// semantic color tracks window active-state and fails to recompute on the - /// resign-key → become-key cycle, rendering the glyph transparent. + /// Explicit appearance-aware color for the outline-toggle icon, matching + /// the titlebar's own `NSColor`-backed color treatment. private var toggleIconColor: Color { Color(nsColor: NSColor(name: nil) { appearance in if appearance.bestMatch(from: [.darkAqua, .vibrantDark, .accessibilityHighContrastDarkAqua]) != nil { diff --git a/Sources/Markee/PreviewView.swift b/Sources/Markee/PreviewView.swift index ae75328..71c9249 100644 --- a/Sources/Markee/PreviewView.swift +++ b/Sources/Markee/PreviewView.swift @@ -93,7 +93,11 @@ private struct PreviewContent: View { private func configureWindow(_ window: NSWindow) { window.titlebarAppearsTransparent = true window.titleVisibility = .hidden - window.styleMask.insert(.fullSizeContentView) + // Only assign styleMask when it actually changes — reassigning it + // forces an NSThemeFrame rebuild. This runs on every becomeKey. + if !window.styleMask.contains(.fullSizeContentView) { + window.styleMask.insert(.fullSizeContentView) + } window.isMovableByWindowBackground = false } } diff --git a/Sources/Markee/WindowAccessor.swift b/Sources/Markee/WindowAccessor.swift index f9917c6..2e473d7 100644 --- a/Sources/Markee/WindowAccessor.swift +++ b/Sources/Markee/WindowAccessor.swift @@ -1,18 +1,27 @@ import SwiftUI import AppKit -/// SwiftUI helper that surfaces the hosting `NSWindow` once it's attached. -/// Use as a `.background(...)` modifier. The closure runs once per window -/// attachment. +/// SwiftUI helper that surfaces the hosting `NSWindow` and keeps the +/// integrated-titlebar configuration applied to it. +/// +/// `onAttach` sets `titlebarAppearsTransparent`, `titleVisibility`, and +/// `.fullSizeContentView`. AppKit resets `titlebarAppearsTransparent` back to +/// `false` as part of window reactivation — and does so a beat *after* the +/// `didBecomeKey` / `didBecomeMain` notifications fire, so re-applying on +/// those notifications is clobbered milliseconds later. Instead, the +/// coordinator observes `titlebarAppearsTransparent` directly with KVO and +/// re-runs `onAttach` whenever the property is knocked back to `false`. KVO +/// change callbacks are synchronous, so the property is corrected within the +/// same runloop turn — before the window draws — leaving no visible flicker. struct WindowAccessor: NSViewRepresentable { let onAttach: (NSWindow) -> Void + func makeCoordinator() -> Coordinator { Coordinator() } + func makeNSView(context: Context) -> NSView { let view = NSView() DispatchQueue.main.async { - if let window = view.window { - onAttach(window) - } + context.coordinator.attach(to: view.window, onAttach: onAttach) } return view } @@ -20,8 +29,37 @@ struct WindowAccessor: NSViewRepresentable { func updateNSView(_ nsView: NSView, context: Context) { // If the view wasn't yet in a window during makeNSView, retry on update. DispatchQueue.main.async { - if let window = nsView.window { - onAttach(window) + context.coordinator.attach(to: nsView.window, onAttach: onAttach) + } + } + + /// Owns the KVO guard that keeps the titlebar configuration durable across + /// window reactivation. Internal (not private) so it can be unit-tested. + @MainActor + final class Coordinator { + private weak var observedWindow: NSWindow? + private var transparencyGuard: NSKeyValueObservation? + + /// Apply `onAttach` to `window` now, and re-apply it whenever AppKit + /// resets `titlebarAppearsTransparent` back to `false`. + func attach(to window: NSWindow?, onAttach: @escaping (NSWindow) -> Void) { + guard let window else { return } + onAttach(window) + + guard observedWindow !== window else { return } + observedWindow = window + // Assigning a new observation releases (and invalidates) any + // previous one, so re-attaching to a different window is safe. + // The guard captures this call's `onAttach`; for Markee that + // closure only writes window chrome, so holding the first one is + // equivalent to holding any later one. + transparencyGuard = window.observe( + \.titlebarAppearsTransparent, options: [.new] + ) { observed, change in + // React only to AppKit knocking it false; our own re-apply + // sets it true, which re-fires KVO and stops here. + guard change.newValue == false else { return } + onAttach(observed) } } } diff --git a/Tests/MarkeeTests/WindowAccessorTests.swift b/Tests/MarkeeTests/WindowAccessorTests.swift new file mode 100644 index 0000000..37b0d4c --- /dev/null +++ b/Tests/MarkeeTests/WindowAccessorTests.swift @@ -0,0 +1,48 @@ +import XCTest +import AppKit +@testable import Markee + +@MainActor +final class WindowAccessorTests: XCTestCase { + func test_attachAppliesConfigurationImmediately() { + let window = NSWindow() + let coordinator = WindowAccessor.Coordinator() + var applyCount = 0 + coordinator.attach(to: window) { _ in applyCount += 1 } + XCTAssertEqual(applyCount, 1) + } + + func test_restoresTitlebarTransparencyWhenAppKitClobbersIt() { + let window = NSWindow() + window.titlebarAppearsTransparent = true + let coordinator = WindowAccessor.Coordinator() + var applyCount = 0 + coordinator.attach(to: window) { w in + w.titlebarAppearsTransparent = true + applyCount += 1 + } + XCTAssertEqual(applyCount, 1) + + // Reproduce the bug: AppKit resets this during window reactivation. + window.titlebarAppearsTransparent = false + + XCTAssertTrue(window.titlebarAppearsTransparent, + "the KVO guard must restore titlebar transparency") + XCTAssertEqual(applyCount, 2, "onAttach must re-run when transparency is clobbered") + } + + func test_ignoresTransparencyChangesOnOtherWindows() { + let window = NSWindow() + let other = NSWindow() + other.titlebarAppearsTransparent = true + let coordinator = WindowAccessor.Coordinator() + var applyCount = 0 + coordinator.attach(to: window) { w in + w.titlebarAppearsTransparent = true + applyCount += 1 + } + + other.titlebarAppearsTransparent = false + XCTAssertEqual(applyCount, 1, "another window's change must not trigger re-apply") + } +} From 41b8329463650c83d986806ada13352c6c926a7d Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 22:09:52 -0600 Subject: [PATCH 17/17] Use non-optional Data(_:) in UpdaterTests JSON fixtures SwiftLint --strict (run in CI) flags `String.data(using:)!` via the non_optional_string_data_conversion rule. Switch the two JSON-fixture literals to `Data(string.utf8)`. Co-Authored-By: Claude Opus 4.7 (1M context) --- Tests/MarkeeTests/UpdaterTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/MarkeeTests/UpdaterTests.swift b/Tests/MarkeeTests/UpdaterTests.swift index 089b8d6..9653b71 100644 --- a/Tests/MarkeeTests/UpdaterTests.swift +++ b/Tests/MarkeeTests/UpdaterTests.swift @@ -43,14 +43,14 @@ final class GitHubReleaseTests: XCTestCase { { "name": "SomethingElse.txt", "browser_download_url": "https://example.com/dl/other.txt" } """ - return """ + return Data(""" { "tag_name": "v0.5.0", "html_url": "https://github.com/sethbang/markee/releases/tag/v0.5.0", "body": "Release notes here.", "assets": [ \(asset) ] } - """.data(using: .utf8)! + """.utf8) } func test_parsesValidPayload() { @@ -73,7 +73,7 @@ final class GitHubReleaseTests: XCTestCase { } func test_notesEmptyWhenBodyMissing() { - let json = """ + let json = Data(""" { "tag_name": "v0.5.0", "html_url": "https://github.com/sethbang/markee/releases/tag/v0.5.0", @@ -82,7 +82,7 @@ final class GitHubReleaseTests: XCTestCase { "browser_download_url": "https://example.com/dl/Markee.app.zip" } ] } - """.data(using: .utf8)! + """.utf8) let release = GitHubRelease(json: json) XCTAssertNotNil(release) XCTAssertEqual(release?.notes, "")