Compare commits

..
17 Commits
Author SHA1 Message Date
coder 83710bc1fe Document one shared runner per host [skip ci] 2026-09-10 09:05:04 +02:00
coder 7584863392 fix: isolate CI Keychain access from the production app
Build / macos (push) Successful in 27s
2026-09-10 00:33:30 +02:00
coder 31584792e6 fix: launch CI smoke app through macOS Launch Services
Build / macos (push) Failing after 1m3s
2026-09-10 00:25:09 +02:00
coder 58fbe56298 ci: retire the superseded GitHub release workflows
Build / macos (push) Failing after 1m22s
2026-09-10 00:08:31 +02:00
coder 4d87288f2f ci: stage the Mac launch check on the internal volume
Build / macos (push) Successful in 26s
2026-09-09 23:56:02 +02:00
coder cda28cc6be ci: preserve evidence when the Mac smoke launch stalls
Build / macos (push) Failing after 1m1s
2026-09-09 23:51:29 +02:00
coder df23c84474 ci: release notarized Meetingnotes updates through Gitea
Build / macos (push) Failing after 57s
2026-09-09 23:49:37 +02:00
coder 2508c77f17 ci: build and smoke test main on the Gitea Mac runner
Build / macos (push) Failing after 1m11s
2026-09-09 23:45:46 +02:00
coder 607e1236f7 fix: allow recording while meetings process 2026-08-27 16:05:23 +02:00
coder 204857cdd9 fix: infer actual system audio clock rate 2026-08-27 13:02:00 +02:00
coder 3a9ad8fe0c fix: preserve diagnostics when merging meetings 2026-08-26 17:04:15 +02:00
coder 299986ab00 fix: preserve transcription failures and audio diagnostics 2026-08-26 16:43:43 +02:00
coder 66447b2510 fix: count Core Audio callback frames explicitly 2026-08-26 14:36:32 +02:00
coder 9f3805733b fix: capture system audio as mono 2026-08-26 11:56:27 +02:00
coder 4e8fdc7603 fix: use delivered system audio format 2026-08-26 10:22:59 +02:00
coder aed34f6d28 fix: debounce automatic meeting stops 2026-08-26 10:13:39 +02:00
coder 79b2f61dfe feat: merge split meeting continuations 2026-08-26 10:09:21 +02:00
26 changed files with 1119 additions and 505 deletions
@@ -9,7 +9,7 @@ on:
jobs: jobs:
macos: macos:
runs-on: macos-15 runs-on: macos-arm64
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build Meetingnotes - name: Build Meetingnotes
@@ -22,6 +22,7 @@ jobs:
-derivedDataPath "$RUNNER_TEMP/DerivedData" -derivedDataPath "$RUNNER_TEMP/DerivedData"
ARCHS="arm64 x86_64" ARCHS="arm64 x86_64"
ONLY_ACTIVE_ARCH=NO ONLY_ACTIVE_ARCH=NO
PRODUCT_BUNDLE_IDENTIFIER=net.jamesbone.meetingnotes.ci
CODE_SIGNING_ALLOWED=NO CODE_SIGNING_ALLOWED=NO
build build
- name: Sign test build - name: Sign test build
@@ -34,28 +35,15 @@ jobs:
codesign -d --entitlements :- "$app_path" 2>&1 \ codesign -d --entitlements :- "$app_path" 2>&1 \
| grep -q 'com.apple.security.network.server' | grep -q 'com.apple.security.network.server'
- name: Smoke test local API - name: Smoke test local API
run: | run: python3 scripts/smoke_test_api.py "$RUNNER_TEMP/DerivedData/Build/Products/Release/Meetingnotes.app"
defaults write net.jamesbone.meetingnotes muteDeckAPIEnabled -bool true
defaults write net.jamesbone.meetingnotes muteDeckAPIPort -int 19880
"$RUNNER_TEMP/DerivedData/Build/Products/Release/Meetingnotes.app/Contents/MacOS/Meetingnotes" >"$RUNNER_TEMP/meetingnotes.log" 2>&1 &
app_pid=$!
trap 'kill "$app_pid" 2>/dev/null || true' EXIT
for _ in {1..20}; do
if curl -fsS http://127.0.0.1:19880/api/info >"$RUNNER_TEMP/api-info.json"; then
break
fi
sleep 1
done
grep -q '"name":"MeetingDebrief"' "$RUNNER_TEMP/api-info.json"
test "$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:19880/api/recording/status)" = "401"
- name: Package test build - name: Package test build
if: always()
run: | run: |
app_path="$RUNNER_TEMP/DerivedData/Build/Products/Release/Meetingnotes.app" app_path="$RUNNER_TEMP/DerivedData/Build/Products/Release/Meetingnotes.app"
ditto -c -k --sequesterRsrc --keepParent \ ditto -c -k --sequesterRsrc --keepParent \
"$app_path" "$RUNNER_TEMP/Meetingnotes-macOS.zip" "$app_path" "$RUNNER_TEMP/Meetingnotes-macOS.zip"
- name: Upload test build - name: Upload test build
if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: Meetingnotes-macOS-${{ github.sha }} name: Meetingnotes-macOS-${{ github.sha }}
+47
View File
@@ -0,0 +1,47 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: Version from MARKETING_VERSION, without the v prefix
required: true
type: string
permissions:
contents: write
concurrency:
group: meetingnotes-release
cancel-in-progress: false
jobs:
release:
runs-on: macos-arm64
env:
VERSION: ${{ inputs.version }}
RELEASE_BASE_URL: https://git.jamesbone.net/coder/meetingnotes
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
steps:
- name: Require main
run: test "$GITHUB_REF" = refs/heads/main
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build, sign, and notarize release
timeout-minutes: 45
env:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: python3 scripts/with_signing_identity.py scripts/package_release.sh
- name: Preserve signed release artifacts
uses: actions/upload-artifact@v4
with:
name: meetingnotes-signed-release-${{ github.run_id }}
path: ${{ runner.temp }}/meetingnotes-release/release
if-no-files-found: error
retention-days: 30
- name: Publish Gitea release
env:
GITEA_SERVER_URL: ${{ github.server_url }}
GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/publish_gitea_release.py
-92
View File
@@ -1,92 +0,0 @@
name: Finalize Notarization (manual)
on:
workflow_dispatch:
inputs:
release_run_id:
description: Release workflow run ID; leave blank to use the latest pending run
required: false
type: string
permissions:
actions: read
contents: write
concurrency:
group: meetingnotes-finalize-release
cancel-in-progress: false
jobs:
finalize:
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
runs-on: macos-15
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v7
- name: Validate release secrets
run: |
for variable in APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD SPARKLE_PRIVATE_KEY; do
if [[ -z "${!variable:-}" ]]; then
echo "Missing GitHub Actions secret: $variable" >&2
exit 1
fi
done
- name: Download pending signed build
id: submission
env:
MANUAL_RUN_ID: ${{ inputs.release_run_id }}
COMPLETED_RUN_ID: ${{ github.event.workflow_run.id }}
run: |
PENDING_DIR="$RUNNER_TEMP/meetingnotes-pending"
candidate_ids=()
if [[ -n "${MANUAL_RUN_ID:-}" ]]; then
candidate_ids+=("$MANUAL_RUN_ID")
elif [[ -n "${COMPLETED_RUN_ID:-}" ]]; then
candidate_ids+=("$COMPLETED_RUN_ID")
else
while IFS= read -r run_id; do
candidate_ids+=("$run_id")
done < <(gh run list --repo "$GITHUB_REPOSITORY" --workflow Release --status success --limit 20 --json databaseId --jq '.[].databaseId')
fi
for run_id in "${candidate_ids[@]}"; do
if [[ ! "$run_id" =~ ^[0-9]+$ ]]; then
echo "Invalid release run ID: $run_id" >&2
exit 1
fi
artifact_name="meetingnotes-notarization-$run_id"
artifact_count=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/artifacts" \
--jq "[.artifacts[] | select(.name == \"$artifact_name\" and .expired == false)] | length")
if [[ "$artifact_count" == 0 ]]; then
continue
fi
rm -rf "$PENDING_DIR"
mkdir -p "$PENDING_DIR"
gh run download "$run_id" --repo "$GITHUB_REPOSITORY" --name "$artifact_name" --dir "$PENDING_DIR"
version=$(<"$PENDING_DIR/version")
if gh release view "v$version" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
continue
fi
echo "found=true" >> "$GITHUB_OUTPUT"
echo "run_id=$run_id" >> "$GITHUB_OUTPUT"
echo "Using release submission from workflow run $run_id"
exit 0
done
echo "found=false" >> "$GITHUB_OUTPUT"
echo "No pending release submission was found"
- name: Check notarization and publish when accepted
if: steps.submission.outputs.found == 'true'
run: scripts/finalize_release.sh
-88
View File
@@ -1,88 +0,0 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: Version from MARKETING_VERSION, without the v prefix
required: true
type: string
permissions:
contents: write
concurrency:
group: meetingnotes-release
cancel-in-progress: false
jobs:
release:
runs-on: macos-15
env:
VERSION: ${{ inputs.version }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Validate release secrets
env:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
for variable in APPLE_CERTIFICATE_P12 APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD SPARKLE_PRIVATE_KEY; do
if [[ -z "${!variable:-}" ]]; then
echo "Missing GitHub Actions secret: $variable" >&2
exit 1
fi
done
- name: Import Developer ID certificate
uses: apple-actions/import-codesign-certs@v7
with:
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE_P12 }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Locate Developer ID identity
run: |
signing_identity=$(security find-identity -v -p codesigning | awk -F '"' '/Developer ID Application/{print $2; exit}')
if [[ -z "$signing_identity" ]]; then
echo "The .p12 does not contain a Developer ID Application identity" >&2
exit 1
fi
echo "SIGNING_IDENTITY=$signing_identity" >> "$GITHUB_ENV"
- name: Build, sign, and notarize release
timeout-minutes: 30
run: scripts/package_release.sh
- name: Preserve signed release artifacts
uses: actions/upload-artifact@v4
with:
name: meetingnotes-signed-release-${{ github.run_id }}
path: ${{ runner.temp }}/meetingnotes-release/release
if-no-files-found: error
retention-days: 30
- name: Publish signed GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="v$VERSION"
if gh release view "$tag" >/dev/null 2>&1; then
gh release upload "$tag" \
"$RUNNER_TEMP/meetingnotes-release/release/Meetingnotes-$VERSION.zip" \
"$RUNNER_TEMP/meetingnotes-release/release/appcast.xml" \
--clobber
else
gh release create "$tag" \
"$RUNNER_TEMP/meetingnotes-release/release/Meetingnotes-$VERSION.zip" \
"$RUNNER_TEMP/meetingnotes-release/release/appcast.xml" \
--target "$GITHUB_SHA" \
--title "Meetingnotes $VERSION" \
--generate-notes
fi
+4 -4
View File
@@ -276,7 +276,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 35; CURRENT_PROJECT_VERSION = 45;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX; DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -290,7 +290,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.23; MARKETING_VERSION = 1.1.33;
ONLY_ACTIVE_ARCH = NO; ONLY_ACTIVE_ARCH = NO;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
@@ -312,7 +312,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 35; CURRENT_PROJECT_VERSION = 45;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX; DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -326,7 +326,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.23; MARKETING_VERSION = 1.1.33;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
+22 -4
View File
@@ -22,7 +22,8 @@ Implemented:
- Meeting search functionality - Meeting search functionality
- Abilty to edit system prompt - Abilty to edit system prompt
- Select any compatible Coder model for transcription and note generation - Select any compatible Coder model for transcription and note generation
- Automatic start and stop from MuteDeck through a compatible local API - Automatic start and stop from MuteDeck through a compatible local API, with a 10-second reconnect grace period
- Merge an automatically split continuation back into its previous meeting
- Auto updates - Auto updates
- Text formatting - Text formatting
- Different note templates - Different note templates
@@ -45,7 +46,7 @@ Later:
## Releasing a New Version ## Releasing a New Version
Production releases are Developer ID signed, notarized by Apple, published to Production releases are Developer ID signed, notarized by Apple, published to
GitHub Releases, and signed for Sparkle auto-updates. [Gitea Releases](https://git.jamesbone.net/coder/meetingnotes/releases), and signed for Sparkle auto-updates.
### Release Process ### Release Process
@@ -67,12 +68,12 @@ GitHub Releases, and signed for Sparkle auto-updates.
2. Commit and push the version change to `main`. 2. Commit and push the version change to `main`.
3. Run the `Release` workflow from GitHub Actions and enter the version without 3. Run the `Release` workflow from Gitea Actions on `main` and enter the version without
the `v` prefix. The workflow signs and notarizes the app, generates the the `v` prefix. The workflow signs and notarizes the app, generates the
signed appcast, creates the version tag, and publishes both release assets. signed appcast, creates the version tag, and publishes both release assets.
The app checks The app checks
`https://github.com/superdooper86/meetingnotes/releases/latest/download/appcast.xml` `https://git.jamesbone.net/coder/meetingnotes/releases/download/latest/appcast.xml`
and installs later releases automatically through Sparkle. and installs later releases automatically through Sparkle.
### Recovering Meetings ### Recovering Meetings
@@ -81,3 +82,20 @@ The first Developer ID signed build may not automatically inherit data from an
older ad-hoc signed build. In Settings, use **Import Meetings...** and select the older ad-hoc signed build. In Settings, use **Import Meetings...** and select the
old `Meetings` folder. After this one-time transition, the stable signing old `Meetings` folder. After this one-time transition, the stable signing
identity keeps the same sandbox container across updates. identity keeps the same sandbox container across updates.
### Build runner and GitHub transition
`.gitea/workflows/build.yml` builds universal macOS artifacts for pushes and pull
requests to `main`. The shared account-scoped `mac-mini` runner uses the
`macos-arm64` label. Smoke tests use a separate CI bundle identifier and temporary
launch preferences and an internal-volume staging directory. Keychain services
follow the bundle identifier, so CI never reads production credentials; the smoke
test removes its isolated API token before and after each launch. Release jobs import the original Developer ID certificate
into a temporary keychain and keep the original Sparkle signing key in Gitea
Actions secrets. They never publish from a development branch.
Version 1.1.33 moves the embedded update feed to Gitea. After its notarized archive
is verified, replace the `appcast.xml` asset on the last GitHub release with the
new appcast. Existing installations discover the Gitea download through that old
GitHub feed; after installing it, they check Gitea directly. Keep the old GitHub
repository and its release appcast available for installations that update later.
+1 -1
View File
@@ -14,7 +14,7 @@
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Meetingnotes needs access to your microphone for transcription.</string> <string>Meetingnotes needs access to your microphone for transcription.</string>
<key>SUFeedURL</key> <key>SUFeedURL</key>
<string>https://github.com/superdooper86/meetingnotes/releases/latest/download/appcast.xml</string> <string>https://git.jamesbone.net/coder/meetingnotes/releases/download/latest/appcast.xml</string>
<key>SUPublicEDKey</key> <key>SUPublicEDKey</key>
<string>9ZuN9G9ERB3Qoyyd/4FsF+6LMUv5jzAGP26OXAHBiW0=</string> <string>9ZuN9G9ERB3Qoyyd/4FsF+6LMUv5jzAGP26OXAHBiW0=</string>
<key>SUEnableAutomaticChecks</key> <key>SUEnableAutomaticChecks</key>
+379 -44
View File
@@ -20,6 +20,27 @@ private enum RecoveryTranscriptionError: LocalizedError {
} }
} }
private struct SystemCaptureDiagnostics {
var tapAdvertisedFormat = "unavailable"
var aggregateInputFormat = "unavailable"
var selectedInputFormat = "unavailable"
var targetFormat = "unavailable"
var selectedInputSampleRate: Double?
var targetSampleRate: Double?
var inputRateInference = "unavailable"
var firstBufferLayout: String?
var callbackCount: UInt64 = 0
var inputFrameCount: UInt64 = 0
var outputFrameCount: UInt64 = 0
var discardedCallbackCount: UInt64 = 0
var firstSampleTime: Double?
var lastSampleTime: Double?
var firstHostTime: UInt64?
var lastHostTime: UInt64?
var firstCallbackAt: Date?
var lastCallbackAt: Date?
}
/// Captures microphone and system audio locally, then sends completed files to Coder. /// Captures microphone and system audio locally, then sends completed files to Coder.
@MainActor @MainActor
final class AudioManager: NSObject, ObservableObject { final class AudioManager: NSObject, ObservableObject {
@@ -50,8 +71,9 @@ final class AudioManager: NSObject, ObservableObject {
private var micAudioURL: URL? private var micAudioURL: URL?
private var systemAudioURL: URL? private var systemAudioURL: URL?
private var recordingStartedAt = Date() private var recordingStartedAt = Date()
private var systemDiagnostics = SystemCaptureDiagnostics()
private override init() { override init() {
super.init() super.init()
observeAudioEngine() observeAudioEngine()
} }
@@ -67,6 +89,7 @@ final class AudioManager: NSObject, ObservableObject {
sessionID = UUID() sessionID = UUID()
self.meetingID = meetingID self.meetingID = meetingID
recordingStartedAt = Date() recordingStartedAt = Date()
systemDiagnostics = SystemCaptureDiagnostics()
do { do {
try prepareAudioFiles() try prepareAudioFiles()
startMicrophoneTap() startMicrophoneTap()
@@ -79,6 +102,7 @@ final class AudioManager: NSObject, ObservableObject {
func stopRecordingAndTranscribe() async -> [TranscriptChunk] { func stopRecordingAndTranscribe() async -> [TranscriptChunk] {
let completedMeetingID = meetingID let completedMeetingID = meetingID
let completedSessionID = sessionID
let captureStartedAt = recordingStartedAt let captureStartedAt = recordingStartedAt
let files = stopCaptureAndCloseFiles() let files = stopCaptureAndCloseFiles()
isProcessing = true isProcessing = true
@@ -86,11 +110,20 @@ final class AudioManager: NSObject, ObservableObject {
isProcessing = false isProcessing = false
} }
repairHalfDurationSystemWAVIfNeeded(in: files) let preRepairFileSummaries = files.map(audioFileSummary)
let repairApplied = repairHalfDurationSystemWAVIfNeeded(in: files)
let completedFiles = files.compactMap { $0 } let completedFiles = files.compactMap { $0 }
let audioFolder = preserveAudioFiles(completedFiles, meetingID: completedMeetingID) let audioFolder = preserveAudioFiles(completedFiles, meetingID: completedMeetingID)
let transcriptionFiles = preservedAudioFiles(files, in: audioFolder) let transcriptionFiles = preservedAudioFiles(files, in: audioFolder)
lastRecoveryAudioFolderName = audioFolder?.lastPathComponent lastRecoveryAudioFolderName = audioFolder?.lastPathComponent
writeCaptureDiagnostics(
sessionID: completedSessionID,
captureStartedAt: captureStartedAt,
preRepairFileSummaries: preRepairFileSummaries,
repairedFiles: transcriptionFiles,
repairApplied: repairApplied,
audioFolder: audioFolder
)
if let mismatch = captureDurationMismatch(in: transcriptionFiles) { if let mismatch = captureDurationMismatch(in: transcriptionFiles) {
let recoveryMessage = audioFolder == nil let recoveryMessage = audioFolder == nil
? " The audio remains in the app's temporary folder." ? " The audio remains in the app's temporary folder."
@@ -133,17 +166,29 @@ final class AudioManager: NSObject, ObservableObject {
isProcessing = true isProcessing = true
defer { isProcessing = false } defer { isProcessing = false }
let model = UserDefaultsManager.shared.transcriptionModel let model = UserDefaultsManager.shared.transcriptionModel
let micURL = recoveryFiles.first(where: { $0.source == .mic })?.url let sessions = Dictionary(grouping: recoveryFiles) { recoverySessionKey(for: $0.url) }
let systemURL = recoveryFiles.first(where: { $0.source == .system })?.url .values
async let micResult = transcribe(micURL, model: model, diarization: false) .map { files in
async let systemResult = transcribe(systemURL, model: model, diarization: true) (files: files, startedAt: recoveryCaptureStartedAt(for: files, fallback: captureStartedAt))
let (micTranscription, systemTranscription) = await (micResult, systemResult) }
let results = [micTranscription, systemTranscription] .sorted { $0.startedAt < $1.startedAt }
let (chunks, failures) = buildTranscriptChunks(
from: results, var chunks: [TranscriptChunk] = []
captureStartedAt: captureStartedAt, var failures: [String] = []
existingChunks: [] for session in sessions {
) let micURL = session.files.first(where: { $0.source == .mic })?.url
let systemURL = session.files.first(where: { $0.source == .system })?.url
async let micResult = transcribe(micURL, model: model, diarization: false)
async let systemResult = transcribe(systemURL, model: model, diarization: true)
let (micTranscription, systemTranscription) = await (micResult, systemResult)
let result = buildTranscriptChunks(
from: [micTranscription, systemTranscription],
captureStartedAt: session.startedAt,
existingChunks: chunks
)
chunks = result.0
failures.append(contentsOf: result.1)
}
if !failures.isEmpty { if !failures.isEmpty {
throw RecoveryTranscriptionError.requestFailed(failures.joined(separator: "; ")) throw RecoveryTranscriptionError.requestFailed(failures.joined(separator: "; "))
@@ -154,6 +199,28 @@ final class AudioManager: NSObject, ObservableObject {
return chunks return chunks
} }
private func recoverySessionKey(for url: URL) -> String {
let name = url.deletingPathExtension().lastPathComponent
if name.hasSuffix("-mic") { return String(name.dropLast(4)) }
if name.hasSuffix("-system") { return String(name.dropLast(7)) }
return name
}
private func recoveryCaptureStartedAt(
for files: [(url: URL, source: AudioSource)],
fallback: Date
) -> Date {
let estimatedStarts = files.compactMap { file -> Date? in
guard let duration = audioDuration(at: file.url),
let values = try? file.url.resourceValues(forKeys: [.contentModificationDateKey, .creationDateKey]),
let finishedAt = values.contentModificationDate ?? values.creationDate else {
return nil
}
return finishedAt.addingTimeInterval(-duration)
}
return estimatedStarts.min() ?? fallback
}
func cancelRecording() { func cancelRecording() {
cancelCapture(removeFiles: true) cancelCapture(removeFiles: true)
lastRecoveryAudioFolderName = nil lastRecoveryAudioFolderName = nil
@@ -371,18 +438,76 @@ final class AudioManager: NSObject, ObservableObject {
advertisedInputFormat.sampleRate > 0 else { advertisedInputFormat.sampleRate > 0 else {
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported system audio format"]) throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported system audio format"])
} }
systemDiagnostics.tapAdvertisedFormat = streamDescriptionSummary(tap.tapAdvertisedStreamDescription)
systemDiagnostics.aggregateInputFormat = streamDescriptionSummary(tap.aggregateInputStreamDescription)
systemDiagnostics.targetFormat = audioFormatSummary(targetFormat)
systemDiagnostics.targetSampleRate = targetFormat.sampleRate
var inputFormat: AVAudioFormat? var inputFormat: AVAudioFormat?
var converter: AVAudioConverter? var converter: AVAudioConverter?
try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in var pendingBuffer: AVAudioPCMBuffer?
var pendingInputTime: AudioTimeStamp?
var pendingNow: AudioTimeStamp?
try tap.run(on: tapQueue) { [weak self] inNow, inputData, inInputTime, _, _ in
guard let self else { return } guard let self else { return }
if inputFormat == nil { if inputFormat == nil {
guard let callbackFormat = self.inputFormat(
for: inputData,
sampleRate: advertisedInputFormat.sampleRate
),
let currentBuffer = self.copyAudioBuffer(from: inputData, format: callbackFormat) else {
self.systemDiagnostics.discardedCallbackCount += 1
return
}
guard let previousBuffer = pendingBuffer,
let previousInputTime = pendingInputTime,
let previousNow = pendingNow else {
pendingBuffer = currentBuffer
pendingInputTime = inInputTime.pointee
pendingNow = inNow.pointee
return
}
let selectedSampleRate = self.effectiveInputSampleRate(
advertisedSampleRate: advertisedInputFormat.sampleRate,
previousFrameLength: previousBuffer.frameLength,
previousTimestamp: previousInputTime,
currentTimestamp: inInputTime.pointee,
previousHostTimestamp: previousNow,
currentHostTimestamp: inNow.pointee
)
inputFormat = self.inputFormat( inputFormat = self.inputFormat(
for: inputData, for: inputData,
advertisedFormat: advertisedInputFormat sampleRate: selectedSampleRate
) )
if let inputFormat { if let inputFormat {
self.systemDiagnostics.selectedInputFormat = self.audioFormatSummary(inputFormat)
self.systemDiagnostics.selectedInputSampleRate = inputFormat.sampleRate
converter = AVAudioConverter(from: inputFormat, to: targetFormat) converter = AVAudioConverter(from: inputFormat, to: targetFormat)
} }
guard let inputFormat, let converter else { return }
self.processAudioBuffer(
{ self.copyAudioBuffer(from: previousBuffer.audioBufferList, format: inputFormat) },
converter: converter,
targetFormat: targetFormat,
source: .system,
callbackTimestamp: previousInputTime,
ioTimestamp: pendingNow
)
pendingBuffer = nil
pendingInputTime = nil
pendingNow = nil
self.processAudioBuffer(
{ self.copyAudioBuffer(from: currentBuffer.audioBufferList, format: inputFormat) },
converter: converter,
targetFormat: targetFormat,
source: .system,
callbackTimestamp: inInputTime.pointee,
ioTimestamp: inNow.pointee
)
return
} }
guard let inputFormat, let converter else { return } guard let inputFormat, let converter else { return }
// The tap queue is serial. Reusing the converter preserves its // The tap queue is serial. Reusing the converter preserves its
@@ -391,7 +516,9 @@ final class AudioManager: NSObject, ObservableObject {
{ self.copyAudioBuffer(from: inputData, format: inputFormat) }, { self.copyAudioBuffer(from: inputData, format: inputFormat) },
converter: converter, converter: converter,
targetFormat: targetFormat, targetFormat: targetFormat,
source: .system source: .system,
callbackTimestamp: inInputTime.pointee,
ioTimestamp: inNow.pointee
) )
} invalidationHandler: { [weak self] _ in } invalidationHandler: { [weak self] _ in
guard let self, self.isRecording else { return } guard let self, self.isRecording else { return }
@@ -401,7 +528,7 @@ final class AudioManager: NSObject, ObservableObject {
private func inputFormat( private func inputFormat(
for inputData: UnsafePointer<AudioBufferList>, for inputData: UnsafePointer<AudioBufferList>,
advertisedFormat: AVAudioFormat sampleRate: Double
) -> AVAudioFormat? { ) -> AVAudioFormat? {
let buffers = UnsafeMutableAudioBufferListPointer( let buffers = UnsafeMutableAudioBufferListPointer(
UnsafeMutablePointer(mutating: inputData) UnsafeMutablePointer(mutating: inputData)
@@ -409,38 +536,106 @@ final class AudioManager: NSObject, ObservableObject {
let channelCount = buffers.reduce(UInt32(0)) { $0 + $1.mNumberChannels } let channelCount = buffers.reduce(UInt32(0)) { $0 + $1.mNumberChannels }
guard channelCount > 0 else { return nil } guard channelCount > 0 else { return nil }
// HAL tap metadata can advertise interleaved stereo while the callback // HAL I/O proc samples use the canonical Float32 representation. The
// supplies one mono buffer per channel (or the reverse). Constructing a // tap's stream description can advertise a different common format;
// PCM buffer with that mismatched layout halves its frame count and // using that to interpret the callback bytes can halve the frame count
// produces 2x-speed system audio. The callback's AudioBufferList is the // (for example, treating four-byte Float32 samples as eight-byte
// authoritative layout for the memory we are copying. // Float64 samples). The callback's AudioBufferList is authoritative for
// its channel layout, while its sample rate comes from the input stream.
let isInterleaved = buffers.count == 1 && channelCount > 1 let isInterleaved = buffers.count == 1 && channelCount > 1
return AVAudioFormat( return AVAudioFormat(
commonFormat: advertisedFormat.commonFormat, commonFormat: .pcmFormatFloat32,
sampleRate: advertisedFormat.sampleRate, sampleRate: sampleRate,
channels: AVAudioChannelCount(channelCount), channels: AVAudioChannelCount(channelCount),
interleaved: isInterleaved interleaved: isInterleaved
) )
} }
private func effectiveInputSampleRate(
advertisedSampleRate: Double,
previousFrameLength: AVAudioFrameCount,
previousTimestamp: AudioTimeStamp,
currentTimestamp: AudioTimeStamp,
previousHostTimestamp: AudioTimeStamp,
currentHostTimestamp: AudioTimeStamp
) -> Double {
let sampleTimeDelta = currentTimestamp.mSampleTime - previousTimestamp.mSampleTime
guard advertisedSampleRate > 0, previousFrameLength > 0 else {
systemDiagnostics.inputRateInference = "advertised (invalid advertised rate or frame count)"
return advertisedSampleRate
}
guard currentHostTimestamp.mHostTime > previousHostTimestamp.mHostTime else {
systemDiagnostics.inputRateInference = "advertised (host timestamps unavailable)"
return advertisedSampleRate
}
let hostTimeDelta = currentHostTimestamp.mHostTime - previousHostTimestamp.mHostTime
let hostNanoseconds = AudioConvertHostTimeToNanos(hostTimeDelta)
guard hostNanoseconds > 0 else {
systemDiagnostics.inputRateInference = "advertised (host-time conversion failed)"
return advertisedSampleRate
}
let inferredSampleRate = Double(previousFrameLength) * 1_000_000_000 / Double(hostNanoseconds)
let plausibleRange = (advertisedSampleRate * 0.25)...(advertisedSampleRate * 1.25)
guard inferredSampleRate.isFinite, plausibleRange.contains(inferredSampleRate) else {
systemDiagnostics.inputRateInference = String(
format: "advertised (invalid host inference %.3f from %u frames / %llu ns; sampleTimeDelta=%.3f)",
inferredSampleRate,
previousFrameLength,
hostNanoseconds,
sampleTimeDelta
)
return advertisedSampleRate
}
let commonSampleRates: [Double] = [8_000, 11_025, 12_000, 16_000, 22_050, 24_000, 32_000, 44_100, 48_000, 88_200, 96_000]
let selectedSampleRate = commonSampleRates
.min(by: { abs($0 - inferredSampleRate) < abs($1 - inferredSampleRate) })
.flatMap { abs($0 - inferredSampleRate) / $0 <= 0.01 ? $0 : nil }
?? inferredSampleRate
systemDiagnostics.inputRateInference = String(
format: "source=hostTime,advertised=%.3f,inferred=%.3f,selected=%.3f,previousFrames=%u,hostNanoseconds=%llu,sampleTimeDelta=%.3f",
advertisedSampleRate,
inferredSampleRate,
selectedSampleRate,
previousFrameLength,
hostNanoseconds,
sampleTimeDelta
)
return selectedSampleRate
}
private func copyAudioBuffer( private func copyAudioBuffer(
from inputData: UnsafePointer<AudioBufferList>, from inputData: UnsafePointer<AudioBufferList>,
format: AVAudioFormat format: AVAudioFormat
) -> AVAudioPCMBuffer? { ) -> AVAudioPCMBuffer? {
guard let borrowedBuffer = AVAudioPCMBuffer(
pcmFormat: format,
bufferListNoCopy: inputData,
deallocator: nil
), borrowedBuffer.frameLength > 0,
let ownedBuffer = AVAudioPCMBuffer(
pcmFormat: format,
frameCapacity: borrowedBuffer.frameLength
) else { return nil }
ownedBuffer.frameLength = borrowedBuffer.frameLength
let sourceBuffers = UnsafeMutableAudioBufferListPointer( let sourceBuffers = UnsafeMutableAudioBufferListPointer(
UnsafeMutablePointer(mutating: inputData) UnsafeMutablePointer(mutating: inputData)
) )
if systemDiagnostics.firstBufferLayout == nil {
systemDiagnostics.firstBufferLayout = sourceBuffers.enumerated().map { index, buffer in
"buffer\(index):channels=\(buffer.mNumberChannels),bytes=\(buffer.mDataByteSize)"
}.joined(separator: "; ")
}
let frameLengths = sourceBuffers.compactMap { source -> AVAudioFrameCount? in
let bytesPerFrame = Int(source.mNumberChannels) * MemoryLayout<Float32>.size
guard bytesPerFrame > 0,
Int(source.mDataByteSize).isMultiple(of: bytesPerFrame) else { return nil }
return AVAudioFrameCount(Int(source.mDataByteSize) / bytesPerFrame)
}
guard frameLengths.count == sourceBuffers.count,
let frameLength = frameLengths.first,
frameLength > 0,
frameLengths.allSatisfy({ $0 == frameLength }),
let ownedBuffer = AVAudioPCMBuffer(
pcmFormat: format,
frameCapacity: frameLength
) else { return nil }
// Set the frame count explicitly instead of asking AVAudioPCMBuffer to
// infer it from potentially inconsistent tap metadata.
ownedBuffer.frameLength = frameLength
let destinationBuffers = UnsafeMutableAudioBufferListPointer( let destinationBuffers = UnsafeMutableAudioBufferListPointer(
ownedBuffer.mutableAudioBufferList ownedBuffer.mutableAudioBufferList
) )
@@ -463,20 +658,44 @@ final class AudioManager: NSObject, ObservableObject {
_ inputBufferProvider: () -> AVAudioPCMBuffer?, _ inputBufferProvider: () -> AVAudioPCMBuffer?,
converter: AVAudioConverter, converter: AVAudioConverter,
targetFormat: AVAudioFormat, targetFormat: AVAudioFormat,
source: AudioSource source: AudioSource,
callbackTimestamp: AudioTimeStamp? = nil,
ioTimestamp: AudioTimeStamp? = nil
) { ) {
// The system callback copies its borrowed Core Audio memory while this // The system callback copies its borrowed Core Audio memory while this
// lock prevents teardown, then conversion operates on the owned copy. // lock prevents teardown, then conversion operates on the owned copy.
audioFileLock.lock() audioFileLock.lock()
defer { audioFileLock.unlock() } defer { audioFileLock.unlock() }
guard isAcceptingAudio, guard isAcceptingAudio else { return }
let inputBuffer = inputBufferProvider(), if source == .system {
inputBuffer.frameLength > 0 else { return } systemDiagnostics.callbackCount += 1
let callbackAt = Date()
if systemDiagnostics.firstCallbackAt == nil { systemDiagnostics.firstCallbackAt = callbackAt }
systemDiagnostics.lastCallbackAt = callbackAt
if let callbackTimestamp {
if systemDiagnostics.firstSampleTime == nil { systemDiagnostics.firstSampleTime = callbackTimestamp.mSampleTime }
systemDiagnostics.lastSampleTime = callbackTimestamp.mSampleTime
}
if let ioTimestamp {
if systemDiagnostics.firstHostTime == nil { systemDiagnostics.firstHostTime = ioTimestamp.mHostTime }
systemDiagnostics.lastHostTime = ioTimestamp.mHostTime
}
}
guard let inputBuffer = inputBufferProvider(), inputBuffer.frameLength > 0 else {
if source == .system { systemDiagnostics.discardedCallbackCount += 1 }
return
}
if source == .system {
systemDiagnostics.inputFrameCount += UInt64(inputBuffer.frameLength)
}
updateAudioLevel(inputBuffer, source: source) updateAudioLevel(inputBuffer, source: source)
let ratio = targetFormat.sampleRate / inputBuffer.format.sampleRate let ratio = targetFormat.sampleRate / inputBuffer.format.sampleRate
let capacity = max(1, AVAudioFrameCount(ceil(Double(inputBuffer.frameLength) * ratio))) let capacity = max(1, AVAudioFrameCount(ceil(Double(inputBuffer.frameLength) * ratio)))
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { return } guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else {
if source == .system { systemDiagnostics.discardedCallbackCount += 1 }
return
}
var suppliedInput = false var suppliedInput = false
var conversionError: NSError? var conversionError: NSError?
let status = converter.convert(to: outputBuffer, error: &conversionError) { _, outputStatus in let status = converter.convert(to: outputBuffer, error: &conversionError) { _, outputStatus in
@@ -488,7 +707,10 @@ final class AudioManager: NSObject, ObservableObject {
outputStatus.pointee = .haveData outputStatus.pointee = .haveData
return inputBuffer return inputBuffer
} }
guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { return } guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else {
if source == .system { systemDiagnostics.discardedCallbackCount += 1 }
return
}
do { do {
switch source { switch source {
@@ -496,6 +718,7 @@ final class AudioManager: NSObject, ObservableObject {
try micAudioFile?.write(from: outputBuffer) try micAudioFile?.write(from: outputBuffer)
case .system: case .system:
try systemAudioFile?.write(from: outputBuffer) try systemAudioFile?.write(from: outputBuffer)
systemDiagnostics.outputFrameCount += UInt64(outputBuffer.frameLength)
} }
} catch { } catch {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
@@ -589,15 +812,20 @@ final class AudioManager: NSObject, ObservableObject {
return String(format: "mic %.1fs, system %.1fs", micDuration, systemDuration) return String(format: "mic %.1fs, system %.1fs", micDuration, systemDuration)
} }
private func repairHalfDurationSystemWAVIfNeeded(in files: [URL?]) { private func repairHalfDurationSystemWAVIfNeeded(in files: [URL?]) -> Bool {
guard files.count >= 2, guard files.count >= 2,
let micDuration = audioDuration(at: files[0]), let micDuration = audioDuration(at: files[0]),
let systemURL = files[1], let systemURL = files[1],
let systemDuration = audioDuration(at: systemURL), let systemDuration = audioDuration(at: systemURL),
micDuration >= 60, micDuration >= 60,
systemURL.pathExtension.caseInsensitiveCompare("wav") == .orderedSame, systemURL.pathExtension.caseInsensitiveCompare("wav") == .orderedSame,
(0.48...0.52).contains(systemDuration / micDuration) else { return } (0.48...0.52).contains(systemDuration / micDuration) else { return false }
try? halveWAVSampleRate(at: systemURL) do {
try halveWAVSampleRate(at: systemURL)
return true
} catch {
return false
}
} }
private func halveWAVSampleRate(at url: URL) throws { private func halveWAVSampleRate(at url: URL) throws {
@@ -662,6 +890,113 @@ final class AudioManager: NSObject, ObservableObject {
file.processingFormat.sampleRate > 0 else { return nil } file.processingFormat.sampleRate > 0 else { return nil }
return Double(file.length) / file.processingFormat.sampleRate return Double(file.length) / file.processingFormat.sampleRate
} }
private func streamDescriptionSummary(_ description: AudioStreamBasicDescription?) -> String {
guard let description else { return "unavailable" }
return String(
format: "sampleRate=%.3f,formatID=%u,flags=%u,bytesPerPacket=%u,framesPerPacket=%u,bytesPerFrame=%u,channels=%u,bitsPerChannel=%u",
description.mSampleRate,
description.mFormatID,
description.mFormatFlags,
description.mBytesPerPacket,
description.mFramesPerPacket,
description.mBytesPerFrame,
description.mChannelsPerFrame,
description.mBitsPerChannel
)
}
private func audioFormatSummary(_ format: AVAudioFormat) -> String {
"sampleRate=\(format.sampleRate),channels=\(format.channelCount),commonFormat=\(format.commonFormat.rawValue),interleaved=\(format.isInterleaved)"
}
private func audioFileSummary(_ url: URL?) -> String {
guard let url else { return "missing" }
guard let file = try? AVAudioFile(forReading: url), file.processingFormat.sampleRate > 0 else {
return "\(url.lastPathComponent):unreadable"
}
let duration = Double(file.length) / file.processingFormat.sampleRate
return String(
format: "%@:sampleRate=%.3f,channels=%u,frames=%lld,duration=%.6f",
url.lastPathComponent,
file.processingFormat.sampleRate,
file.processingFormat.channelCount,
file.length,
duration
)
}
private func writeCaptureDiagnostics(
sessionID: UUID,
captureStartedAt: Date,
preRepairFileSummaries: [String],
repairedFiles: [URL?],
repairApplied: Bool,
audioFolder: URL?
) {
guard let audioFolder else { return }
let callbackDuration = systemDiagnostics.firstCallbackAt.flatMap { first in
systemDiagnostics.lastCallbackAt.map { $0.timeIntervalSince(first) }
}
let sampleTimeDelta = systemDiagnostics.firstSampleTime.flatMap { first in
systemDiagnostics.lastSampleTime.map { $0 - first }
}
let hostTimeDelta = systemDiagnostics.firstHostTime.flatMap { first in
systemDiagnostics.lastHostTime.map { $0 >= first ? $0 - first : 0 }
}
let inputFrameDuration = systemDiagnostics.selectedInputSampleRate.flatMap { sampleRate in
sampleRate > 0 ? Double(systemDiagnostics.inputFrameCount) / sampleRate : nil
}
let outputFrameDuration = systemDiagnostics.targetSampleRate.flatMap { sampleRate in
sampleRate > 0 ? Double(systemDiagnostics.outputFrameCount) / sampleRate : nil
}
let observedInputRate = callbackDuration.flatMap { duration in
duration > 0 ? Double(systemDiagnostics.inputFrameCount) / duration : nil
}
let preRepairMic = preRepairFileSummaries.indices.contains(0) ? preRepairFileSummaries[0] : "missing"
let preRepairSystem = preRepairFileSummaries.indices.contains(1) ? preRepairFileSummaries[1] : "missing"
let postRepairMic = repairedFiles.indices.contains(0) ? audioFileSummary(repairedFiles[0]) : "missing"
let postRepairSystem = repairedFiles.indices.contains(1) ? audioFileSummary(repairedFiles[1]) : "missing"
let callbackDurationLine = callbackDuration.map { String(format: "callbackWallDuration=%.6f", $0) } ?? "callbackWallDuration=unavailable"
let inputFrameDurationLine = inputFrameDuration.map { String(format: "inputFrameDurationAtSelectedRate=%.6f", $0) } ?? "inputFrameDurationAtSelectedRate=unavailable"
let outputFrameDurationLine = outputFrameDuration.map { String(format: "outputFrameDurationAtTargetRate=%.6f", $0) } ?? "outputFrameDurationAtTargetRate=unavailable"
let observedInputRateLine = observedInputRate.map { String(format: "observedInputFramesPerSecond=%.3f", $0) } ?? "observedInputFramesPerSecond=unavailable"
let sampleTimeDeltaLine = sampleTimeDelta.map { String(format: "sampleTimeDelta=%.6f", $0) } ?? "sampleTimeDelta=unavailable"
let hostTimeDeltaLine = hostTimeDelta.map { "hostTimeDelta=\($0)" } ?? "hostTimeDelta=unavailable"
let lines: [String] = [
"Meetingnotes system capture diagnostics",
"sessionID=\(sessionID.uuidString)",
"createdAt=\(ISO8601DateFormatter().string(from: Date()))",
String(format: "captureWallDuration=%.6f", Date().timeIntervalSince(captureStartedAt)),
"tapAdvertisedFormat=\(systemDiagnostics.tapAdvertisedFormat)",
"aggregateInputFormat=\(systemDiagnostics.aggregateInputFormat)",
"selectedInputFormat=\(systemDiagnostics.selectedInputFormat)",
"inputRateInference=\(systemDiagnostics.inputRateInference)",
"targetFormat=\(systemDiagnostics.targetFormat)",
"firstBufferLayout=\(systemDiagnostics.firstBufferLayout ?? "unavailable")",
"callbackCount=\(systemDiagnostics.callbackCount)",
"inputFrameCount=\(systemDiagnostics.inputFrameCount)",
"outputFrameCount=\(systemDiagnostics.outputFrameCount)",
"discardedCallbackCount=\(systemDiagnostics.discardedCallbackCount)",
callbackDurationLine,
inputFrameDurationLine,
outputFrameDurationLine,
observedInputRateLine,
sampleTimeDeltaLine,
hostTimeDeltaLine,
"repairApplied=\(repairApplied)",
"preRepairMic=\(preRepairMic)",
"preRepairSystem=\(preRepairSystem)",
"postRepairMic=\(postRepairMic)",
"postRepairSystem=\(postRepairSystem)"
]
let diagnosticsURL = audioFolder.appendingPathComponent("audio-diagnostics-\(sessionID.uuidString).txt")
try? lines.joined(separator: "\n").appending("\n").write(
to: diagnosticsURL,
atomically: true,
encoding: .utf8
)
}
private func resetAudioLevels() { private func resetAudioLevels() {
micAudioLevel = 0 micAudioLevel = 0
+1 -1
View File
@@ -8,7 +8,7 @@ import Security
class KeychainHelper { class KeychainHelper {
static let shared = KeychainHelper() static let shared = KeychainHelper()
private let serviceName = "net.jamesbone.meetingnotes" private let serviceName = Bundle.main.bundleIdentifier ?? "net.jamesbone.meetingnotes"
private init() {} private init() {}
@@ -152,6 +152,101 @@ class LocalStorageManager {
} }
} }
func mergeMeeting(_ continuation: Meeting, into previous: Meeting) -> Meeting? {
guard continuation.id != previous.id, continuation.date >= previous.date else { return nil }
let previousFolder = recoveryDirectory.appendingPathComponent(previous.id.uuidString, isDirectory: true)
let continuationFolder = recoveryAudioFolder(named: continuation.id.uuidString)
var copiedAudioURLs: [URL] = []
var createdPreviousFolder = false
if let continuationFolder {
if !FileManager.default.fileExists(atPath: previousFolder.path) {
do {
try FileManager.default.createDirectory(at: previousFolder, withIntermediateDirectories: true)
createdPreviousFolder = true
} catch {
return nil
}
}
let recoveryFiles = recoveryAudioFiles(in: continuationFolder).map(\.url)
+ recoveryDiagnosticFiles(in: continuationFolder)
for recoveryFile in recoveryFiles {
let destination = previousFolder.appendingPathComponent(recoveryFile.lastPathComponent)
if FileManager.default.fileExists(atPath: destination.path) {
guard FileManager.default.contentsEqual(
atPath: recoveryFile.path,
andPath: destination.path
) else {
rollbackMergedAudio(copiedAudioURLs, removeFolder: createdPreviousFolder ? previousFolder : nil)
return nil
}
continue
}
do {
try FileManager.default.copyItem(at: recoveryFile, to: destination)
copiedAudioURLs.append(destination)
} catch {
rollbackMergedAudio(copiedAudioURLs, removeFolder: createdPreviousFolder ? previousFolder : nil)
return nil
}
}
}
var merged = previous
var seenChunkIDs = Set<UUID>()
merged.transcriptChunks = (previous.transcriptChunks + continuation.transcriptChunks)
.filter { seenChunkIDs.insert($0.id).inserted }
.sorted {
if $0.timestamp == $1.timestamp {
return $0.id.uuidString < $1.id.uuidString
}
return $0.timestamp < $1.timestamp
}
merged.userNotes = mergedText(previous.userNotes, continuation.userNotes)
merged.generatedNotes = mergedText(previous.generatedNotes, continuation.generatedNotes)
merged.templateId = previous.templateId ?? continuation.templateId
merged.transcriptionError = previous.transcriptionError ?? continuation.transcriptionError
if !recoveryAudioFiles(in: previousFolder).isEmpty {
merged.recoveryAudioFolderName = previous.id.uuidString
}
guard saveMeeting(merged) else {
rollbackMergedAudio(copiedAudioURLs, removeFolder: createdPreviousFolder ? previousFolder : nil)
return nil
}
guard deleteMeeting(continuation) else {
_ = saveMeeting(previous)
rollbackMergedAudio(copiedAudioURLs, removeFolder: createdPreviousFolder ? previousFolder : nil)
return nil
}
if let continuationFolder {
try? FileManager.default.removeItem(at: continuationFolder)
}
return merged
}
private func mergedText(_ first: String, _ second: String) -> String {
let first = first.trimmingCharacters(in: .whitespacesAndNewlines)
let second = second.trimmingCharacters(in: .whitespacesAndNewlines)
guard !first.isEmpty else { return second }
guard !second.isEmpty, second != first else { return first }
return first + "\n\n---\n\n" + second
}
private func rollbackMergedAudio(_ copiedURLs: [URL], removeFolder folder: URL?) {
for url in copiedURLs {
try? FileManager.default.removeItem(at: url)
}
if let folder, recoveryAudioFiles(in: folder).isEmpty {
try? FileManager.default.removeItem(at: folder)
}
}
// MARK: - Recovery Audio // MARK: - Recovery Audio
func preserveAudioFiles(_ urls: [URL], for meetingID: UUID) -> URL? { func preserveAudioFiles(_ urls: [URL], for meetingID: UUID) -> URL? {
@@ -255,6 +350,23 @@ class LocalStorageManager {
} }
} }
private func recoveryDiagnosticFiles(in folder: URL) -> [URL] {
guard let files = try? FileManager.default.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
) else {
return []
}
return files.filter { url in
let values = try? url.resourceValues(forKeys: [.isRegularFileKey])
return values?.isRegularFile == true
&& url.pathExtension.caseInsensitiveCompare("txt") == .orderedSame
&& url.lastPathComponent.lowercased().hasPrefix("audio-diagnostics-")
}
}
func findRecoveryAudioFolder(for meeting: Meeting) -> URL? { func findRecoveryAudioFolder(for meeting: Meeting) -> URL? {
let canonicalName = meeting.id.uuidString let canonicalName = meeting.id.uuidString
guard meeting.recoveryAudioFolderName == nil guard meeting.recoveryAudioFolderName == nil
@@ -2,6 +2,12 @@ import Foundation
import SwiftUI import SwiftUI
import Combine import Combine
struct RecordingCompletion {
let chunks: [TranscriptChunk]
let recoveryAudioFolderName: String?
let transcriptionError: String?
}
/// Manages recording sessions at the app level to persist across navigation /// Manages recording sessions at the app level to persist across navigation
@MainActor @MainActor
class RecordingSessionManager: ObservableObject { class RecordingSessionManager: ObservableObject {
@@ -14,9 +20,11 @@ class RecordingSessionManager: ObservableObject {
@Published var errorMessage: String? @Published var errorMessage: String?
@Published var activeRecordingTranscriptChunksUpdated: [TranscriptChunk] = [] @Published var activeRecordingTranscriptChunksUpdated: [TranscriptChunk] = []
private let audioManager = AudioManager.shared private var audioManager = AudioManager.shared
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var audioManagerCancellables = Set<AnyCancellable>()
private let transcriptUpdateSubject = PassthroughSubject<[TranscriptChunk], Never>() private let transcriptUpdateSubject = PassthroughSubject<[TranscriptChunk], Never>()
private var processingMeetingIds = Set<UUID>()
// Store transcript chunks for the active recording session // Store transcript chunks for the active recording session
private var activeRecordingTranscriptChunks: [TranscriptChunk] = [] private var activeRecordingTranscriptChunks: [TranscriptChunk] = []
@@ -27,24 +35,19 @@ class RecordingSessionManager: ObservableObject {
} }
private func setupAudioManagerBindings() { private func setupAudioManagerBindings() {
audioManagerCancellables.removeAll()
// Bind to audio manager state // Bind to audio manager state
audioManager.$isRecording audioManager.$isRecording
.sink { [weak self] isRecording in .sink { [weak self] isRecording in
self?.isRecording = isRecording self?.isRecording = isRecording
} }
.store(in: &cancellables) .store(in: &audioManagerCancellables)
audioManager.$isProcessing
.sink { [weak self] isProcessing in
self?.isProcessing = isProcessing
}
.store(in: &cancellables)
audioManager.$errorMessage audioManager.$errorMessage
.sink { [weak self] errorMessage in .sink { [weak self] errorMessage in
self?.errorMessage = errorMessage self?.errorMessage = errorMessage
} }
.store(in: &cancellables) .store(in: &audioManagerCancellables)
// When transcript chunks change, store them for the active recording and send to debouncer // When transcript chunks change, store them for the active recording and send to debouncer
audioManager.$transcriptChunks audioManager.$transcriptChunks
@@ -55,7 +58,7 @@ class RecordingSessionManager: ObservableObject {
self.transcriptUpdateSubject.send(newChunks) self.transcriptUpdateSubject.send(newChunks)
} }
.store(in: &cancellables) .store(in: &audioManagerCancellables)
} }
private func setupDebouncedSaving() { private func setupDebouncedSaving() {
@@ -84,22 +87,40 @@ class RecordingSessionManager: ObservableObject {
audioManager.startRecording(for: meetingId) audioManager.startRecording(for: meetingId)
} }
func stopRecording() async -> [TranscriptChunk] { func stopRecording() async -> RecordingCompletion {
print("🛑 Stopping recording for meeting: \(activeMeetingId?.uuidString ?? "unknown")") print("🛑 Stopping recording for meeting: \(activeMeetingId?.uuidString ?? "unknown")")
guard let meetingId = activeMeetingId else { guard let meetingId = activeMeetingId else {
audioManager.cancelRecording() audioManager.cancelRecording()
recordingStartedAt = nil recordingStartedAt = nil
return [] return RecordingCompletion(chunks: [], recoveryAudioFolderName: nil, transcriptionError: nil)
} }
recordingStartedAt = nil
let chunks = await audioManager.stopRecordingAndTranscribe() let completedAudioManager = audioManager
activeRecordingTranscriptChunks = chunks audioManager = AudioManager()
activeRecordingTranscriptChunksUpdated = chunks setupAudioManagerBindings()
updateActiveMeetingTranscript(meetingId: meetingId, chunks: chunks)
activeMeetingId = nil activeMeetingId = nil
activeRecordingTranscriptChunks = [] recordingStartedAt = nil
return chunks processingMeetingIds.insert(meetingId)
isProcessing = true
defer {
processingMeetingIds.remove(meetingId)
isProcessing = !processingMeetingIds.isEmpty
}
let chunks = await completedAudioManager.stopRecordingAndTranscribe()
let completion = RecordingCompletion(
chunks: chunks,
recoveryAudioFolderName: completedAudioManager.lastRecoveryAudioFolderName,
transcriptionError: completedAudioManager.errorMessage
)
updateActiveMeetingTranscript(
meetingId: meetingId,
chunks: chunks,
recoveryAudioFolderName: completion.recoveryAudioFolderName,
transcriptionError: completion.transcriptionError
)
return completion
} }
func cancelRecording() { func cancelRecording() {
@@ -113,20 +134,26 @@ class RecordingSessionManager: ObservableObject {
return isRecording && activeMeetingId == meetingId return isRecording && activeMeetingId == meetingId
} }
var lastRecoveryAudioFolderName: String? { func isProcessingMeeting(_ meetingId: UUID) -> Bool {
audioManager.lastRecoveryAudioFolderName processingMeetingIds.contains(meetingId)
} }
private func updateActiveMeetingTranscript(meetingId: UUID, chunks: [TranscriptChunk]) { private func updateActiveMeetingTranscript(
meetingId: UUID,
chunks: [TranscriptChunk],
recoveryAudioFolderName: String? = nil,
transcriptionError: String? = nil
) {
// Load all meetings // Load all meetings
var meetings = LocalStorageManager.shared.loadMeetings() var meetings = LocalStorageManager.shared.loadMeetings()
// Find and update the active meeting // Find and update the active meeting
if let index = meetings.firstIndex(where: { $0.id == meetingId }) { if let index = meetings.firstIndex(where: { $0.id == meetingId }) {
meetings[index].transcriptChunks = chunks meetings[index].transcriptChunks = chunks
if let recoveryAudioFolderName = lastRecoveryAudioFolderName { if let recoveryAudioFolderName {
meetings[index].recoveryAudioFolderName = recoveryAudioFolderName meetings[index].recoveryAudioFolderName = recoveryAudioFolderName
} }
meetings[index].transcriptionError = transcriptionError
// Save the updated meeting // Save the updated meeting
let success = LocalStorageManager.shared.saveMeeting(meetings[index]) let success = LocalStorageManager.shared.saveMeeting(meetings[index])
+3
View File
@@ -99,6 +99,7 @@ struct Meeting: Codable, Identifiable, Hashable {
var generatedNotes: String var generatedNotes: String
var templateId: UUID? // Add property to track per-meeting template var templateId: UUID? // Add property to track per-meeting template
var recoveryAudioFolderName: String? var recoveryAudioFolderName: String?
var transcriptionError: String?
// MARK: - Data versioning // MARK: - Data versioning
/// Version of this Meeting record on disk. Useful for migration. /// Version of this Meeting record on disk. Useful for migration.
var dataVersion: Int var dataVersion: Int
@@ -113,6 +114,7 @@ struct Meeting: Codable, Identifiable, Hashable {
generatedNotes: String = "", generatedNotes: String = "",
templateId: UUID? = nil, templateId: UUID? = nil,
recoveryAudioFolderName: String? = nil, recoveryAudioFolderName: String? = nil,
transcriptionError: String? = nil,
dataVersion: Int = Meeting.currentDataVersion) { dataVersion: Int = Meeting.currentDataVersion) {
self.id = id self.id = id
self.date = date self.date = date
@@ -122,6 +124,7 @@ struct Meeting: Codable, Identifiable, Hashable {
self.generatedNotes = generatedNotes self.generatedNotes = generatedNotes
self.templateId = templateId self.templateId = templateId
self.recoveryAudioFolderName = recoveryAudioFolderName self.recoveryAudioFolderName = recoveryAudioFolderName
self.transcriptionError = transcriptionError
self.dataVersion = dataVersion self.dataVersion = dataVersion
} }
+42 -1
View File
@@ -116,6 +116,47 @@ extension AudioObjectID {
try read(kAudioTapPropertyFormat, defaultValue: AudioStreamBasicDescription()) try read(kAudioTapPropertyFormat, defaultValue: AudioStreamBasicDescription())
} }
/// Reads the virtual format of the first input stream exposed by this device.
///
/// An aggregate device can adapt a tap to the active output hardware. Its
/// input stream format is therefore the format delivered to the I/O proc,
/// which can differ from the tap object's originally advertised format.
func readInputStreamBasicDescription() throws -> AudioStreamBasicDescription {
var streamsAddress = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreams,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var dataSize: UInt32 = 0
var status = AudioObjectGetPropertyDataSize(self, &streamsAddress, 0, nil, &dataSize)
guard status == noErr else {
throw "Error reading device stream list size: \(status)"
}
var streamIDs = [AudioObjectID](
repeating: .unknown,
count: Int(dataSize) / MemoryLayout<AudioObjectID>.size
)
status = AudioObjectGetPropertyData(self, &streamsAddress, 0, nil, &dataSize, &streamIDs)
guard status == noErr else {
throw "Error reading device stream list: \(status)"
}
for streamID in streamIDs {
let direction: UInt32 = try streamID.read(
kAudioStreamPropertyDirection,
defaultValue: 0
)
guard direction == 1 else { continue }
return try streamID.read(
kAudioStreamPropertyVirtualFormat,
defaultValue: AudioStreamBasicDescription()
)
}
throw "Device has no input stream."
}
private func requireSystemObject() throws { private func requireSystemObject() throws {
if self != .system { throw "Only supported for the system object." } if self != .system { throw "Only supported for the system object." }
} }
@@ -307,4 +348,4 @@ extension AudioObjectID {
func getDeviceName() throws -> String { func getDeviceName() throws -> String {
return try readString(kAudioDevicePropertyDeviceNameCFString) return try readString(kAudioDevicePropertyDeviceNameCFString)
} }
} }
+26 -7
View File
@@ -63,6 +63,10 @@ final class ProcessTap {
@ObservationIgnored @ObservationIgnored
private(set) var tapStreamDescription: AudioStreamBasicDescription? private(set) var tapStreamDescription: AudioStreamBasicDescription?
@ObservationIgnored @ObservationIgnored
private(set) var tapAdvertisedStreamDescription: AudioStreamBasicDescription?
@ObservationIgnored
private(set) var aggregateInputStreamDescription: AudioStreamBasicDescription?
@ObservationIgnored
private var invalidationHandler: InvalidationHandler? private var invalidationHandler: InvalidationHandler?
@ObservationIgnored @ObservationIgnored
@@ -138,11 +142,11 @@ final class ProcessTap {
tapDescription = CATapDescription(stereoMixdownOfProcesses: [process.objectID]) tapDescription = CATapDescription(stereoMixdownOfProcesses: [process.objectID])
logger.debug("Configuring tap for single process objectID: \(process.objectID)") logger.debug("Configuring tap for single process objectID: \(process.objectID)")
case .systemAudio: case .systemAudio:
// Keep the HAL tap's buffer layout consistent with the default // The transcription file is mono, so ask Core Audio for a mono
// output stream. AudioManager performs the stereo-to-mono mix when // mixdown at the source. This avoids interpreting a stereo HAL
// it converts the captured audio to the 16 kHz transcription file. // buffer as half as many frames before the 16 kHz conversion.
tapDescription = CATapDescription(stereoGlobalTapButExcludeProcesses: []) tapDescription = CATapDescription(monoGlobalTapButExcludeProcesses: [])
logger.debug("Configuring a stereo global system audio tap.") logger.info("Configuring a mono global system audio tap.")
} }
tapDescription.uuid = UUID() tapDescription.uuid = UUID()
@@ -248,8 +252,23 @@ final class ProcessTap {
do { do {
logger.debug("Attempting to read audio tap stream basic description for tapID #\(tapID)...") logger.debug("Attempting to read audio tap stream basic description for tapID #\(tapID)...")
self.tapStreamDescription = try tapID.readAudioTapStreamBasicDescription() let advertisedDescription = try tapID.readAudioTapStreamBasicDescription()
logger.debug("Successfully read tap stream description: \(String(describing: self.tapStreamDescription))") self.tapAdvertisedStreamDescription = advertisedDescription
// The aggregate device may adapt the tap to the active hardware's
// sample rate. Its input stream is the format actually delivered
// to the I/O proc, so use that rather than the tap's pre-aggregate
// advertisement. Using the latter can halve the written duration
// when, for example, a 48 kHz tap is delivered at 24 kHz.
do {
let aggregateDescription = try aggregateDeviceID.readInputStreamBasicDescription()
self.aggregateInputStreamDescription = aggregateDescription
self.tapStreamDescription = aggregateDescription
logger.info("Using aggregate input stream description: \(String(describing: self.tapStreamDescription), privacy: .public); tap advertised: \(String(describing: advertisedDescription), privacy: .public)")
} catch {
self.tapStreamDescription = advertisedDescription
logger.warning("Could not read aggregate input stream format; using tap format: \(error, privacy: .public)")
}
} catch { } catch {
logger.error("Failed to read audio tap stream basic description for tapID #\(tapID): \(error)") logger.error("Failed to read audio tap stream basic description for tapID #\(tapID): \(error)")
throw error // Propagate error throw error // Propagate error
+35 -2
View File
@@ -30,6 +30,7 @@ enum CoderAPIError: LocalizedError {
case missingModel(String) case missingModel(String)
case invalidResponse case invalidResponse
case serviceError(Int, String) case serviceError(Int, String)
case audioPreparationFailed(String, String)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
@@ -43,6 +44,8 @@ enum CoderAPIError: LocalizedError {
return "Coder returned an invalid response." return "Coder returned an invalid response."
case .serviceError(let status, let message): case .serviceError(let status, let message):
return "Coder request failed (\(status)): \(message)" return "Coder request failed (\(status)): \(message)"
case .audioPreparationFailed(let filename, let message):
return "Could not prepare \(filename) for transcription: \(message)"
} }
} }
} }
@@ -179,7 +182,12 @@ final class CoderAPIClient {
let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines) let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") } guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") }
let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "") let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "")
let chunks = try makeAudioChunks(from: fileURL, preserveSpeakerIdentity: diarization) let chunks: [AudioChunk]
do {
chunks = try makeAudioChunks(from: fileURL, preserveSpeakerIdentity: diarization)
} catch {
throw CoderAPIError.audioPreparationFailed(fileURL.lastPathComponent, error.localizedDescription)
}
defer { defer {
for chunk in chunks where chunk.isTemporary { for chunk in chunks where chunk.isTemporary {
try? FileManager.default.removeItem(at: chunk.url) try? FileManager.default.removeItem(at: chunk.url)
@@ -260,13 +268,38 @@ final class CoderAPIClient {
let size = attributes[.size] as? NSNumber { let size = attributes[.size] as? NSNumber {
request.setValue(size.stringValue, forHTTPHeaderField: "Content-Length") request.setValue(size.stringValue, forHTTPHeaderField: "Content-Length")
} }
let (data, response) = try await transcriptionSession.upload(for: request, fromFile: bodyURL) let (data, response) = try await uploadTranscription(request: request, bodyURL: bodyURL)
try validate(response: response, data: data) try validate(response: response, data: data)
let decoded = try JSONDecoder().decode(TranscriptionResponse.self, from: data) let decoded = try JSONDecoder().decode(TranscriptionResponse.self, from: data)
let segments = decoded.segments ?? segments(from: decoded.words ?? []) let segments = decoded.segments ?? segments(from: decoded.words ?? [])
return Transcription(text: decoded.text, segments: segments) return Transcription(text: decoded.text, segments: segments)
} }
private func uploadTranscription(request: URLRequest, bodyURL: URL) async throws -> (Data, URLResponse) {
let retryDelays: [UInt64] = [3, 10]
for attempt in 0...retryDelays.count {
do {
return try await transcriptionSession.upload(for: request, fromFile: bodyURL)
} catch {
guard attempt < retryDelays.count, isRetryableTranscriptionError(error) else {
throw error
}
try await Task.sleep(nanoseconds: retryDelays[attempt] * 1_000_000_000)
}
}
throw CoderAPIError.invalidResponse
}
private func isRetryableTranscriptionError(_ error: Error) -> Bool {
guard let urlError = error as? URLError else { return false }
switch urlError.code {
case .networkConnectionLost, .cannotConnectToHost, .timedOut:
return true
default:
return false
}
}
private func makeAudioChunks(from fileURL: URL, preserveSpeakerIdentity: Bool) throws -> [AudioChunk] { private func makeAudioChunks(from fileURL: URL, preserveSpeakerIdentity: Bool) throws -> [AudioChunk] {
let input = try AVAudioFile(forReading: fileURL) let input = try AVAudioFile(forReading: fileURL)
let format = input.processingFormat let format = input.processingFormat
+32 -15
View File
@@ -182,13 +182,13 @@ private enum LocalAPIRouter {
} }
private enum LocalRecordingError: LocalizedError { private enum LocalRecordingError: LocalizedError {
case processing case stopping
case saveFailed case saveFailed
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .processing: case .stopping:
return "The previous meeting is still processing." return "The current recording is still stopping."
case .saveFailed: case .saveFailed:
return "Could not create a meeting for this recording." return "Could not create a meeting for this recording."
} }
@@ -200,18 +200,20 @@ private final class LocalRecordingController {
static let shared = LocalRecordingController() static let shared = LocalRecordingController()
private let recordingManager = RecordingSessionManager.shared private let recordingManager = RecordingSessionManager.shared
private let stopGraceNanoseconds: UInt64 = 10_000_000_000
private var isStopping = false private var isStopping = false
private var pendingStopTask: Task<Void, Never>?
private init() {} private init() {}
func statusPayload() -> [String: Any] { func statusPayload() -> [String: Any] {
let state: String let state: String
if isStopping || recordingManager.isProcessing { if recordingManager.isRecording {
state = "processing"
} else if recordingManager.isRecording {
state = "recording" state = "recording"
} else if recordingManager.activeMeetingId != nil { } else if recordingManager.activeMeetingId != nil {
state = "starting" state = "starting"
} else if isStopping || recordingManager.isProcessing {
state = "processing"
} else { } else {
state = "idle" state = "idle"
} }
@@ -237,8 +239,14 @@ private final class LocalRecordingController {
} }
func startRecording() throws -> [String: Any] { func startRecording() throws -> [String: Any] {
if isStopping || recordingManager.isProcessing { if let pendingStopTask {
throw LocalRecordingError.processing pendingStopTask.cancel()
self.pendingStopTask = nil
isStopping = false
return statusPayload()
}
if isStopping {
throw LocalRecordingError.stopping
} }
if recordingManager.activeMeetingId != nil { if recordingManager.activeMeetingId != nil {
return statusPayload() return statusPayload()
@@ -258,14 +266,22 @@ private final class LocalRecordingController {
return statusPayload() return statusPayload()
} }
isStopping = true isStopping = true
Task { [weak self] in pendingStopTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: self?.stopGraceNanoseconds ?? 0)
guard !Task.isCancelled else { return }
await self?.finishRecording() await self?.finishRecording()
} }
return statusPayload() return statusPayload()
} }
func cancelRecording() -> [String: Any] { func cancelRecording() -> [String: Any] {
guard !isStopping else { return statusPayload() } if let pendingStopTask {
pendingStopTask.cancel()
self.pendingStopTask = nil
isStopping = false
} else if isStopping {
return statusPayload()
}
let meetingID = recordingManager.activeMeetingId let meetingID = recordingManager.activeMeetingId
recordingManager.cancelRecording() recordingManager.cancelRecording()
if let meetingID, if let meetingID,
@@ -277,16 +293,18 @@ private final class LocalRecordingController {
} }
private func finishRecording() async { private func finishRecording() async {
pendingStopTask = nil
let meetingID = recordingManager.activeMeetingId let meetingID = recordingManager.activeMeetingId
let chunks = await recordingManager.stopRecording() isStopping = false
let completion = await recordingManager.stopRecording()
guard let meetingID, guard let meetingID,
var meeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meetingID }) else { var meeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meetingID }) else {
isStopping = false
return return
} }
meeting.transcriptChunks = chunks meeting.transcriptChunks = completion.chunks
meeting.recoveryAudioFolderName = recordingManager.lastRecoveryAudioFolderName meeting.recoveryAudioFolderName = completion.recoveryAudioFolderName
meeting.transcriptionError = completion.transcriptionError
let templates = LocalStorageManager.shared.loadTemplates() let templates = LocalStorageManager.shared.loadTemplates()
if meeting.templateId == nil { if meeting.templateId == nil {
meeting.templateId = LocalStorageManager.shared.preferredTemplateID(in: templates) meeting.templateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
@@ -335,7 +353,6 @@ private final class LocalRecordingController {
NotificationCenter.default.post(name: .meetingSaved, object: meeting) NotificationCenter.default.post(name: .meetingSaved, object: meeting)
} }
} }
isStopping = false
} }
} }
@@ -66,6 +66,28 @@ class MeetingListViewModel: ObservableObject {
meetings.removeAll { $0.id == meeting.id } meetings.removeAll { $0.id == meeting.id }
_ = LocalStorageManager.shared.deleteMeeting(meeting) _ = LocalStorageManager.shared.deleteMeeting(meeting)
} }
func previousMeeting(for meeting: Meeting) -> Meeting? {
meetings
.filter { $0.id != meeting.id && $0.date < meeting.date }
.max { $0.date < $1.date }
}
func mergeIntoPrevious(_ meeting: Meeting) -> Meeting? {
guard let previous = previousMeeting(for: meeting),
let merged = LocalStorageManager.shared.mergeMeeting(meeting, into: previous) else {
errorMessage = "The meetings could not be merged. Their original records and audio were kept."
return nil
}
meetings.removeAll { $0.id == meeting.id || $0.id == previous.id }
meetings.append(merged)
meetings.sort { $0.date > $1.date }
NotificationCenter.default.post(name: .meetingSaved, object: merged)
NotificationCenter.default.post(name: .meetingDeleted, object: meeting)
PostHogSDK.shared.capture("meetings_merged")
return merged
}
func createNewMeeting() -> Meeting { func createNewMeeting() -> Meeting {
let newMeeting = Meeting(templateId: LocalStorageManager.shared.preferredTemplateID()) let newMeeting = Meeting(templateId: LocalStorageManager.shared.preferredTemplateID())
+21 -7
View File
@@ -47,7 +47,7 @@ class MeetingViewModel: ObservableObject {
var isProcessing: Bool { var isProcessing: Bool {
return isRetryingTranscription || return isRetryingTranscription ||
(recordingSessionManager.isProcessing && recordingSessionManager.activeMeetingId == meeting.id) recordingSessionManager.isProcessingMeeting(meeting.id)
} }
var canRetryTranscription: Bool { var canRetryTranscription: Bool {
@@ -137,17 +137,26 @@ class MeetingViewModel: ObservableObject {
self.meeting.transcriptChunks = recordingSessionManager.getTranscriptChunks(for: meeting.id) self.meeting.transcriptChunks = recordingSessionManager.getTranscriptChunks(for: meeting.id)
} }
// Listen for final transcript updates for this meeting. // Listen for transcript updates emitted while this meeting is recording.
recordingSessionManager.$activeRecordingTranscriptChunksUpdated recordingSessionManager.$activeRecordingTranscriptChunksUpdated
.dropFirst() .dropFirst()
.sink { [weak self] updatedChunks in .sink { [weak self] updatedChunks in
guard let self = self else { return } guard let self = self else { return }
// Only update if this meeting is the active recording if recordingSessionManager.activeMeetingId == self.meeting.id {
if recordingSessionManager.isRecordingMeeting(self.meeting.id) {
self.meeting.transcriptChunks = updatedChunks self.meeting.transcriptChunks = updatedChunks
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
NotificationCenter.default.publisher(for: .meetingSaved)
.compactMap { $0.object as? Meeting }
.filter { [weak self] in $0.id == self?.meeting.id }
.sink { [weak self] savedMeeting in
guard let self, !self.isDeleted, self.meeting != savedMeeting else { return }
self.meeting = savedMeeting
self.refreshRecoveryAudioFolder()
}
.store(in: &cancellables)
@@ -212,9 +221,11 @@ class MeetingViewModel: ObservableObject {
func stopRecording() { func stopRecording() {
isStartingRecording = true isStartingRecording = true
Task { Task {
let chunks = await recordingSessionManager.stopRecording() let completion = await recordingSessionManager.stopRecording()
meeting.transcriptChunks = chunks meeting.transcriptChunks = completion.chunks
meeting.recoveryAudioFolderName = recordingSessionManager.lastRecoveryAudioFolderName meeting.recoveryAudioFolderName = completion.recoveryAudioFolderName
meeting.transcriptionError = completion.transcriptionError
errorMessage = completion.transcriptionError
refreshRecoveryAudioFolder() refreshRecoveryAudioFolder()
saveMeeting() saveMeeting()
if !meeting.formattedTranscript.isEmpty { if !meeting.formattedTranscript.isEmpty {
@@ -238,6 +249,7 @@ class MeetingViewModel: ObservableObject {
) )
meeting.transcriptChunks = chunks meeting.transcriptChunks = chunks
meeting.recoveryAudioFolderName = recoveryAudioFolderURL.lastPathComponent meeting.recoveryAudioFolderName = recoveryAudioFolderURL.lastPathComponent
meeting.transcriptionError = nil
selectedTab = .transcript selectedTab = .transcript
guard saveMeeting() else { guard saveMeeting() else {
@@ -247,6 +259,8 @@ class MeetingViewModel: ObservableObject {
await generateNotes() await generateNotes()
} catch { } catch {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
meeting.transcriptionError = error.localizedDescription
_ = saveMeeting()
print("Retry transcription failed: \(error)") print("Retry transcription failed: \(error)")
} }
} }
+143 -19
View File
@@ -6,6 +6,8 @@ struct MeetingListView: View {
@StateObject private var recordingSessionManager = RecordingSessionManager.shared @StateObject private var recordingSessionManager = RecordingSessionManager.shared
@State private var selectedMeeting: Meeting? @State private var selectedMeeting: Meeting?
@State private var navigationPath = NavigationPath() @State private var navigationPath = NavigationPath()
@State private var recordingFailureMessage: String?
@State private var failedMeetingID: UUID?
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
@@ -23,6 +25,38 @@ struct MeetingListView: View {
.background(Color.clear) .background(Color.clear)
} }
} }
.onReceive(recordingSessionManager.$activeMeetingId.compactMap { $0 }) { meetingID in
if let meeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meetingID }) {
selectedMeeting = meeting
}
}
.onReceive(NotificationCenter.default.publisher(for: .meetingSaved)) { notification in
guard let savedMeeting = notification.object as? Meeting,
selectedMeeting?.id == savedMeeting.id else { return }
selectedMeeting = savedMeeting
}
.onReceive(recordingSessionManager.$errorMessage.compactMap { $0 }) { message in
failedMeetingID = recordingSessionManager.activeMeetingId
recordingFailureMessage = message
}
.alert("Transcription Failed", isPresented: Binding(
get: { recordingFailureMessage != nil },
set: { if !$0 { recordingFailureMessage = nil } }
)) {
if failedMeetingID != nil {
Button("View Meeting") {
selectFailedMeeting()
recordingFailureMessage = nil
recordingSessionManager.errorMessage = nil
}
}
Button("OK") {
recordingFailureMessage = nil
recordingSessionManager.errorMessage = nil
}
} message: {
Text(recordingFailureMessage ?? "")
}
} }
private var sidebarContent: some View { private var sidebarContent: some View {
@@ -83,10 +117,19 @@ struct MeetingListView: View {
NavigationStack(path: $navigationPath) { NavigationStack(path: $navigationPath) {
Group { Group {
if let selectedMeeting = selectedMeeting { if let selectedMeeting = selectedMeeting {
MeetingDetailContentView(meeting: selectedMeeting, onDelete: { MeetingDetailContentView(
// When a meeting is deleted from the detail view, clear the selection meeting: selectedMeeting,
self.selectedMeeting = nil mergeCandidate: viewModel.previousMeeting(for: selectedMeeting),
}) onMerge: { meeting in
guard let merged = viewModel.mergeIntoPrevious(meeting) else { return false }
self.selectedMeeting = merged
return true
},
onDelete: {
// When a meeting is deleted from the detail view, clear the selection
self.selectedMeeting = nil
}
)
.id(selectedMeeting.id) // Force recreation when selection changes .id(selectedMeeting.id) // Force recreation when selection changes
} else { } else {
ContentUnavailableView( ContentUnavailableView(
@@ -152,6 +195,14 @@ struct MeetingListView: View {
return DayGroup(day: dayString, date: date, meetings: meetings.sorted { $0.date > $1.date }) return DayGroup(day: dayString, date: date, meetings: meetings.sorted { $0.date > $1.date })
}.sorted { $0.date > $1.date } }.sorted { $0.date > $1.date }
} }
private func selectFailedMeeting() {
guard let failedMeetingID,
let meeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == failedMeetingID }) else {
return
}
selectedMeeting = meeting
}
} }
struct DayGroup { struct DayGroup {
@@ -173,7 +224,14 @@ struct MeetingRowView: View {
.foregroundColor(.red) .foregroundColor(.red)
.font(.headline) .font(.headline)
} }
Text(meeting.title.isEmpty ? "Untitled meeting" : meeting.title) if meeting.transcriptionError != nil {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.accessibilityLabel("Transcription failed")
}
Text(meeting.title.isEmpty
? (meeting.transcriptionError == nil ? "Untitled meeting" : "Transcription failed")
: meeting.title)
.font(.headline) .font(.headline)
.lineLimit(1) .lineLimit(1)
} }
@@ -229,12 +287,22 @@ struct MeetingDetailContentView: View {
@StateObject private var viewModel: MeetingViewModel @StateObject private var viewModel: MeetingViewModel
@StateObject private var recordingSessionManager = RecordingSessionManager.shared @StateObject private var recordingSessionManager = RecordingSessionManager.shared
@State private var showDeleteAlert = false @State private var showDeleteAlert = false
@State private var showMergeAlert = false
@State private var isEditing = false @State private var isEditing = false
@State private var showCopyConfirmation = false @State private var showCopyConfirmation = false
let mergeCandidate: Meeting?
let onMerge: (Meeting) -> Bool
let onDelete: () -> Void let onDelete: () -> Void
init(meeting: Meeting, onDelete: @escaping () -> Void) { init(
meeting: Meeting,
mergeCandidate: Meeting?,
onMerge: @escaping (Meeting) -> Bool,
onDelete: @escaping () -> Void
) {
self._viewModel = StateObject(wrappedValue: MeetingViewModel(meeting: meeting)) self._viewModel = StateObject(wrappedValue: MeetingViewModel(meeting: meeting))
self.mergeCandidate = mergeCandidate
self.onMerge = onMerge
self.onDelete = onDelete self.onDelete = onDelete
} }
@@ -276,6 +344,17 @@ struct MeetingDetailContentView: View {
Divider() Divider()
} }
if mergeCandidate != nil {
Button {
showMergeAlert = true
} label: {
Label("Merge into Previous Meeting", systemImage: "arrow.triangle.merge")
}
.disabled(recordingSessionManager.isRecording || recordingSessionManager.isProcessing)
Divider()
}
Button("Delete Meeting", role: .destructive) { Button("Delete Meeting", role: .destructive) {
showDeleteAlert = true showDeleteAlert = true
} }
@@ -461,6 +540,23 @@ struct MeetingDetailContentView: View {
} message: { } message: {
Text("Are you sure you want to delete this meeting? This action cannot be undone.") Text("Are you sure you want to delete this meeting? This action cannot be undone.")
} }
.alert("Merge into Previous Meeting?", isPresented: $showMergeAlert) {
Button("Merge", role: .destructive) {
// Prevent the disappearing detail view from auto-saving the
// continuation after the storage layer removes it.
viewModel.isDeleted = true
if !onMerge(viewModel.meeting) {
viewModel.isDeleted = false
viewModel.errorMessage = "The meetings could not be merged. Their original records and audio were kept."
}
}
Button("Cancel", role: .cancel) { }
} message: {
let previousTitle = mergeCandidate?.title.isEmpty == false
? mergeCandidate?.title ?? "the previous meeting"
: "the previous meeting"
Text("This combines this meeting's transcript, notes, and saved audio into \"\(previousTitle)\", keeps the earlier meeting's title and time, then removes this continuation.")
}
.onDisappear { .onDisappear {
// A failed recording may still be empty. Keep it until the user // A failed recording may still be empty. Keep it until the user
// explicitly deletes it so app updates cannot erase history. // explicitly deletes it so app updates cannot erase history.
@@ -495,24 +591,52 @@ struct MeetingDetailContentView: View {
} }
private var transcriptView: some View { private var transcriptView: some View {
ScrollView { VStack(alignment: .leading, spacing: 10) {
if viewModel.meeting.collapsedTranscriptChunks.isEmpty { if let transcriptionError = viewModel.meeting.transcriptionError {
Text("Transcript will appear here...") HStack(alignment: .top, spacing: 10) {
.frame(maxWidth: .infinity, alignment: .leading) Image(systemName: "exclamationmark.triangle.fill")
.padding() .foregroundColor(.orange)
.foregroundColor(.secondary) VStack(alignment: .leading, spacing: 4) {
} else { Text("Transcription failed")
LazyVStack(alignment: .leading, spacing: 4) { .font(.headline)
ForEach(viewModel.meeting.collapsedTranscriptChunks) { chunk in Text(transcriptionError)
CollapsedTranscriptChunkView(chunk: chunk) .font(.caption)
.foregroundColor(.secondary)
}
Spacer()
if viewModel.recoveryAudioFolderURL != nil {
Button("Retry") {
viewModel.retryTranscription()
}
.disabled(!viewModel.canRetryTranscription)
} }
} }
.padding() .padding()
.background(Color.orange.opacity(0.08))
.cornerRadius(8)
} }
ScrollView {
if viewModel.meeting.collapsedTranscriptChunks.isEmpty {
Text(viewModel.meeting.transcriptionError == nil
? "Transcript will appear here..."
: "No transcript was produced. The recording was kept and can be retried.")
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.foregroundColor(.secondary)
} else {
LazyVStack(alignment: .leading, spacing: 4) {
ForEach(viewModel.meeting.collapsedTranscriptChunks) { chunk in
CollapsedTranscriptChunkView(chunk: chunk)
}
}
.padding()
}
}
.frame(maxHeight: .infinity)
.background(Color.gray.opacity(0.05))
.cornerRadius(8)
} }
.frame(maxHeight: .infinity)
.background(Color.gray.opacity(0.05))
.cornerRadius(8)
} }
private var enhancedNotesView: some View { private var enhancedNotesView: some View {
+1 -1
View File
@@ -255,7 +255,7 @@ struct SettingsView: View {
// Link to GitHub repository // Link to GitHub repository
Link("GitHub", Link("GitHub",
destination: URL(string: "https://github.com/superdooper86/meetingnotes")!) destination: URL(string: "https://git.jamesbone.net/coder/meetingnotes")!)
.foregroundColor(.blue) .foregroundColor(.blue)
// Link to landing page // Link to landing page
@@ -1,34 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPOSITORY="${REPOSITORY:-superdooper86/meetingnotes}"
if ! command -v gh >/dev/null 2>&1; then
echo "Install GitHub CLI first: brew install gh" >&2
exit 1
fi
gh auth status >/dev/null
read -r -p "Developer ID certificate (.p12) path: " certificate_path
if [[ ! -f "$certificate_path" ]]; then
echo "Certificate not found: $certificate_path" >&2
exit 1
fi
read -r -s -p "Certificate export password: " certificate_password
printf '\n'
read -r -p "Apple ID email: " apple_id
read -r -p "Apple Developer Team ID: " team_id
read -r -s -p "Apple app-specific password: " app_password
printf '\n'
base64 < "$certificate_path" | gh secret set APPLE_CERTIFICATE_P12 -R "$REPOSITORY"
printf '%s' "$certificate_password" | gh secret set APPLE_CERTIFICATE_PASSWORD -R "$REPOSITORY"
printf '%s' "$apple_id" | gh secret set APPLE_ID -R "$REPOSITORY"
printf '%s' "$team_id" | gh secret set APPLE_TEAM_ID -R "$REPOSITORY"
printf '%s' "$app_password" | gh secret set APPLE_APP_PASSWORD -R "$REPOSITORY"
unset certificate_password app_password
echo "Apple release secrets configured for $REPOSITORY."
-139
View File
@@ -1,139 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="Meetingnotes"
RUNNER_TEMP="${RUNNER_TEMP:-/tmp}"
PENDING_DIR="${PENDING_DIR:-$RUNNER_TEMP/meetingnotes-pending}"
WORK_ROOT="${WORK_ROOT:-$RUNNER_TEMP/meetingnotes-finalize}"
APP_PATH="$WORK_ROOT/$APP_NAME.app"
RELEASE_DIR="$WORK_ROOT/release"
VERSION_PATH="$PENDING_DIR/version"
COMMIT_SHA_PATH="$PENDING_DIR/commit-sha"
SUBMISSION_PATH="$PENDING_DIR/notary-submission.json"
PRE_NOTARY_ZIP="$PENDING_DIR/$APP_NAME-pre-notary.zip"
required_variables=(
APPLE_ID
APPLE_TEAM_ID
APPLE_APP_PASSWORD
SPARKLE_PRIVATE_KEY
GH_TOKEN
GITHUB_REPOSITORY
)
for variable in "${required_variables[@]}"; do
if [[ -z "${!variable:-}" ]]; then
echo "Missing required environment variable: $variable" >&2
exit 1
fi
done
for path in "$VERSION_PATH" "$COMMIT_SHA_PATH" "$SUBMISSION_PATH" "$PRE_NOTARY_ZIP" "$PENDING_DIR/generate_appcast"; do
if [[ ! -e "$path" ]]; then
echo "Missing release submission artifact: $path" >&2
exit 1
fi
done
VERSION=$(<"$VERSION_PATH")
COMMIT_SHA=$(<"$COMMIT_SHA_PATH")
SUBMISSION_ID=$(plutil -extract id raw -o - "$SUBMISSION_PATH")
TAG="v$VERSION"
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release already exists: $TAG"
exit 0
fi
STATUS_PATH="$WORK_ROOT/notary-status.json"
rm -rf "$WORK_ROOT"
mkdir -p "$RELEASE_DIR"
notary_info_succeeded=false
for attempt in 1 2 3; do
if xcrun notarytool info "$SUBMISSION_ID" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_PASSWORD" \
--output-format json > "$STATUS_PATH"; then
notary_info_succeeded=true
break
fi
echo "Notary status check failed ($attempt/3); retrying"
sleep 15
done
if [[ "$notary_info_succeeded" != true ]]; then
echo "Unable to query Apple notarization status after three attempts" >&2
exit 1
fi
NOTARY_STATUS=$(plutil -extract status raw -o - "$STATUS_PATH")
echo "Notarization status for $SUBMISSION_ID: $NOTARY_STATUS"
case "$NOTARY_STATUS" in
"In Progress")
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
printf 'Apple is still processing Meetingnotes %s. Submission: `%s`. The scheduled workflow will check again.\n' \
"$VERSION" "$SUBMISSION_ID" >> "$GITHUB_STEP_SUMMARY"
fi
exit 0
;;
Accepted)
;;
Invalid|Rejected)
xcrun notarytool log "$SUBMISSION_ID" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_PASSWORD" || true
exit 1
;;
*)
echo "Unexpected notarization status: $NOTARY_STATUS" >&2
exit 1
;;
esac
ditto -x -k "$PRE_NOTARY_ZIP" "$WORK_ROOT"
if [[ ! -d "$APP_PATH" ]]; then
echo "Signed app was not found after extracting $PRE_NOTARY_ZIP" >&2
exit 1
fi
APP_VERSION=$(plutil -extract CFBundleShortVersionString raw -o - "$APP_PATH/Contents/Info.plist")
if [[ "$APP_VERSION" != "$VERSION" ]]; then
echo "Signed app version $APP_VERSION does not match release version $VERSION" >&2
exit 1
fi
xcrun stapler staple "$APP_PATH"
xcrun stapler validate "$APP_PATH"
spctl --assess --type execute --verbose=2 "$APP_PATH"
ARCHIVE_NAME="$APP_NAME-$VERSION.zip"
ARCHIVE_PATH="$RELEASE_DIR/$ARCHIVE_NAME"
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ARCHIVE_PATH"
GENERATE_APPCAST="$PENDING_DIR/generate_appcast"
chmod +x "$GENERATE_APPCAST"
DOWNLOAD_URL="https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG/"
printf '%s' "$SPARKLE_PRIVATE_KEY" | "$GENERATE_APPCAST" "$RELEASE_DIR" \
--ed-key-file - \
--download-url-prefix "$DOWNLOAD_URL" \
--maximum-deltas 0 \
-o "$RELEASE_DIR/appcast.xml"
grep -q "$DOWNLOAD_URL$ARCHIVE_NAME" "$RELEASE_DIR/appcast.xml"
grep -q 'sparkle:edSignature=' "$RELEASE_DIR/appcast.xml"
gh release \
create "$TAG" \
"$ARCHIVE_PATH" \
"$RELEASE_DIR/appcast.xml" \
--repo "$GITHUB_REPOSITORY" \
--target "$COMMIT_SHA" \
--title "Meetingnotes $VERSION" \
--generate-notes
echo "Published Meetingnotes $VERSION"
+7 -5
View File
@@ -14,11 +14,13 @@ APP_PATH="$DERIVED_DATA/Build/Products/Release/$APP_NAME.app"
required_variables=( required_variables=(
VERSION VERSION
SIGNING_IDENTITY SIGNING_IDENTITY
SIGNING_KEYCHAIN
APPLE_ID APPLE_ID
APPLE_TEAM_ID APPLE_TEAM_ID
APPLE_APP_PASSWORD APPLE_APP_PASSWORD
SPARKLE_PRIVATE_KEY SPARKLE_PRIVATE_KEY
GITHUB_REPOSITORY GITHUB_REPOSITORY
RELEASE_BASE_URL
) )
for variable in "${required_variables[@]}"; do for variable in "${required_variables[@]}"; do
@@ -57,12 +59,12 @@ SPARKLE_FRAMEWORK="$APP_PATH/Contents/Frameworks/Sparkle.framework"
SPARKLE_CONTENTS="$SPARKLE_FRAMEWORK/Versions/B" SPARKLE_CONTENTS="$SPARKLE_FRAMEWORK/Versions/B"
sign_component() { sign_component() {
codesign --force --timestamp --options runtime --sign "$SIGNING_IDENTITY" "$1" codesign --force --timestamp --options runtime --keychain "$SIGNING_KEYCHAIN" --sign "$SIGNING_IDENTITY" "$1"
} }
sign_component "$SPARKLE_CONTENTS/XPCServices/Installer.xpc" sign_component "$SPARKLE_CONTENTS/XPCServices/Installer.xpc"
if [[ -d "$SPARKLE_CONTENTS/XPCServices/Downloader.xpc" ]]; then if [[ -d "$SPARKLE_CONTENTS/XPCServices/Downloader.xpc" ]]; then
codesign --force --timestamp --options runtime \ codesign --force --timestamp --options runtime --keychain "$SIGNING_KEYCHAIN" \
--preserve-metadata=entitlements \ --preserve-metadata=entitlements \
--sign "$SIGNING_IDENTITY" \ --sign "$SIGNING_IDENTITY" \
"$SPARKLE_CONTENTS/XPCServices/Downloader.xpc" "$SPARKLE_CONTENTS/XPCServices/Downloader.xpc"
@@ -71,7 +73,7 @@ sign_component "$SPARKLE_CONTENTS/Autoupdate"
sign_component "$SPARKLE_CONTENTS/Updater.app" sign_component "$SPARKLE_CONTENTS/Updater.app"
sign_component "$SPARKLE_FRAMEWORK" sign_component "$SPARKLE_FRAMEWORK"
codesign --force --timestamp --options runtime \ codesign --force --timestamp --options runtime --keychain "$SIGNING_KEYCHAIN" \
--entitlements meetingnotes/meetingnotes.entitlements \ --entitlements meetingnotes/meetingnotes.entitlements \
--sign "$SIGNING_IDENTITY" \ --sign "$SIGNING_IDENTITY" \
"$APP_PATH" "$APP_PATH"
@@ -116,7 +118,7 @@ if [[ -z "$GENERATE_APPCAST" ]]; then
exit 1 exit 1
fi fi
DOWNLOAD_URL="https://github.com/$GITHUB_REPOSITORY/releases/download/v$VERSION/" DOWNLOAD_URL="$RELEASE_BASE_URL/releases/download/v$VERSION/"
printf '%s' "$SPARKLE_PRIVATE_KEY" | "$GENERATE_APPCAST" "$RELEASE_DIR" \ printf '%s' "$SPARKLE_PRIVATE_KEY" | "$GENERATE_APPCAST" "$RELEASE_DIR" \
--ed-key-file - \ --ed-key-file - \
--download-url-prefix "$DOWNLOAD_URL" \ --download-url-prefix "$DOWNLOAD_URL" \
@@ -127,7 +129,7 @@ grep -q "$DOWNLOAD_URL$ARCHIVE_NAME" "$RELEASE_DIR/appcast.xml"
grep -q 'sparkle:edSignature=' "$RELEASE_DIR/appcast.xml" grep -q 'sparkle:edSignature=' "$RELEASE_DIR/appcast.xml"
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
printf 'Built, Developer ID-signed, notarized, and stapled Meetingnotes %s. The GitHub release is ready to publish.\n' \ printf 'Built, Developer ID-signed, notarized, and stapled Meetingnotes %s. The Gitea release is ready to publish.\n' \
"$VERSION" >> "$GITHUB_STEP_SUMMARY" "$VERSION" >> "$GITHUB_STEP_SUMMARY"
fi fi
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Publish both signed assets together, keeping incomplete uploads as a draft."""
import json
import os
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
base = os.environ["GITEA_SERVER_URL"].rstrip("/") + "/api/v1/repos/" + os.environ["GITHUB_REPOSITORY"]
token = os.environ["GITEA_TOKEN"]
version = os.environ["VERSION"]
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
def request(path, method="GET", data=None, binary=False):
body = data if binary else None if data is None else json.dumps(data).encode()
req = urllib.request.Request(base + path, method=method, data=body, headers={
"Authorization": "token " + token,
"Content-Type": "application/octet-stream" if binary else "application/json",
"User-Agent": "Meetingnotes-release",
})
with urllib.request.build_opener(NoRedirect()).open(req, timeout=120) as response:
raw = response.read()
return json.loads(raw) if raw else None
release_dir = Path(os.environ["RUNNER_TEMP"]) / "meetingnotes-release/release"
assets = [release_dir / f"Meetingnotes-{version}.zip", release_dir / "appcast.xml"]
for asset in assets:
if not asset.is_file() or not asset.stat().st_size:
raise RuntimeError(f"Missing release artifact: {asset.name}")
release = request("/releases", "POST", {
"tag_name": "v" + version, "target_commitish": os.environ["GITHUB_SHA"],
"name": "Meetingnotes " + version, "draft": True, "prerelease": False,
"body": "Developer ID signed, Apple notarized, and signed for Sparkle updates.\n\nBuilt from main at `" + os.environ["GITHUB_SHA"] + "`.",
})
for asset in assets:
request(f"/releases/{release['id']}/assets?name=" + urllib.parse.quote(asset.name), "POST", asset.read_bytes(), binary=True)
request(f"/releases/{release['id']}", "PATCH", {"draft": False})
print("Published Meetingnotes " + version)
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Check the CI app's unauthenticated API without changing saved preferences."""
import json
import plistlib
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
# Launch from the Mac's internal volume; the runner checkout is on removable storage.
cache = Path.home() / "Library/Caches/meetingnotes-ci"
cache.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="smoke-", dir=cache) as directory:
staged = Path(directory) / "Meetingnotes.app"
subprocess.run(["ditto", sys.argv[1], str(staged)], check=True)
app = staged / "Contents/MacOS/Meetingnotes"
with (staged / "Contents/Info.plist").open("rb") as info_file:
bundle_id = plistlib.load(info_file)["CFBundleIdentifier"]
assert bundle_id == "net.jamesbone.meetingnotes.ci", "Smoke tests require the isolated CI app"
def clear_test_token():
result = subprocess.run(
["security", "delete-generic-password", "-s", bundle_id, "-a", "muteDeckAPIToken"],
capture_output=True, timeout=10,
)
if result.returncode not in (0, 44): # 44: item does not exist
raise RuntimeError("Could not clear the CI Keychain token")
clear_test_token()
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
port = listener.getsockname()[1]
with tempfile.TemporaryFile() as log:
process = subprocess.Popen(
[str(app), "-muteDeckAPIEnabled", "YES", "-muteDeckAPIPort", str(port), "-hasCompletedOnboarding", "YES", "-hasAcceptedTerms", "YES", "-SUEnableAutomaticChecks", "NO"],
stdout=log, stderr=subprocess.STDOUT,
)
try:
for attempt in range(30):
if process.poll() is not None:
raise RuntimeError("CI app exited before the API became ready")
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/info", timeout=2) as response:
info = json.load(response)
break
except (urllib.error.URLError, TimeoutError):
time.sleep(1)
else:
raise RuntimeError("CI API did not become ready")
assert info["name"] == "MeetingDebrief", "Unexpected API identity"
try:
urllib.request.urlopen(f"http://127.0.0.1:{port}/api/recording/status", timeout=2)
except urllib.error.HTTPError as error:
assert error.code == 401, f"Unexpected status: {error.code}"
else:
raise RuntimeError("Recording status allowed an unauthenticated request")
print("Local API readiness and authentication checks passed")
except Exception:
if process.poll() is None:
sample = subprocess.run(["sample", str(process.pid), "1", "1"], capture_output=True, text=True, timeout=10)
print(sample.stdout.split("Binary Images:")[0], file=sys.stderr)
log.seek(0)
print(log.read().decode(errors="replace")[-8000:], file=sys.stderr)
raise
finally:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
clear_test_token()
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Import the CI certificate into an isolated keychain for one release command."""
import base64
import os
import re
import secrets
import subprocess
import sys
import tempfile
from pathlib import Path
def security(*args):
result = subprocess.run(["security", *args], capture_output=True, text=True)
if result.returncode:
raise RuntimeError(f"security {args[0]} failed (output withheld to protect credentials)")
return result.stdout
with tempfile.TemporaryDirectory(prefix="meetingnotes-signing-", dir=os.environ["RUNNER_TEMP"]) as directory:
keychain = str(Path(directory) / "release.keychain-db")
certificate = Path(directory) / "certificate.p12"
certificate.write_bytes(base64.b64decode(os.environ["APPLE_CERTIFICATE_P12"]))
certificate.chmod(0o600)
password = secrets.token_urlsafe(32)
created = False
try:
security("create-keychain", "-p", password, keychain)
created = True
security("set-keychain-settings", "-lut", "21600", keychain)
security("unlock-keychain", "-p", password, keychain)
security("import", str(certificate), "-k", keychain, "-P", os.environ["APPLE_CERTIFICATE_PASSWORD"], "-T", "/usr/bin/codesign", "-T", "/usr/bin/security")
security("set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychain)
identities = security("find-identity", "-v", "-p", "codesigning", keychain)
match = re.search(r'([0-9A-F]{40}) "Developer ID Application:', identities)
if not match:
raise RuntimeError("The certificate contains no valid Developer ID Application identity")
environment = dict(os.environ, SIGNING_IDENTITY=match[1], SIGNING_KEYCHAIN=keychain)
status = subprocess.run(sys.argv[1:], env=environment).returncode
finally:
if created:
security("delete-keychain", keychain)
sys.exit(status)