diff --git a/AGENTS.md b/AGENTS.md index 3b0b7ff3..ec25024b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1227,6 +1227,9 @@ matches `project.yml`. ## Releasing +Before the bump, add the release's entry to [`CHANGELOG.md`](./CHANGELOG.md) (format at its top) — +the What's New sheet shows it after the update. + Releases run entirely in GitHub Actions, so they can be driven from a browser or a chat session with no terminal, and without permission to dispatch a workflow. Start `release-bump` by pushing a marker branch at main — `git push origin main:refs/heads/release/v0.1.37`, which names the version and diff --git a/App/Blurt/Blurt.xcodeproj/project.pbxproj b/App/Blurt/Blurt.xcodeproj/project.pbxproj index 29113b63..a26e3264 100644 --- a/App/Blurt/Blurt.xcodeproj/project.pbxproj +++ b/App/Blurt/Blurt.xcodeproj/project.pbxproj @@ -210,6 +210,7 @@ 776B57FD593356ED09E879F2 /* rom1b-25-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 7FB45A274F0CC28BD0923634 /* rom1b-25-stop.m4a */; }; 77ADF5811E27F7E0BE4AC895 /* rom1a-22-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = BC17D9B01FDBC9667A7CD78A /* rom1a-22-start.m4a */; }; 787D291747E3FB4814EA7CC5 /* rom1a-13-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 482422C7CC15583570E3E869 /* rom1a-13-stop.m4a */; }; + 7AFD26BCDC9E4321D5AE5E8C /* WhatsNewSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1843FE274F84EA0E1BC322D /* WhatsNewSheet.swift */; }; 7CB462ED1AF7569FFA464439 /* rom1b-7-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = C2CB5D2E464BFD7F8BABE760 /* rom1b-7-stop.m4a */; }; 7EECAF13B83D447177BB90C9 /* juno-42-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 79FDAA58085D83CB336E9578 /* juno-42-start.m4a */; }; 7EF091D67076CE014C6AF679 /* juno-123-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 1467EAF745A940434A8AF18F /* juno-123-stop.m4a */; }; @@ -774,6 +775,7 @@ AD5F1BDEB1DB3658DDB96247 /* juno-70-start.m4a */ = {isa = PBXFileReference; path = "juno-70-start.m4a"; sourceTree = ""; }; AFCA36ECEFBA3D7C7B1AB1B7 /* rom1a-31-start.m4a */ = {isa = PBXFileReference; path = "rom1a-31-start.m4a"; sourceTree = ""; }; B0CB1B169A47290B83AD44C0 /* rom1b-20-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-20-stop.m4a"; sourceTree = ""; }; + B1843FE274F84EA0E1BC322D /* WhatsNewSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewSheet.swift; sourceTree = ""; }; B24D0EACA5DCC94CFCAD9753 /* rom1b-16-start.m4a */ = {isa = PBXFileReference; path = "rom1b-16-start.m4a"; sourceTree = ""; }; B293D9F11DB3C3D268E82E4A /* rom1b-25-start.m4a */ = {isa = PBXFileReference; path = "rom1b-25-start.m4a"; sourceTree = ""; }; B401FA111CA49F5A535366AC /* rom1b-15-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-15-stop.m4a"; sourceTree = ""; }; @@ -1390,6 +1392,7 @@ 37CBB4580F251B182A8B1937 /* SettingsWindowRoot.swift */, 30703F589CF213720C7E3B15 /* StyleRow.swift */, 3A50C85936C100F9E51CC2AD /* TextShortcutsSection.swift */, + B1843FE274F84EA0E1BC322D /* WhatsNewSheet.swift */, 30E57A9C9B934A2120381005 /* WizardController.swift */, 7F646CD07DBBA47F44CE0AD1 /* WizardView.swift */, F61744A7BF2B24E0C8078DE6 /* Steps */, @@ -1507,6 +1510,7 @@ buildConfigurationList = DD65B6019D3D8212517A7206 /* Build configuration list for PBXNativeTarget "Blurt" */; buildPhases = ( D3EE99FB1BF094322A1363EF /* Sources */, + 62B8938AA93F724D22671AAD /* Bundle CHANGELOG.md */, 97591D6CB8B701814C3DD210 /* Resources */, 5438DE37A9D7E9E5EEC56272 /* Frameworks */, B7E6843D4868DDAA607ADE70 /* Install to /Applications */, @@ -1972,6 +1976,26 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 62B8938AA93F724D22671AAD /* Bundle CHANGELOG.md */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/../../CHANGELOG.md", + ); + name = "Bundle CHANGELOG.md"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/CHANGELOG.md", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -e\nmkdir -p \"$(dirname \"$SCRIPT_OUTPUT_FILE_0\")\"\ncp \"$SCRIPT_INPUT_FILE_0\" \"$SCRIPT_OUTPUT_FILE_0\"\n"; + }; B7E6843D4868DDAA607ADE70 /* Install to /Applications */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -2051,6 +2075,7 @@ 1817ACD2F7D8F7AF3C9B1C7C /* UITestIdentifiers.swift in Sources */, E1CA5C692CB2A7E1BF231928 /* UITestSupport.swift in Sources */, AB6CEBDE7BE4CA99EB469D8E /* UpdateCheckModel.swift in Sources */, + 7AFD26BCDC9E4321D5AE5E8C /* WhatsNewSheet.swift in Sources */, 91C1257866F6E2D4EE65BD09 /* WindowAccessor.swift in Sources */, B844779F8427A1225C45A9FB /* WizardController.swift in Sources */, DD800E4A2657C87DD45E4AF8 /* WizardView.swift in Sources */, diff --git a/App/Blurt/Blurt/AppDelegate.swift b/App/Blurt/Blurt/AppDelegate.swift index 942f4363..c1ee8487 100644 --- a/App/Blurt/Blurt/AppDelegate.swift +++ b/App/Blurt/Blurt/AppDelegate.swift @@ -35,6 +35,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// the menu-bar item) still opens on General as before. var settingsOpensOnStyles = false + /// The What's New sheet's content while it's up, nil otherwise. Set by the + /// first launch after an update and by Help → What's New in Blurt; + /// `MainWindowRoot` presents it over the main window and clears it on dismiss. + var whatsNew: WhatsNewRequest? + /// Opens a window scene by id. The `openWindow` action lives in SwiftUI, so /// `MainWindowRoot` captures it here (in its launch-time `onAppear`) to give /// AppKit entry points — notably a Dock click with no open windows — a way to @@ -63,6 +68,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { openMainWindow() } + /// Help → What's New in Blurt: this version's notes, or the newest release's + /// before it. The main window is surfaced first — reopened if the user closed + /// it — since that is where the sheet hangs. + func showWhatsNew() { + guard let current = UpdateCheckModel.bundleVersion(), let entry = Changelog.bundled.entry(for: current) + else { return } + surfaceMainWindow() + whatsNew = WhatsNewRequest(entries: [entry]) + } + /// True once the launch-time activation has run. @ObservationIgnored private var didActivateAtLaunch = false @@ -157,6 +172,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let wizard = makeWizardController(coord: coord) self.wizardController = wizard + // The first launch after an update opens on its release notes. Same + // readiness gate as the update check below, and ahead of it, because + // whether a check has ever completed is how an install from before this + // sheet existed tells itself apart from a fresh one — see + // `Changelog.whatsNewAtLaunch`. + presentWhatsNewAtLaunch(isConfigured: wizard.isReady) + // A configured app checks for updates on its own shortly after launch — // at most once a day, and silent unless a newer release exists (see // `UpdateCheckModel.checkForUpdatesAtLaunch`). Still download-only: the @@ -236,6 +258,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if let persist { defaults.set(persist, forKey: key) } } + /// Applies the engine's launch decision: show the notes it picked (the main + /// window is presented at launch, so the sheet has a host), and record this + /// version as seen when it says to. + private func presentWhatsNewAtLaunch(isConfigured: Bool) { + guard let current = UpdateCheckModel.bundleVersion() else { return } + let store = LastSeenVersionStore() + let decision = Changelog.bundled.whatsNewAtLaunch( + current: current, lastSeen: store.lastSeen, isConfigured: isConfigured, + hasRunBefore: LastUpdateCheckStore().lastCheck != nil) + if decision.recordsCurrentVersion { store.lastSeen = current } + if !decision.entries.isEmpty { whatsNew = WhatsNewRequest(entries: decision.entries) } + } + /// Builds the setup wizard's controller. Created before the run loop presents /// any scene, so `MainWindowRoot` always finds it; on a configured launch its /// init reveals the overlay pill even though no window is shown, and diff --git a/App/Blurt/Blurt/Update/UpdateCheckModel.swift b/App/Blurt/Blurt/Update/UpdateCheckModel.swift index 2e6f34cd..afbfd9b0 100644 --- a/App/Blurt/Blurt/Update/UpdateCheckModel.swift +++ b/App/Blurt/Blurt/Update/UpdateCheckModel.swift @@ -200,7 +200,8 @@ final class UpdateCheckModel { } } - private static func bundleVersion() -> SemanticVersion? { + /// The running version, also read by the What's New sheet (`AppDelegate`). + static func bundleVersion() -> SemanticVersion? { let raw = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String return raw.flatMap(SemanticVersion.init) } diff --git a/App/Blurt/Blurt/Wizard/BlurtCommands.swift b/App/Blurt/Blurt/Wizard/BlurtCommands.swift index d7135904..fd1c46a7 100644 --- a/App/Blurt/Blurt/Wizard/BlurtCommands.swift +++ b/App/Blurt/Blurt/Wizard/BlurtCommands.swift @@ -21,8 +21,10 @@ struct BlurtCommands: Commands { // share intent, which moved out of that footer so its quietest line stops // reading as an ad. No ellipses: they open a page and ask nothing more, the // way Apple's own Help-menu links read. Each omits itself if its URL fails - // to build. + // to build. Above them, the release notes the first launch after an update + // shows, reopened over the main window (see `AppDelegate.showWhatsNew`). CommandGroup(replacing: .help) { + Button("What’s New in Blurt") { appDelegate.showWhatsNew() } if let url = BlurtLinks.reportBug { Link("Report a Bug", destination: url) } diff --git a/App/Blurt/Blurt/Wizard/MainWindowRoot.swift b/App/Blurt/Blurt/Wizard/MainWindowRoot.swift index 8ae98cf2..ba86d8ef 100644 --- a/App/Blurt/Blurt/Wizard/MainWindowRoot.swift +++ b/App/Blurt/Blurt/Wizard/MainWindowRoot.swift @@ -57,6 +57,8 @@ struct MainWindowRoot: View { WizardView(controller: controller, coordinator: coordinator) } } + // The release notes after an update, or from Help → What's New in Blurt. + .sheet(item: Bindable(appDelegate).whatsNew) { WhatsNewSheet(entries: $0.entries) } .onAppear { // Capture the open action so AppKit entry points (a Dock click with no // open windows, the missing-key hotkey nudge) can reopen the main diff --git a/App/Blurt/Blurt/Wizard/ReadyView.swift b/App/Blurt/Blurt/Wizard/ReadyView.swift index b8e52c73..f28cf631 100644 --- a/App/Blurt/Blurt/Wizard/ReadyView.swift +++ b/App/Blurt/Blurt/Wizard/ReadyView.swift @@ -211,6 +211,9 @@ enum BlurtLinks { /// The repo's GitHub issues. static let reportBug = URL(string: "https://github.com/AssemblyAI/blurt/issues") + /// Every release's notes on GitHub — the What's New sheet's "See all releases". + static let releases = URL(string: "https://github.com/AssemblyAI/blurt/releases") + /// LinkedIn's share intent. It takes only a URL to share, so this one /// carries no message. static let shareOnLinkedIn: URL? = { diff --git a/App/Blurt/Blurt/Wizard/WhatsNewSheet.swift b/App/Blurt/Blurt/Wizard/WhatsNewSheet.swift new file mode 100644 index 00000000..4cc0618a --- /dev/null +++ b/App/Blurt/Blurt/Wizard/WhatsNewSheet.swift @@ -0,0 +1,142 @@ +import BlurtEngine +import SwiftUI + +extension Changelog { + /// The release notes the app ships: the repo's `CHANGELOG.md`, copied into the + /// bundle by the "Bundle CHANGELOG.md" build phase (`project.yml`). Read once; + /// a missing file reads as no notes, which shows no sheet. + static let bundled = Changelog( + markdown: Bundle.main.url(forResource: "CHANGELOG", withExtension: "md") + .flatMap { try? String(contentsOf: $0, encoding: .utf8) } ?? "") +} + +/// One presentation of the What's New sheet, set on `AppDelegate.whatsNew`. +/// Identifiable so `.sheet(item:)` keeps showing it while the sheet animates out; +/// `nonisolated` so that conformance isn't main-actor-isolated by the target's +/// default isolation. +nonisolated struct WhatsNewRequest: Identifiable { + let id = UUID() + /// Newest first — more than one only after skipping a version. + let entries: [ChangelogEntry] +} + +/// The release notes shown on the first launch after an update, and again from +/// Help → What's New in Blurt. Which notes, and when, is the engine's call +/// (`Changelog.whatsNewAtLaunch`); the wording is `CHANGELOG.md`'s. +/// +/// Same shape as `APIKeyEditorSheet`: headline, content, then a button row with +/// the secondary action at the leading edge and the default one trailing, so +/// Return dismisses. +struct WhatsNewSheet: View { + let entries: [ChangelogEntry] + + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 22) { + // One release fits as is; several (after skipping versions) scroll rather + // than grow the sheet past the window. + if entries.count > 1 { + ScrollView { notes } + .frame(height: 480) + } else { + notes + } + + HStack { + if let url = BlurtLinks.releases { + Link("See all releases", destination: url) + .foregroundStyle(BlurtBrand.accent) + } + Spacer(minLength: 12) + Button("Continue") { dismiss() } + .glassButtonStyleCompat(prominent: true) + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 440) + } + + private var notes: some View { + VStack(alignment: .leading, spacing: 28) { + ForEach(entries, id: \.title) { ReleaseNotes(entry: $0) } + } + } +} + +/// One release: title and date, the headline features, then the fixes. +private struct ReleaseNotes: View { + let entry: ChangelogEntry + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 2) { + Text(entry.title) + .font(.title2.bold()) + if let date = entry.dateLabel { + Text(date) + .font(.callout) + .foregroundStyle(.secondary) + } + } + + if !entry.features.isEmpty { + VStack(alignment: .leading, spacing: 16) { + ForEach(entry.features, id: \.title) { FeatureRow(feature: $0) } + } + } + + if !entry.fixes.isEmpty { + fixes + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var fixes: some View { + VStack(alignment: .leading, spacing: 7) { + Divider() + .padding(.bottom, 9) + Text("Fixes") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 5) { + ForEach(entry.fixes, id: \.self) { fix in + HStack(alignment: .firstTextBaseline, spacing: 8) { + // The bullet is decoration, so VoiceOver skips it. + Text("•") + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + Text(fix) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .font(.callout) + } + } +} + +/// A headline feature: its symbol in the brand accent, like the settings rows' +/// glyphs, beside a bold title and a secondary sentence. +private struct FeatureRow: View { + let feature: ChangelogEntry.Feature + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: feature.symbolName) + .font(.title2) + .foregroundStyle(BlurtBrand.accent) + .frame(width: 28) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(feature.title) + .fontWeight(.semibold) + Text(feature.summary) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} diff --git a/App/Blurt/project.yml b/App/Blurt/project.yml index ee4a9ff2..0e04d98d 100644 --- a/App/Blurt/project.yml +++ b/App/Blurt/project.yml @@ -143,6 +143,19 @@ targets: dependencies: - package: BlurtEngine product: BlurtEngine + # The What's New sheet's release notes (`Changelog.bundled`). CHANGELOG.md + # lives at the repo root, outside this target's sources, so it's copied into + # the bundle's Resources after compiling rather than listed as a resource. + postCompileScripts: + - name: Bundle CHANGELOG.md + script: | + set -e + mkdir -p "$(dirname "$SCRIPT_OUTPUT_FILE_0")" + cp "$SCRIPT_INPUT_FILE_0" "$SCRIPT_OUTPUT_FILE_0" + inputFiles: + - $(SRCROOT)/../../CHANGELOG.md + outputFiles: + - $(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/CHANGELOG.md postBuildScripts: - name: Install to /Applications script: | diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..61b93d41 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ + + + +# Changelog + +## 0.1.57 — 2026-09-29 + +### Text shortcuts + + + +Say "work email" and Blurt types the whole address. Save the phrases you repeat, like a calendar link or a sign-off, in Settings. + +### fn to dictate + + + +If fn is easier to reach on your keyboard, make it your trigger key. + +### A calmer main window + + + +We rebuilt the main window and every Settings screen. Same controls, less on screen. + +### Fixes + +- Blurt now runs natively on Intel Macs, too. +- Dictation now pastes into Codex and other apps built on Electron. +- Better spacing in Google Docs after you click or type between dictations. +- Report a bug right from the main window footer. diff --git a/Sources/BlurtEngine/Config/DefaultsKey.swift b/Sources/BlurtEngine/Config/DefaultsKey.swift index 7850d08a..17b4d427 100644 --- a/Sources/BlurtEngine/Config/DefaultsKey.swift +++ b/Sources/BlurtEngine/Config/DefaultsKey.swift @@ -43,6 +43,8 @@ enum DefaultsKey: String, CaseIterable { case overlayCustomOriginX = "OverlayCustomOriginX" case overlayCustomOriginY = "OverlayCustomOriginY" case lastUpdateCheck = "LastUpdateCheck" + /// The app version the What's New sheet last dealt with (`LastSeenVersionStore`). + case lastSeenVersion = "LastSeenVersion" /// The CoreAudio UID of the input device dictation is pinned to, or empty for /// "same as system" (`MicDeviceStore`). case micDeviceUID = "MicDeviceUID" diff --git a/Sources/BlurtEngine/Update/Changelog.swift b/Sources/BlurtEngine/Update/Changelog.swift new file mode 100644 index 00000000..f74523ab --- /dev/null +++ b/Sources/BlurtEngine/Update/Changelog.swift @@ -0,0 +1,216 @@ +import Foundation + +/// One release's notes, as written in the repo's `CHANGELOG.md`: a few headline +/// features and a list of fixes. What the What's New sheet renders. +public struct ChangelogEntry: Equatable, Sendable { + /// One headline feature: an SF Symbol, a bold title, and one sentence or two. + public struct Feature: Equatable, Sendable { + public let title: String + public let summary: String + /// From the feature's `` line, or `Changelog.defaultSymbolName`. + public let symbolName: String + } + + let version: SemanticVersion + /// Midnight UTC of the heading's `YYYY-MM-DD`, or nil when the heading has none. + let date: Date? + public let features: [Feature] + public let fixes: [String] + + /// "What’s new in Blurt 0.1.57" — named through `UpdateAlertContent.appVersionLabel` + /// so the sheet and the update alerts can't word the version two ways. + public var title: String { "What’s new in \(UpdateAlertContent.appVersionLabel(version))" } + + /// "September 29, 2026" in the user's locale, or nil when the heading has no date. + public var dateLabel: String? { formattedDate(locale: .autoupdatingCurrent, calendar: .autoupdatingCurrent) } + + /// Formatted in UTC, the zone the date was parsed in: the user's own zone would + /// show the day before anywhere west of Greenwich. + func formattedDate(locale: Locale, calendar: Calendar) -> String? { + date?.formatted(Date.FormatStyle(date: .long, time: .omitted, locale: locale, calendar: calendar, timeZone: .gmt)) + } +} + +/// The release notes parsed from `CHANGELOG.md`, the single hand-written source of +/// truth (bundled into the app at build time; see `project.yml`). Engine-side for +/// the reason `UpdateAlertContent` is: the format is a rule, and the shell that +/// reads the file has no test target. +/// +/// The format, also documented at the top of the file itself: +/// +/// ```markdown +/// ## 0.1.57 — 2026-09-29 +/// ### Text shortcuts +/// +/// One paragraph saying what it does for the user. +/// ### Fixes +/// - One line per fix. +/// ``` +/// +/// Anything else — the file's `#` title, other HTML comments, a heading whose +/// version doesn't parse — is skipped rather than rejected: a malformed entry +/// costs its own notes, never the sheet. `ChangelogTests` parses the real file, +/// so a slip there fails the build instead. +public struct Changelog: Sendable { + /// At most this many headline features per release; extras are dropped. The + /// sheet is laid out for a short list, and the cap keeps the notes to the + /// changes worth interrupting someone for — the rest belong under Fixes. + static let featureLimit = 3 + + /// The glyph a feature gets when its section names none. + static let defaultSymbolName = "sparkles" + + /// Every entry, newest version first, whatever order the file lists them in. + let entries: [ChangelogEntry] + + public init(markdown: String) { + var parser = Parser() + for line in markdown.components(separatedBy: .newlines) { + parser.consume(line.trimmingCharacters(in: .whitespaces)) + } + entries = parser.finish().sorted { $0.version > $1.version } + } + + /// The notes for `version`, or for the newest release before it when that + /// version has no entry of its own — what Help → What's New in Blurt shows. + public func entry(for version: SemanticVersion) -> ChangelogEntry? { + entries.first { $0.version <= version } + } +} + +extension Changelog { + /// A line-at-a-time reader for the format on `Changelog`. + private struct Parser { + private enum Section { + case none + case feature + case fixes + } + + private struct FeatureDraft { + let title: String + var lines: [String] = [] + var symbol: String? + } + + private struct EntryDraft { + let version: SemanticVersion + let date: Date? + var features: [FeatureDraft] = [] + var fixes: [String] = [] + } + + private var finished: [ChangelogEntry] = [] + private var draft: EntryDraft? + private var section = Section.none + private var inComment = false + + mutating func consume(_ line: String) { + if consumeComment(line) { return } + if line.hasPrefix("## ") { + startEntry(heading: String(line.dropFirst(3))) + } else if line.hasPrefix("### ") { + startSection(title: line.dropFirst(4).trimmingCharacters(in: .whitespaces)) + } else if line.hasPrefix("#") { + section = .none + } else if !line.isEmpty { + addText(line) + } + } + + mutating func finish() -> [ChangelogEntry] { + closeEntry() + return finished + } + + /// Skips HTML comments, single- or multi-line (the format notes at the top of + /// the file are one), picking up a feature's `` on the way. + /// True when the line was part of a comment. + private mutating func consumeComment(_ line: String) -> Bool { + if inComment { + if line.contains("-->") { inComment = false } + return true + } + guard line.hasPrefix("") { + inComment = true + } + return true + } + + /// `0.1.57 — 2026-09-29`: the version, then an optional date after a dash. + private mutating func startEntry(heading: String) { + closeEntry() + section = .none + let parts = heading.split(separator: " ", maxSplits: 1) + guard let token = parts.first, let version = SemanticVersion(String(token)) else { return } + let rest = parts.count > 1 ? parts[1].trimmingCharacters(in: CharacterSet(charactersIn: "—–- ")) : "" + draft = EntryDraft(version: version, date: Self.date(from: rest)) + } + + private mutating func startSection(title: String) { + guard draft != nil else { return } + if title.caseInsensitiveCompare("Fixes") == .orderedSame { + section = .fixes + } else { + draft?.features.append(FeatureDraft(title: title)) + section = .feature + } + } + + private mutating func addText(_ line: String) { + switch section { + case .none: + return + case .feature: + guard let last = draft?.features.indices.last else { return } + draft?.features[last].lines.append(line) + case .fixes: + if line.hasPrefix("- ") || line.hasPrefix("* ") { + draft?.fixes.append(line.dropFirst(2).trimmingCharacters(in: .whitespaces)) + } else if let last = draft?.fixes.indices.last { + // A bullet wrapped onto a second line continues the one above it. + draft?.fixes[last] += " " + line + } + } + } + + private mutating func closeEntry() { + guard let draft else { return } + self.draft = nil + let features = draft.features.prefix(Changelog.featureLimit).map { + ChangelogEntry.Feature( + title: $0.title, summary: $0.lines.joined(separator: " "), + symbolName: $0.symbol ?? Changelog.defaultSymbolName) + } + // An entry with nothing to say would open an empty sheet. + guard !features.isEmpty || !draft.fixes.isEmpty else { return } + finished.append( + ChangelogEntry(version: draft.version, date: draft.date, features: features, fixes: draft.fixes)) + } + + /// `` → `text.bubble`. + private static func symbolName(in line: String) -> String? { + guard line.hasSuffix("-->") else { return nil } + let body = line.dropFirst(4).dropLast(3).trimmingCharacters(in: .whitespaces) + guard body.hasPrefix("symbol:") else { return nil } + return body.dropFirst("symbol:".count).trimmingCharacters(in: .whitespaces).trimmedNonEmpty() + } + + /// `2026-09-29` → midnight UTC that day; nil for anything else. + private static func date(from text: String) -> Date? { + let fields = text.split(separator: "-") + let parts = fields.compactMap { Int($0) } + guard fields.count == 3, parts.count == 3 else { return nil } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .gmt + let components = DateComponents(year: parts[0], month: parts[1], day: parts[2]) + guard components.isValidDate(in: calendar) else { return nil } + return calendar.date(from: components) + } + } +} diff --git a/Sources/BlurtEngine/Update/LastSeenVersionStore.swift b/Sources/BlurtEngine/Update/LastSeenVersionStore.swift new file mode 100644 index 00000000..6db56945 --- /dev/null +++ b/Sources/BlurtEngine/Update/LastSeenVersionStore.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Persists the last app version whose launch the What's New sheet has dealt +/// with, so the next launch can tell an update from an ordinary relaunch (see +/// `Changelog.whatsNewAtLaunch`). Same shape as `LastUpdateCheckStore`; its key +/// is a `DefaultsKey` case, so every "reset to a clean state" sweep clears it too. +public struct LastSeenVersionStore { + /// Public so the reset sweep can name it. + public static var defaultsKey: String { DefaultsKey.lastSeenVersion.key } + + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + /// The version last recorded, or `nil` if none ever was (or what's stored + /// doesn't parse, which reads the same way). + public var lastSeen: SemanticVersion? { + get { defaults.string(forKey: Self.defaultsKey).flatMap(SemanticVersion.init) } + nonmutating set { defaults.set(newValue?.description, forKey: Self.defaultsKey) } + } +} diff --git a/Sources/BlurtEngine/Update/WhatsNewLaunch.swift b/Sources/BlurtEngine/Update/WhatsNewLaunch.swift new file mode 100644 index 00000000..9087f6d9 --- /dev/null +++ b/Sources/BlurtEngine/Update/WhatsNewLaunch.swift @@ -0,0 +1,54 @@ +/// What a launch does about the What's New sheet: which release notes to show, +/// and whether to record the running version as seen (`LastSeenVersionStore`). +/// +/// Engine-side for the `AutomaticUpdateCheck` reason: it is a rule, and the +/// shell that applies it has no test target. Decided by +/// `Changelog.whatsNewAtLaunch(current:lastSeen:isConfigured:hasRunBefore:)`. +public struct WhatsNewLaunch: Equatable, Sendable { + /// The notes to show, newest first. Empty means no sheet. + public let entries: [ChangelogEntry] + /// Whether to store the running version as the last one seen. + public let recordsCurrentVersion: Bool +} + +extension Changelog { + /// The launch decision, given the running version and the last one the user saw. + /// + /// - The same version as last time, or an older one (a downgrade), does nothing: + /// the notes have been seen, and a downgrade recorded now would replay them. + /// - Setup unfinished shows nothing, for the reason the update check waits + /// (no sheet thrown over the wizard). It still records a **fresh install**'s + /// version — which is what keeps a first run from ever reading as an + /// upgrade — but otherwise leaves the stored version alone, so an upgrade + /// whose launch finds setup broken (a revoked permission) shows its notes + /// once setup is back. + /// - Upgraded: every entry newer than the last version seen, up to and including + /// this one. None in that range (a build with no entry of its own) shows + /// nothing but still records. + /// - Nothing stored on a configured launch means the install predates this + /// feature, or is a fresh one: every launch of a build that has it records a + /// version, and a first run is never configured. `hasRunBefore` tells them + /// apart — the shell passes whether an update check has ever completed, which + /// a configured Blurt does within a day of its first launch. An upgrade then + /// shows this version's notes (there is no telling which version it came + /// from); anything else just records. + public func whatsNewAtLaunch( + current: SemanticVersion, lastSeen: SemanticVersion?, isConfigured: Bool, hasRunBefore: Bool + ) -> WhatsNewLaunch { + if let lastSeen, lastSeen >= current { + return WhatsNewLaunch(entries: [], recordsCurrentVersion: false) + } + guard isConfigured else { + return WhatsNewLaunch(entries: [], recordsCurrentVersion: lastSeen == nil && !hasRunBefore) + } + guard let lastSeen else { + guard hasRunBefore, let latest = entry(for: current) else { + return WhatsNewLaunch(entries: [], recordsCurrentVersion: true) + } + return WhatsNewLaunch(entries: [latest], recordsCurrentVersion: true) + } + return WhatsNewLaunch( + entries: entries.filter { $0.version > lastSeen && $0.version <= current }, + recordsCurrentVersion: true) + } +} diff --git a/Tests/BlurtEngineTests/ChangelogTests.swift b/Tests/BlurtEngineTests/ChangelogTests.swift new file mode 100644 index 00000000..3da87157 --- /dev/null +++ b/Tests/BlurtEngineTests/ChangelogTests.swift @@ -0,0 +1,182 @@ +import Foundation +import Testing + +@testable import BlurtEngine + +/// The `CHANGELOG.md` format the What's New sheet reads. The shell that loads the +/// file has no test target, so a parse that quietly drops an entry would only +/// show up as a sheet that never appears — hence the suite parses the real file +/// too. +@Suite("Changelog") +struct ChangelogTests { + private static let sample = """ + + + # Changelog + + ## 0.1.57 — 2026-09-29 + + ### Text shortcuts + + + + Say "work email" and Blurt types the whole address. + Save the phrases you repeat in Settings. + + ### fn to dictate + + If fn is easier to reach, make it your trigger key. + + ### Fixes + + - Blurt now runs natively on Intel Macs, too. + - Better spacing in Google Docs after you click + or type between dictations. + + ## 0.1.56 — 2026-09-22 + + ### Fixes + + - An older fix. + """ + + private func version(_ string: String) throws -> SemanticVersion { + try #require(SemanticVersion(string)) + } + + @Test("parses features, symbols and fixes, newest first") + func parsesEntries() throws { + let entries = Changelog(markdown: Self.sample).entries + let versions = entries.map(\.version.description) + #expect(versions == ["0.1.57", "0.1.56"]) + + let latest = try #require(entries.first) + let titles = latest.features.map(\.title) + let symbols = latest.features.map(\.symbolName) + #expect(titles == ["Text shortcuts", "fn to dictate"]) + #expect(symbols == ["text.bubble", Changelog.defaultSymbolName]) + // A paragraph's lines join into one summary. + #expect( + latest.features.first?.summary + == "Say \"work email\" and Blurt types the whole address. Save the phrases you repeat in Settings.") + // A wrapped bullet continues the one above it rather than starting another. + #expect( + latest.fixes == [ + "Blurt now runs natively on Intel Macs, too.", + "Better spacing in Google Docs after you click or type between dictations.", + ]) + let oldest = try #require(entries.last) + #expect(oldest.features.isEmpty) + #expect(oldest.fixes == ["An older fix."]) + } + + @Test("entries are ordered by version, not by where the file lists them") + func sortsByVersion() { + let markdown = """ + ## 0.1.9 + - ignored: no section yet + ### Fixes + - nine + ## 0.1.10 + ### Fixes + - ten + """ + let entries = Changelog(markdown: markdown).entries + let versions = entries.map(\.version.description) + #expect(versions == ["0.1.10", "0.1.9"]) + #expect(entries.last?.fixes == ["nine"]) + } + + @Test("more than the feature limit keeps only the first ones") + func capsFeatures() { + let features = (1...5).map { "### Feature \($0)\nSummary \($0)." }.joined(separator: "\n") + let entries = Changelog(markdown: "## 1.0.0\n" + features).entries + let titles = entries.first?.features.map(\.title) + #expect(titles == ["Feature 1", "Feature 2", "Feature 3"]) + #expect(Changelog.featureLimit == 3) + } + + @Test("a heading that doesn't parse, or an entry with nothing in it, is skipped") + func skipsMalformedEntries() { + let markdown = """ + ## Unreleased + ### Fixes + - not shipped yet + ## 1.0.0 — 2026-01-01 + ## 0.9.0 + ### Fixes + - kept + """ + // "Unreleased" isn't a version, and 1.0.0 has no features or fixes: neither + // may open a sheet. + let versions = Changelog(markdown: markdown).entries.map(\.version.description) + #expect(versions == ["0.9.0"]) + #expect(Changelog(markdown: "").entries.isEmpty) + } + + @Test("the heading's date formats as a long date on that day, whatever the time zone") + func formatsDate() throws { + let entry = try #require(Changelog(markdown: Self.sample).entries.first) + let locale = Locale(identifier: "en_US") + var calendar = Calendar(identifier: .gregorian) + // A zone well west of UTC: formatting in it would give September 28. + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + #expect(entry.formattedDate(locale: locale, calendar: calendar) == "September 29, 2026") + + // No date, and a date that doesn't exist: both read as undated. + let undated = Changelog(markdown: "## 1.0.0\n### Fixes\n- one\n## 0.9.0 — 2026-02-30\n### Fixes\n- two") + #expect(undated.entries.count == 2) + for undatedEntry in undated.entries { + #expect(undatedEntry.dateLabel == nil) + } + } + + @Test("the title names the version the way the update alerts do") + func titlesTheVersion() { + let entry = Changelog(markdown: Self.sample).entries.first + #expect(entry?.title == "What’s new in \(HostIdentity.current.productName) 0.1.57") + } + + @Test("the Help menu's entry is this version's, or the newest before it") + func entryForVersion() throws { + let changelog = Changelog(markdown: Self.sample) + let exact = try changelog.entry(for: version("0.1.57")) + let newer = try changelog.entry(for: version("0.1.58")) + let older = try changelog.entry(for: version("0.1.56")) + let tooOld = try changelog.entry(for: version("0.1.55")) + #expect(exact?.version.description == "0.1.57") + #expect(newer?.version.description == "0.1.57") + #expect(older?.version.description == "0.1.56") + #expect(tooOld == nil) + } + + @Test("the repo's CHANGELOG.md parses, every entry dated and within the feature limit") + func realChangelogParses() throws { + // The file the app bundles (see project.yml): Tests/BlurtEngineTests/ → repo root. + let url = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("CHANGELOG.md") + let markdown = try String(contentsOf: url, encoding: .utf8) + let entries = Changelog(markdown: markdown).entries + #expect(!entries.isEmpty) + // One heading per release, so each version appears once. + let versions = entries.map(\.version.description) + #expect(Set(versions).count == versions.count) + for entry in entries { + #expect(entry.date != nil, "\(entry.version) has no date") + for feature in entry.features { + #expect(!feature.summary.isEmpty, "\(entry.version): \(feature.title) has no summary") + } + } + // The cap drops extras silently, so check the file itself: no release may + // list more headline features than the sheet will show. + for release in markdown.components(separatedBy: "\n## ").dropFirst() { + let sections = release.components(separatedBy: "\n### ").dropFirst() + let featureCount = sections.filter { !$0.hasPrefix("Fixes") }.count + #expect(featureCount <= Changelog.featureLimit, "too many features under ## \(release.prefix(20))") + } + } +} diff --git a/Tests/BlurtEngineTests/LastSeenVersionStoreTests.swift b/Tests/BlurtEngineTests/LastSeenVersionStoreTests.swift new file mode 100644 index 00000000..ab621b68 --- /dev/null +++ b/Tests/BlurtEngineTests/LastSeenVersionStoreTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing + +@testable import BlurtEngine + +/// The version the What's New sheet last dealt with. Round-tripping is the whole +/// job; what matters beyond it is that "never recorded" stays distinguishable +/// from a version, since the launch decision treats the two differently. +@Suite("LastSeenVersionStore") +struct LastSeenVersionStoreTests { + @Test("an unset store has seen no version") + func unsetIsNil() { + #expect(LastSeenVersionStore(defaults: freshDefaults()).lastSeen == nil) + } + + @Test("a version round-trips, and clearing it returns to never-recorded") + func roundTrips() throws { + let store = LastSeenVersionStore(defaults: freshDefaults()) + let version = try #require(SemanticVersion("0.1.57")) + store.lastSeen = version + #expect(store.lastSeen == version) + store.lastSeen = nil + #expect(store.lastSeen == nil) + } + + @Test("a stored value that isn't a version reads as never recorded") + func garbageIsNil() { + let defaults = freshDefaults() + defaults.set("not a version", forKey: LastSeenVersionStore.defaultsKey) + #expect(LastSeenVersionStore(defaults: defaults).lastSeen == nil) + } +} diff --git a/Tests/BlurtEngineTests/PersistedSettingsTests.swift b/Tests/BlurtEngineTests/PersistedSettingsTests.swift index 19d1f822..76da17b0 100644 --- a/Tests/BlurtEngineTests/PersistedSettingsTests.swift +++ b/Tests/BlurtEngineTests/PersistedSettingsTests.swift @@ -35,6 +35,9 @@ struct PersistedSettingsTests { // The launch update check's throttle: left out of the sweep, a UI-test run // (or a "clean install") would inherit yesterday's stamp and skip the check. #expect(PersistedSettings.allDefaultsKeys.contains(LastUpdateCheckStore.defaultsKey)) + // The What's New sheet's last-seen version: left out, a reset "clean install" + // would still carry the version history of the install it replaced. + #expect(PersistedSettings.allDefaultsKeys.contains(LastSeenVersionStore.defaultsKey)) // The pinned microphone: left out, a reset "clean install" would keep // recording from a previously pinned device. #expect(PersistedSettings.allDefaultsKeys.contains(MicDeviceStore.defaultsKey)) @@ -45,10 +48,10 @@ struct PersistedSettingsTests { @Test("the roster carries no stale or duplicate keys") func rosterHasNoStrays() { - // Exactly the eleven known stores' keys (OverlayOriginStore contributes two, + // Exactly the twelve known stores' keys (OverlayOriginStore contributes two, // StyleProfileStore three): a removed store must leave the roster in the same // change, and a key listed twice would hint at a copy-paste slip. - #expect(PersistedSettings.allDefaultsKeys.count == 14) + #expect(PersistedSettings.allDefaultsKeys.count == 15) #expect(Set(PersistedSettings.allDefaultsKeys).count == PersistedSettings.allDefaultsKeys.count) } @@ -73,13 +76,14 @@ struct PersistedSettingsTests { OverlayOriginStore.xDefaultsKey, OverlayOriginStore.yDefaultsKey, LastUpdateCheckStore.defaultsKey, + LastSeenVersionStore.defaultsKey, MicDeviceStore.defaultsKey, TextShortcutStore.defaultsKey, ] #expect(storeKeys == Set(DefaultsKey.allCases.map(\.key))) // No two stores sharing a slot — the Set above would have quietly absorbed a // collision, and two stores on one key means each overwrites the other. - #expect(storeKeys.count == 14) + #expect(storeKeys.count == 15) } @Test("resetAll clears every roster key and leaves unrelated ones alone") diff --git a/Tests/BlurtEngineTests/WhatsNewLaunchTests.swift b/Tests/BlurtEngineTests/WhatsNewLaunchTests.swift new file mode 100644 index 00000000..578f2ff4 --- /dev/null +++ b/Tests/BlurtEngineTests/WhatsNewLaunchTests.swift @@ -0,0 +1,102 @@ +import Testing + +@testable import BlurtEngine + +/// Whether a launch shows the What's New sheet, and what it records. The shell +/// that applies this has no test target, and every way to get it wrong is quiet: +/// a sheet that never appears, one that greets a brand-new user, or one that +/// comes back on every launch. +@Suite("WhatsNewLaunch") +struct WhatsNewLaunchTests { + private static let changelog = Changelog( + markdown: """ + ## 0.1.59 — 2026-10-13 + ### Fixes + - fifty-nine + ## 0.1.58 — 2026-10-06 + ### Fixes + - fifty-eight + ## 0.1.57 — 2026-09-29 + ### Fixes + - fifty-seven + """) + + private func version(_ string: String) throws -> SemanticVersion { + try #require(SemanticVersion(string)) + } + + private func decide( + current: String, lastSeen: String?, isConfigured: Bool = true, hasRunBefore: Bool = true + ) throws -> WhatsNewLaunch { + try Self.changelog.whatsNewAtLaunch( + current: version(current), lastSeen: lastSeen.map { try version($0) }, isConfigured: isConfigured, + hasRunBefore: hasRunBefore) + } + + private func shownVersions(_ decision: WhatsNewLaunch) -> [String] { + decision.entries.map(\.version.description) + } + + @Test("an update shows every entry since the last version seen, newest first, then records") + func upgradeShowsTheRange() throws { + let decision = try decide(current: "0.1.59", lastSeen: "0.1.57") + #expect(shownVersions(decision) == ["0.1.59", "0.1.58"]) + #expect(decision.recordsCurrentVersion) + + let oneStep = try decide(current: "0.1.58", lastSeen: "0.1.57") + #expect(shownVersions(oneStep) == ["0.1.58"]) + } + + @Test("an update to a build with no entry in range shows nothing but still records") + func upgradeWithoutAnEntry() throws { + let decision = try decide(current: "0.1.60", lastSeen: "0.1.59") + #expect(decision.entries.isEmpty) + #expect(decision.recordsCurrentVersion) + } + + @Test("the same version as last time, or an older one, does nothing") + func relaunchAndDowngrade() throws { + let relaunch = try decide(current: "0.1.58", lastSeen: "0.1.58") + #expect(relaunch == WhatsNewLaunch(entries: [], recordsCurrentVersion: false)) + // Recording the downgrade would replay 0.1.59's notes on the way back up. + let downgrade = try decide(current: "0.1.58", lastSeen: "0.1.59") + #expect(downgrade == WhatsNewLaunch(entries: [], recordsCurrentVersion: false)) + } + + @Test("a fresh install's first run records its version and shows nothing") + func freshInstall() throws { + // A first run is never configured and has never checked for updates. + let decision = try decide(current: "0.1.58", lastSeen: nil, isConfigured: false, hasRunBefore: false) + #expect(decision == WhatsNewLaunch(entries: [], recordsCurrentVersion: true)) + // So its next launch, now set up, is an ordinary relaunch. + let next = try decide(current: "0.1.58", lastSeen: "0.1.58") + #expect(next.entries.isEmpty) + } + + @Test("an update whose launch finds setup unfinished waits for a configured launch") + func unconfiguredUpgradeWaits() throws { + let decision = try decide(current: "0.1.59", lastSeen: "0.1.57", isConfigured: false) + #expect(decision == WhatsNewLaunch(entries: [], recordsCurrentVersion: false)) + // An install from before this feature, likewise: no baseline recorded yet. + let untracked = try decide(current: "0.1.59", lastSeen: nil, isConfigured: false, hasRunBefore: true) + #expect(untracked == WhatsNewLaunch(entries: [], recordsCurrentVersion: false)) + } + + @Test("an install from before this feature shows this version's notes once") + func untrackedUpgrade() throws { + let decision = try decide(current: "0.1.58", lastSeen: nil) + #expect(shownVersions(decision) == ["0.1.58"]) + #expect(decision.recordsCurrentVersion) + // A build with no entry of its own falls back to the newest one before it. + let noEntry = try decide(current: "0.1.60", lastSeen: nil) + #expect(shownVersions(noEntry) == ["0.1.59"]) + } + + @Test("a configured launch with nothing stored and no sign of an earlier run just records") + func configuredWithoutHistory() throws { + // A reinstall that inherited its key and permissions, or a UI-test launch + // (which resets the defaults but forces the ready state). + let decision = try decide(current: "0.1.58", lastSeen: nil, hasRunBefore: false) + #expect(decision == WhatsNewLaunch(entries: [], recordsCurrentVersion: true)) + } +}