Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions App/Blurt/Blurt.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -774,6 +775,7 @@
AD5F1BDEB1DB3658DDB96247 /* juno-70-start.m4a */ = {isa = PBXFileReference; path = "juno-70-start.m4a"; sourceTree = "<group>"; };
AFCA36ECEFBA3D7C7B1AB1B7 /* rom1a-31-start.m4a */ = {isa = PBXFileReference; path = "rom1a-31-start.m4a"; sourceTree = "<group>"; };
B0CB1B169A47290B83AD44C0 /* rom1b-20-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-20-stop.m4a"; sourceTree = "<group>"; };
B1843FE274F84EA0E1BC322D /* WhatsNewSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewSheet.swift; sourceTree = "<group>"; };
B24D0EACA5DCC94CFCAD9753 /* rom1b-16-start.m4a */ = {isa = PBXFileReference; path = "rom1b-16-start.m4a"; sourceTree = "<group>"; };
B293D9F11DB3C3D268E82E4A /* rom1b-25-start.m4a */ = {isa = PBXFileReference; path = "rom1b-25-start.m4a"; sourceTree = "<group>"; };
B401FA111CA49F5A535366AC /* rom1b-15-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-15-stop.m4a"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1390,6 +1392,7 @@
37CBB4580F251B182A8B1937 /* SettingsWindowRoot.swift */,
30703F589CF213720C7E3B15 /* StyleRow.swift */,
3A50C85936C100F9E51CC2AD /* TextShortcutsSection.swift */,
B1843FE274F84EA0E1BC322D /* WhatsNewSheet.swift */,
30E57A9C9B934A2120381005 /* WizardController.swift */,
7F646CD07DBBA47F44CE0AD1 /* WizardView.swift */,
F61744A7BF2B24E0C8078DE6 /* Steps */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 */,
Expand Down
35 changes: 35 additions & 0 deletions App/Blurt/Blurt/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion App/Blurt/Blurt/Update/UpdateCheckModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 3 additions & 1 deletion App/Blurt/Blurt/Wizard/BlurtCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 2 additions & 0 deletions App/Blurt/Blurt/Wizard/MainWindowRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions App/Blurt/Blurt/Wizard/ReadyView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? = {
Expand Down
142 changes: 142 additions & 0 deletions App/Blurt/Blurt/Wizard/WhatsNewSheet.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
13 changes: 13 additions & 0 deletions App/Blurt/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<!--
Release notes for Blurt's "What's new" sheet. This file is the source: the app
bundles it at build time and shows the new version's entry on the first launch
after an update (Help > What's New in Blurt shows it again). Parsed by
`Changelog` in the engine; `ChangelogTests` fails the build on an entry it can't
read.

Add an entry before bumping the version, newest at the top, shaped like the
0.1.57 one below:

- A `## <version> — <YYYY-MM-DD>` heading.
- Up to 3 headline features, each a `### <title>` heading followed by one
paragraph saying what it does for the user. Any more are not shown; the rest
belongs under Fixes.
- Optionally, under a feature's heading, a comment line naming its SF Symbol
(`symbol: text.bubble`, written as an HTML comment like the ones below).
Without one the feature gets `sparkles`.
- Optionally, a `### Fixes` heading with one `- ` bullet per fix.

Comments like this one are skipped.
-->
<!-- markdownlint-configure-file { "MD024": { "siblings_only": true } } -->

# Changelog

## 0.1.57 — 2026-09-29

### Text shortcuts

<!-- symbol: text.bubble -->

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

<!-- symbol: keyboard -->

If fn is easier to reach on your keyboard, make it your trigger key.

### A calmer main window

<!-- symbol: macwindow -->

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.
Loading
Loading