Compare commits

..
Author SHA1 Message Date
james 628587ae92 feat: retry failed transcriptions 2026-07-20 13:13:15 +02:00
james f992457cfe feat: add menu bar mode 2026-07-17 12:46:34 +02:00
james ad995b59ad feat: generate meeting titles automatically 2026-07-17 12:15:04 +02:00
james a13153b8fe fix: isolate system audio converter callbacks 2026-07-17 11:20:53 +02:00
james 0008bd3081 fix: own system audio buffers before conversion 2026-07-17 11:19:28 +02:00
james 6bcdd51201 fix: retain failed and empty meetings 2026-07-17 10:37:32 +02:00
james ea44104458 fix: synchronize audio capture teardown 2026-07-17 10:30:42 +02:00
james 336fb95b07 fix: restore multiline template editors 2026-07-15 23:45:54 +02:00
james c7e29828b4 fix: prevent duplicate template editor navigation 2026-07-15 23:36:33 +02:00
james 97eb58d1dc feat: use a default template for automatic notes 2026-07-15 23:28:39 +02:00
james bcc16cd53f fix: stabilize template editor layout 2026-07-15 23:26:08 +02:00
james 25ae1ae710 ci: use current bundle preferences domain 2026-07-15 20:14:32 +02:00
james 47ca61a811 release: sign 1.1.3 without notarization 2026-07-15 20:10:48 +02:00
james e3397e5e85 ci: label disabled legacy publisher 2026-07-15 20:00:15 +02:00
james d69968101a fix: resolve finalizer runner paths at runtime 2026-07-15 19:58:17 +02:00
james 11dfd97801 ci: finalize notarization asynchronously 2026-07-15 19:55:12 +02:00
james e7d5455ec4 ci: allow delayed Apple notarization 2026-07-15 17:48:16 +02:00
coderandjames 70a077492e Make release notarization resilient (#8)
Co-authored-by: superdooper86 <[email protected]>
2026-07-15 16:12:08 +02:00
SuperDooper 5f128fd0cb Delete CONTRIBUTING.md file
Removed the entire contributing guide for the Meetingnotes app.
2026-07-15 15:42:03 +02:00
SuperDooper 6f60933930 Remove MacOS download link from README
Removed the download link for MacOS 14 from the README.
2026-07-15 15:41:40 +02:00
SuperDooper f77b9f4c98 Clean up README by removing unnecessary sections
Removed sections related to Recall.ai, demo, table of contents, project description, features, local development, and prerequisites for releasing a new version.
2026-07-15 15:41:01 +02:00
coderandjames 37e21bf398 Add signed releases and automatic updates (#7)
* feat: prepare stable signed updates

* ci: automate signed macOS releases

* ci: publish releases atomically

* fix: use compatible atomic replacement API

---------

Co-authored-by: superdooper86 <[email protected]>
2026-07-15 15:23:08 +02:00
coderandjames d8f5c33141 fix: make post-meeting transcription reliable (#6)
Co-authored-by: superdooper86 <[email protected]>
2026-07-15 14:21:24 +02:00
34 changed files with 1409 additions and 483 deletions
+2 -2
View File
@@ -35,8 +35,8 @@ jobs:
| 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: |
defaults write owen.meetingnotes muteDeckAPIEnabled -bool true defaults write net.jamesbone.meetingnotes muteDeckAPIEnabled -bool true
defaults write owen.meetingnotes muteDeckAPIPort -int 19880 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 & "$RUNNER_TEMP/DerivedData/Build/Products/Release/Meetingnotes.app/Contents/MacOS/Meetingnotes" >"$RUNNER_TEMP/meetingnotes.log" 2>&1 &
app_pid=$! app_pid=$!
trap 'kill "$app_pid" 2>/dev/null || true' EXIT trap 'kill "$app_pid" 2>/dev/null || true' EXIT
+92
View File
@@ -0,0 +1,92 @@
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
+82
View File
@@ -0,0 +1,82 @@
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 }}
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 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 and sign 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 git rev-parse "$tag" >/dev/null 2>&1; then
echo "Tag already exists: $tag" >&2
exit 1
fi
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
-38
View File
@@ -1,39 +1 @@
# Contributing to Meetingnotes
I anticipate the main users of this app to be technical, given the need to bring your own API key.
As you use the app, you will come across bugs and have desires for new features. It is my hope that you will then be eager to contribute to improve the app.
Since you're here, maybe that's you! Please take a moment to read this guide before making a pull request.
Rules:
- Be respectful
- Open an issue first for major changes
- Use conventional commits
---
## How to Contribute
1. **Fork** the repo
2. **Create a branch**:
`git checkout -b my-feature`
3. **Commit your changes**:
`git commit -m "feat: cool feature"`
4. **Push to your fork**:
`git push origin my-feature`
5. **Open a pull request**
---
## Contributor License Notice
By submitting a pull request or otherwise contributing to this project, you agree to the following:
- You license your contribution under the same license as this project (LGPL-3.0).
- You grant the me the right to relicense your contribution, including under proprietary or commercial licenses in the future.
This helps keep the project open-source while also allowing for commercial options down the road, such as a hosted version where users could pay through the app instead of using their own API key.
If youre not comfortable with this, please feel free to contact me or open an issue to discuss alternatives.
+9 -9
View File
@@ -276,9 +276,9 @@
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 = 13; CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = ML6HYR5LUR; DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -290,10 +290,10 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.1; MARKETING_VERSION = 1.1.12;
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 = owen.meetingnotes; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -312,9 +312,9 @@
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 = 13; CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = ML6HYR5LUR; DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -326,10 +326,10 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.1; MARKETING_VERSION = 1.1.12;
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 = owen.meetingnotes; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -366,7 +366,7 @@
repositoryURL = "https://github.com/sparkle-project/Sparkle.git"; repositoryURL = "https://github.com/sparkle-project/Sparkle.git";
requirement = { requirement = {
kind = upToNextMajorVersion; kind = upToNextMajorVersion;
minimumVersion = 2.7.1; minimumVersion = 2.9.4;
}; };
}; };
CCC33F7F2E236B6F00EDE382 /* XCRemoteSwiftPackageReference "posthog-ios" */ = { CCC33F7F2E236B6F00EDE382 /* XCRemoteSwiftPackageReference "posthog-ios" */ = {
@@ -15,8 +15,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle.git", "location" : "https://github.com/sparkle-project/Sparkle.git",
"state" : { "state" : {
"revision" : "df074165274afaa39539c05d57b0832620775b11", "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
"version" : "2.7.1" "version" : "2.9.4"
} }
}, },
{ {
+14 -120
View File
@@ -6,95 +6,9 @@
<p align="center"> <p align="center">
The Free, Open-Source AI Notetaker for Busy Engineers The Free, Open-Source AI Notetaker for Busy Engineers
<br />
<a href="https://github.com/owengretzinger/meetingnotes/releases/latest/download/Meetingnotes.dmg">Download for MacOS 14+</a>
</p> </p>
</div> </div>
## Recall.ai - Meeting Transcription API
Meetingnotes runs locally, capturing two streams: system & mic.
If youre looking for a transcription API for meetings, consider checking out [Recall.ai](
https://www.recall.ai?utm_source=github&utm_medium=sponsorship&utm_campaign=owengretzinger+meetingnotes), an API that works with Zoom, Google Meet, Microsoft Teams, and more. Recall.ai diarizes by pulling the speaker data and separate audio streams from the meeting platforms, which means 100% accurate speaker diarization with actual speaker names.
## Demo
https://github.com/user-attachments/assets/cadd4504-e9d9-4ccd-874d-41d8a84f4c9d
<!--
## Table of Contents
<details>
<summary>Table of Contents</summary>
<ol>
<li>
<a href="#about-the-project">About The Project</a>
<ul>
<li><a href="#key-features">Key Features</a></li>
</ul>
</li>
<li><a href="#architecture">Architecture</a></li>
<li>
<a href="#getting-started">Getting Started</a>
<ul>
<li><a href="#prerequisites">Prerequisites</a></li>
<li><a href="#installation">Installation</a></li>
</ul>
</li>
<li><a href="#acknowledgments">Acknowledgments</a></li>
</ol>
</details>
## About The Project
Brief description of the project.
### Key Features
- **Feature 1:** ...
- **Feature 2:** ...
- ...
## Architecture
![Architecture Diagram](https://github.com/user-attachments/assets/75adc7aa-7719-4c4f-a9bb-3ba847e12e9f)
(Insert the different technologies used in the project here — could split this into frontend, backend, etc)
(Don't explain what well-known technologies like React are)
## Getting Started
### Prerequisites
- Requirement 1
- Requirement 2
```sh
installation command (if applicable)
```
### Installation
Instructions for cloning the repo, installing packages, configuring environment variables, etc:
1. Step 1
```sh
command
```
2. Step 2
```sh
command
```
3. ...
## Acknowledgments
- This README was created using [gitreadme.dev](https://gitreadme.dev) — an AI tool that looks at your entire codebase to instantly generate high-quality README files.
- (Only include unique things that you are sure should be specifically acknowledged. Don't include libraries or tools like React, Next.js, etc. Don't include services like Vercel, OpenAI, Google Cloud, JetBrains, etc. Stay on the safe side since more can be added later. Do not hallucinate.)
-->
## Features ## Features
Implemented: Implemented:
@@ -128,18 +42,10 @@ Later:
- AI chat for asking questions about a meeting - AI chat for asking questions about a meeting
- Integrations for email, Slack, Notion, etc. - Integrations for email, Slack, Notion, etc.
## Local Development
Open the project in Xcode. Command+R to build it and run it.
## Releasing a New Version ## Releasing a New Version
Follow these steps to create a new release with auto-updates: Production releases are Developer ID signed, notarized by Apple, published to
GitHub Releases, and signed for Sparkle auto-updates.
### Prerequisites
- Homebrew packages: `brew install create-dmg sparkle`
- Make scripts executable: `chmod +x scripts/update_version.sh scripts/build_release.sh`
### Release Process ### Release Process
@@ -159,31 +65,19 @@ Follow these steps to create a new release with auto-updates:
./scripts/update_version.sh custom 1.2.0 ./scripts/update_version.sh custom 1.2.0
``` ```
2. **Build the release:** 2. Commit and push the version change to `main`.
```bash 3. Run the `Release` workflow from GitHub Actions and enter the version without
./scripts/build_release.sh the `v` prefix. The workflow signs and notarizes the app, generates the
``` signed appcast, creates the version tag, and publishes both release assets.
This will: The app checks
`https://github.com/superdooper86/meetingnotes/releases/latest/download/appcast.xml`
and installs later releases automatically through Sparkle.
- Clean build the app in Release mode ### Recovering Meetings
- Create a signed DMG file
- Generate the appcast.xml for auto-updates
3. **Create GitHub Release:** 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
- Go to [GitHub Releases](https://github.com/owengretzinger/meetingnotes/releases) old `Meetings` folder. After this one-time transition, the stable signing
- Click "Create a new release" identity keeps the same sandbox container across updates.
- Tag: `v1.0.1` (match the version number)
- Title: `Meetingnotes v1.0.1`
- Upload the DMG and zip files from `releases/` folder
- Generate release notes
4. **Update appcast:**
```bash
git add appcast.xml
git commit -m "Update appcast for v1.0.1"
git push
```
+5 -77
View File
@@ -1,78 +1,6 @@
<?xml version="1.0" standalone="yes"?> <?xml version="1.0" encoding="utf-8"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0"> <rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel> <channel>
<title>Meetingnotes</title> <title>Meetingnotes</title>
<item> </channel>
<title>1.1.1</title> </rss>
<pubDate>Tue, 05 Aug 2025 12:01:01 -0400</pubDate>
<sparkle:version>13</sparkle:version>
<sparkle:shortVersionString>1.1.1</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes-1.1.1.zip" length="9742223" type="application/octet-stream" sparkle:edSignature="IdBhnJDPglM1H+LxjIDMYvXieGyXK8FLV3o1LSBKgR3SAkbwjieN/dW/kWMw4jOL7ousjZMssvL7AVs+4OoeAw==" />
<sparkle:deltas>
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-12.delta" sparkle:deltaFrom="12" length="1495358" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="JHUS+WLJjdvNP+GGj25pUTXbVL5uzc9IO1eLtkFhSvl1+yb7l7btuRTnCPDVmvX/c8rF+MQraNRamrKVcB2eAw==" />
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-11.delta" sparkle:deltaFrom="11" length="2386250" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="sSZug40z/SSNzUROZFM36FyyaJ6LvTqVGZtpYnvBITkd2/fqrbGaKUXCgBKY/vzvdq+jNduKlArSsQUIkRemAA==" />
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-10.delta" sparkle:deltaFrom="10" length="2381262" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="ODG+JexsG6BGjbOyPMTnt9Gfpd+iHHqSPSQBsCJTIMIJ0/RcKa39iZfZ7rkL996sQllFbwdAAph3bDQCYa0NCg==" />
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-9.delta" sparkle:deltaFrom="9" length="2384498" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="66GFKhxIVI5jzrV40NKywvn3Lx8cdxsQEHYqiWWw1IeqSqH4lF00qE1giPJgKdZkEbL6neMjIqofcvqkIbPRAw==" />
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-8.delta" sparkle:deltaFrom="8" length="2970002" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="C+KVv/usN2Vmg8tkJmYRBWrT7bBrvQY2GbTgXuVljvz/HbgnA1fyrsrhIrkXTbWJ8pdH76pn/Lfzw2oYtvkGDQ==" />
</sparkle:deltas>
</item>
<item>
<title>1.1.0</title>
<pubDate>Fri, 25 Jul 2025 17:39:07 -0400</pubDate>
<sparkle:version>12</sparkle:version>
<sparkle:shortVersionString>1.1.0</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes-1.1.0.zip" length="9715895" type="application/octet-stream" sparkle:edSignature="Htx9FYb58yGYIJc+xJd/W2PHgbjsVXY+TL2WsGa8gx4J4f0Uyk+1Uizl8c2lDC4+hIra+b9lIrjG6l69NjU4Cg==" />
<sparkle:deltas>
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-11.delta" sparkle:deltaFrom="11" length="2319634" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="L2IQPoN19GQfVV8leQCCh4U7/1/Oe3SyclBWbT+58VFKI+FH3KGj2vZM1x+v3X3pmyPdSy/S0N9sg+B7jFSUBg==" />
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-10.delta" sparkle:deltaFrom="10" length="2301554" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="+GKgyT7MBDTqot6SFnvQax1il9p3ZEDG5SWg0gCOwMkKPrp6v48GrJY/VAF8tow2RbeVY2Q2HP6DNVPTm1jeDQ==" />
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-9.delta" sparkle:deltaFrom="9" length="2342546" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="lPAYUzZGPYYgb+UWGCv237QvKTH8l3jZVXxyXHsWOtYcDzuGRYiZ39eMYTAF9OMEKbvfm5vQiXXxu42CKnj3DA==" />
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-8.delta" sparkle:deltaFrom="8" length="2940458" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="iL16jVnWBp5Ws/F1B8goazEcGCrrMK9XwH2imslbJiAWlwG5sXAaN2N8XHcHA7xuysQkway/R1vCONjmm/DaDg==" />
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-7.delta" sparkle:deltaFrom="7" length="2923010" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="LQHXBknrE6nFFdpDW4ZWP9f/BBhU3PIUZ7DihMLh+Iu46/8cCv8vDaH7/doEJGoUfa1oEstYsWX0bCdHkKp9Cw==" />
</sparkle:deltas>
</item>
<item>
<title>1.0.6</title>
<pubDate>Mon, 21 Jul 2025 16:21:51 -0400</pubDate>
<sparkle:version>11</sparkle:version>
<sparkle:shortVersionString>1.0.6</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes-1.0.6.zip" length="9530065" type="application/octet-stream" sparkle:edSignature="IxQ4qvkMsUjHaIn4g4/wH1MR1HtpPXJ/N1XaBtCpyeEK45J1UjoIn1zZae8SrHj8zNDglzK+XvV9vZM3iw1nAA==" />
<sparkle:deltas>
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-9.delta" sparkle:deltaFrom="9" length="1081770" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="uvLxEKq4bGpYvCqPPcp3mDal9z7DI3FPwpBMsdqZFUjmnBWXvtha7Pk6Rv+Ua6OuxjsNqOGg5qiwkp9z3J41Aw==" />
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-10.delta" sparkle:deltaFrom="10" length="1192342" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="+u8c+Qeen3ILEKiPtOpW3KINmsBBk6nqBsCr5aZ4i/WqHHVbG/kTe6oWPCWhiq5XFlzNaRpXguDl3PdEhvafCQ==" />
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-8.delta" sparkle:deltaFrom="8" length="2083034" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="uQinSWneeR1R9ad5GaE3Vr2pZI0vO9KGVVf/y5xw31iKvwe6S1fpZTdZpujlE1NZzl2R07ZOPt9mDYu6uMiXAA==" />
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-7.delta" sparkle:deltaFrom="7" length="2075294" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="9u3ldVNYRuIIVGN0zG4T76yQ7JviM39Rl2cnnfUCpDwJ/+KjXgsjwpbelZa/5AWFeKffDth04D6xVRJB5tWfAw==" />
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-6.delta" sparkle:deltaFrom="6" length="2075930" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="AgHLB01YstVzOTxXfief9M2rfxCNcya/w7N6QevalSI3Uxdw8CH5MVF2UGFWy1kdIiuQC7YBsftGmL20L9KzBA==" />
</sparkle:deltas>
</item>
<item>
<title>1.0.5</title>
<pubDate>Thu, 17 Jul 2025 08:36:59 -0400</pubDate>
<sparkle:version>10</sparkle:version>
<sparkle:shortVersionString>1.0.5</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes-1.0.5.zip" length="9519029" type="application/octet-stream" sparkle:edSignature="XBVJM2eh02aEQ6aNotS/3DNYXaOXA7956k/6GWEcfpcDVpJmUMDKcTIX0mtDWJl5YXin6YTA9qpIZDUJmkitBg==" />
<sparkle:deltas>
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-9.delta" sparkle:deltaFrom="9" length="358186" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="WCLkjV3zZwBgy8QEpQVcvI8mLeZVVwHkYMCis3GROY+TlX3o9P12tJfB/Mb0bRI5MHaXgl+6PelbdBC2SLL9Dg==" />
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-8.delta" sparkle:deltaFrom="8" length="2026862" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="wjQTre8hcYt6MS3gIXnXUpv+GnQ7tZALOAajn1jEgY7nNxk5P30v9qESknlDzLNCcOpb9iy/4aFTtv+aDqb1DQ==" />
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-7.delta" sparkle:deltaFrom="7" length="2033094" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="hUqgwwoswcKVUOA4RUs8DVriGh4BXFXZ15gV2TSaCw8C4TX83qgrDVFa6ZjmpFPO23ck5+lY4chyfAzTFHq8Bw==" />
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-6.delta" sparkle:deltaFrom="6" length="2026918" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="L8Y1rn8/8Kh7mVf9WNbYWIrXJo8jNtQ0MK14qWQ+PKzImSUc8nqVZ1uWgI7cClBRaPUhpqcL8CnWGWfI4BgQBg==" />
</sparkle:deltas>
</item>
<item>
<title>1.0.4</title>
<pubDate>Wed, 16 Jul 2025 09:02:52 -0400</pubDate>
<sparkle:version>9</sparkle:version>
<sparkle:shortVersionString>1.0.4</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes-1.0.4.zip" length="9518446" type="application/octet-stream" sparkle:edSignature="aWBAf2cnvhAf1WBEa9oUHzL+N5RBph8Xbsh/XXBhR7wo9Vkeh51CqBh+3pjWJqxaDGxb3aimgydjIKrCVVpyAw==" />
<sparkle:deltas>
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes9-8.delta" sparkle:deltaFrom="8" length="2038454" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="AUNQbGbKk3a7Phzxfm1yEQQ6KuaqvX0NASF7u657DtdkcTbxvBqPBon/JKaZIEVtlnq+qDyjfcUpTY1S1Cj0Cw==" />
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes9-7.delta" sparkle:deltaFrom="7" length="2027690" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="+libxIpQFVA0lsctoJzmH8dNGCcHZLE8agqRKMzbCN1+fRA8Zq0hrvIAxvrpyCim1StmOtvh/zWKz+483SyJBw==" />
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes9-6.delta" sparkle:deltaFrom="6" length="2022146" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="LLFyZQK3TLSNvD8RCpQKNmKfefg8mp9Z7J1uw09zaYhahjSa+hV1jbzkSMIEG6P6ncBScBfmOrhhuimAG6OWBw==" />
</sparkle:deltas>
</item>
</channel>
</rss>
+8 -2
View File
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>LSUIElement</key>
<true/>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsLocalNetworking</key> <key>NSAllowsLocalNetworking</key>
@@ -12,9 +14,13 @@
<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://raw.githubusercontent.com/superdooper86/meetingnotes/main/appcast.xml</string> <string>https://github.com/superdooper86/meetingnotes/releases/latest/download/appcast.xml</string>
<key>SUPublicEDKey</key> <key>SUPublicEDKey</key>
<string>BVXHOV8ZxPxKZ1swhFndymzew9nyd3si7849JA9cqsg=</string> <string>9ZuN9G9ERB3Qoyyd/4FsF+6LMUv5jzAGP26OXAHBiW0=</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUAutomaticallyUpdate</key>
<true/>
<key>SUEnableInstallerLauncherService</key> <key>SUEnableInstallerLauncherService</key>
<true/> <true/>
</dict> </dict>
+201 -38
View File
@@ -3,6 +3,23 @@ import Combine
import Foundation import Foundation
import SwiftUI import SwiftUI
private enum RecoveryTranscriptionError: LocalizedError {
case noAudioFiles
case noSpeech
case requestFailed(String)
var errorDescription: String? {
switch self {
case .noAudioFiles:
return "The saved recovery audio could not be found."
case .noSpeech:
return "No speech was detected in the saved recovery audio."
case .requestFailed(let details):
return "Retry transcription failed for \(details)"
}
}
}
/// 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 {
@@ -14,6 +31,7 @@ final class AudioManager: NSObject, ObservableObject {
@Published var errorMessage: String? @Published var errorMessage: String?
@Published var micAudioLevel: Float = 0 @Published var micAudioLevel: Float = 0
@Published var systemAudioLevel: Float = 0 @Published var systemAudioLevel: Float = 0
private(set) var lastRecoveryAudioFolderName: String?
private var audioEngine = AVAudioEngine() private var audioEngine = AVAudioEngine()
private var sessionID = UUID() private var sessionID = UUID()
@@ -34,6 +52,7 @@ final class AudioManager: NSObject, ObservableObject {
private var systemAudioFile: AVAudioFile? private var systemAudioFile: AVAudioFile?
private var micAudioURL: URL? private var micAudioURL: URL?
private var systemAudioURL: URL? private var systemAudioURL: URL?
private var recordingStartedAt = Date()
private override init() { private override init() {
super.init() super.init()
@@ -53,9 +72,11 @@ final class AudioManager: NSObject, ObservableObject {
} }
func startRecording() { func startRecording() {
sessionID = UUID()
errorMessage = nil errorMessage = nil
lastRecoveryAudioFolderName = nil
cancelCapture(removeFiles: true) cancelCapture(removeFiles: true)
sessionID = UUID()
recordingStartedAt = Date()
do { do {
try prepareAudioFiles() try prepareAudioFiles()
startMicrophoneTap() startMicrophoneTap()
@@ -67,11 +88,12 @@ final class AudioManager: NSObject, ObservableObject {
} }
func stopRecordingAndTranscribe() async -> [TranscriptChunk] { func stopRecordingAndTranscribe() async -> [TranscriptChunk] {
let completedSessionID = sessionID
let captureStartedAt = recordingStartedAt
let files = stopCaptureAndCloseFiles() let files = stopCaptureAndCloseFiles()
isProcessing = true isProcessing = true
defer { defer {
isProcessing = false isProcessing = false
removeAudioFiles(files.compactMap { $0 })
} }
let model = UserDefaultsManager.shared.transcriptionModel let model = UserDefaultsManager.shared.transcriptionModel
@@ -80,32 +102,63 @@ final class AudioManager: NSObject, ObservableObject {
let (micTranscription, systemTranscription) = await (micResult, systemResult) let (micTranscription, systemTranscription) = await (micResult, systemResult)
let results = [micTranscription, systemTranscription] let results = [micTranscription, systemTranscription]
var updated = transcriptChunks.filter(\.isFinal) let (updated, failures) = buildTranscriptChunks(
var failures: [String] = [] from: results,
for (source, result) in zip([AudioSource.mic, .system], results) { captureStartedAt: captureStartedAt,
guard let result else { continue } existingChunks: transcriptChunks.filter(\.isFinal)
switch result { )
case .success(let text):
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
updated.append(TranscriptChunk(source: source, text: trimmed, isFinal: true))
}
case .failure(let error):
failures.append("\(source.displayName): \(error.localizedDescription)")
}
}
transcriptChunks = updated transcriptChunks = updated
if !failures.isEmpty { let completedFiles = files.compactMap { $0 }
errorMessage = "Transcription failed for " + failures.joined(separator: "; ") if failures.isEmpty {
lastRecoveryAudioFolderName = nil
removeAudioFiles(completedFiles)
} else {
let recoveryFolder = preserveAudioFiles(completedFiles, sessionID: completedSessionID)
lastRecoveryAudioFolderName = recoveryFolder?.lastPathComponent
let recoveryMessage = recoveryFolder == nil
? " The audio remains in the app's temporary folder."
: " Audio was saved in Documents/Meetingnotes-Recovery/\(completedSessionID.uuidString)."
errorMessage = "Transcription failed for " + failures.joined(separator: "; ") + recoveryMessage
} }
return updated return updated
} }
func transcribeRecoveryAudio(in folder: URL, captureStartedAt: Date) async throws -> [TranscriptChunk] {
let recoveryFiles = LocalStorageManager.shared.recoveryAudioFiles(in: folder)
guard !recoveryFiles.isEmpty else {
throw RecoveryTranscriptionError.noAudioFiles
}
isProcessing = true
defer { isProcessing = false }
let model = UserDefaultsManager.shared.transcriptionModel
let micURL = recoveryFiles.first(where: { $0.source == .mic })?.url
let systemURL = recoveryFiles.first(where: { $0.source == .system })?.url
async let micResult = transcribe(micURL, model: model)
async let systemResult = transcribe(systemURL, model: model)
let (micTranscription, systemTranscription) = await (micResult, systemResult)
let results = [micTranscription, systemTranscription]
let (chunks, failures) = buildTranscriptChunks(
from: results,
captureStartedAt: captureStartedAt,
existingChunks: []
)
if !failures.isEmpty {
throw RecoveryTranscriptionError.requestFailed(failures.joined(separator: "; "))
}
guard !chunks.isEmpty else {
throw RecoveryTranscriptionError.noSpeech
}
return chunks
}
func cancelRecording() { func cancelRecording() {
cancelCapture(removeFiles: true) cancelCapture(removeFiles: true)
lastRecoveryAudioFolderName = nil
} }
private func transcribe(_ fileURL: URL?, model: String) async -> Result<String, Error>? { private func transcribe(_ fileURL: URL?, model: String) async -> Result<CoderAPIClient.Transcription, Error>? {
guard let fileURL else { return nil } guard let fileURL else { return nil }
do { do {
return .success(try await CoderAPIClient.shared.transcribe(fileURL: fileURL, model: model)) return .success(try await CoderAPIClient.shared.transcribe(fileURL: fileURL, model: model))
@@ -113,6 +166,45 @@ final class AudioManager: NSObject, ObservableObject {
return .failure(error) return .failure(error)
} }
} }
private func buildTranscriptChunks(
from results: [Result<CoderAPIClient.Transcription, Error>?],
captureStartedAt: Date,
existingChunks: [TranscriptChunk]
) -> ([TranscriptChunk], [String]) {
var updated = existingChunks
var failures: [String] = []
for (source, result) in zip([AudioSource.mic, .system], results) {
guard let result else { continue }
switch result {
case .success(let transcription):
if transcription.segments.isEmpty {
let text = transcription.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
updated.append(TranscriptChunk(timestamp: captureStartedAt, source: source, text: text, isFinal: true))
}
} else {
for segment in transcription.segments {
let text = segment.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { continue }
updated.append(TranscriptChunk(
timestamp: captureStartedAt.addingTimeInterval(max(0, segment.start)),
source: source,
text: text,
isFinal: true
))
}
}
case .failure(let error):
failures.append("\(source.displayName): \(error.localizedDescription)")
}
}
updated.sort {
if $0.timestamp != $1.timestamp { return $0.timestamp < $1.timestamp }
return $0.source.rawValue < $1.source.rawValue
}
return (updated, failures)
}
private func prepareAudioFiles() throws { private func prepareAudioFiles() throws {
let settings: [String: Any] = [ let settings: [String: Any] = [
@@ -156,9 +248,13 @@ final class AudioManager: NSObject, ObservableObject {
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported microphone format"]) throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported microphone format"])
} }
inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in
guard let self, buffer.frameLength > 0 else { return } guard let self else { return }
self.updateAudioLevel(buffer, source: .mic) self.processAudioBuffer(
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .mic) { buffer },
converter: converter,
targetFormat: targetFormat,
source: .mic
)
} }
audioEngine.prepare() audioEngine.prepare()
try audioEngine.start() try audioEngine.start()
@@ -271,28 +367,75 @@ final class AudioManager: NSObject, ObservableObject {
private func startTapIO(_ tap: ProcessTap) throws { private func startTapIO(_ tap: ProcessTap) throws {
guard var description = tap.tapStreamDescription, guard var description = tap.tapStreamDescription,
let inputFormat = AVAudioFormat(streamDescription: &description), let inputFormat = AVAudioFormat(streamDescription: &description),
let targetFormat = systemAudioFile?.processingFormat, let targetFormat = systemAudioFile?.processingFormat else {
let converter = AVAudioConverter(from: inputFormat, to: targetFormat) 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"])
} }
try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in
guard let self, guard let self,
let buffer = AVAudioPCMBuffer(pcmFormat: inputFormat, bufferListNoCopy: inputData, deallocator: nil), let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else { return }
buffer.frameLength > 0 else { return } self.processAudioBuffer(
self.updateAudioLevel(buffer, source: .system) { self.copyAudioBuffer(from: inputData, format: inputFormat) },
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .system) converter: converter,
targetFormat: targetFormat,
source: .system
)
} invalidationHandler: { [weak self] _ in } invalidationHandler: { [weak self] _ in
guard let self, !self.isRestartingSystemTap, self.isRecording else { return } guard let self, !self.isRestartingSystemTap, self.isRecording else { return }
Task { await self.restartSystemAudioTap() } Task { await self.restartSystemAudioTap() }
} }
} }
private func copyAudioBuffer(
from inputData: UnsafePointer<AudioBufferList>,
format: AVAudioFormat
) -> 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(
UnsafeMutablePointer(mutating: inputData)
)
let destinationBuffers = UnsafeMutableAudioBufferListPointer(
ownedBuffer.mutableAudioBufferList
)
guard sourceBuffers.count == destinationBuffers.count else { return nil }
for index in 0..<sourceBuffers.count {
let source = sourceBuffers[index]
let destination = destinationBuffers[index]
let byteCount = Int(source.mDataByteSize)
guard byteCount <= Int(destination.mDataByteSize),
let sourceData = source.mData,
let destinationData = destination.mData else { return nil }
memcpy(destinationData, sourceData, byteCount)
destinationBuffers[index].mDataByteSize = source.mDataByteSize
}
return ownedBuffer
}
private func processAudioBuffer( private func processAudioBuffer(
_ inputBuffer: AVAudioPCMBuffer, _ inputBufferProvider: () -> AVAudioPCMBuffer?,
converter: AVAudioConverter, converter: AVAudioConverter,
targetFormat: AVAudioFormat, targetFormat: AVAudioFormat,
source: AudioSource source: AudioSource
) { ) {
// The system callback copies its borrowed Core Audio memory while this
// lock prevents teardown, then conversion operates on the owned copy.
audioFileLock.lock()
defer { audioFileLock.unlock() }
guard isAcceptingAudio,
let inputBuffer = inputBufferProvider(),
inputBuffer.frameLength > 0 else { return }
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 { return }
@@ -309,11 +452,6 @@ final class AudioManager: NSObject, ObservableObject {
} }
guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { return } guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { return }
audioFileLock.lock()
defer { audioFileLock.unlock() }
guard isAcceptingAudio else {
return
}
do { do {
switch source { switch source {
case .mic: case .mic:
@@ -351,8 +489,8 @@ final class AudioManager: NSObject, ObservableObject {
pendingMicRestart = nil pendingMicRestart = nil
AudioLevelManager.shared.updateRecordingState(false) AudioLevelManager.shared.updateRecordingState(false)
// Stop new writes and wait for any callback already writing before // Stop new callbacks and wait for any active conversion/write before
// AVAudioFile is finalized and released. // invalidating callback-owned buffers or finalizing AVAudioFile.
audioFileLock.lock() audioFileLock.lock()
isAcceptingAudio = false isAcceptingAudio = false
audioFileLock.unlock() audioFileLock.unlock()
@@ -389,6 +527,31 @@ final class AudioManager: NSObject, ObservableObject {
private func removeAudioFiles(_ urls: [URL]) { private func removeAudioFiles(_ urls: [URL]) {
for url in urls { try? FileManager.default.removeItem(at: url) } for url in urls { try? FileManager.default.removeItem(at: url) }
} }
private func preserveAudioFiles(_ urls: [URL], sessionID: UUID) -> URL? {
guard !urls.isEmpty,
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
return nil
}
let folder = documents
.appendingPathComponent("Meetingnotes-Recovery", isDirectory: true)
.appendingPathComponent(sessionID.uuidString, isDirectory: true)
do {
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
} catch {
return nil
}
var preservedCount = 0
for url in urls {
do {
try FileManager.default.moveItem(at: url, to: folder.appendingPathComponent(url.lastPathComponent))
preservedCount += 1
} catch {
continue
}
}
return preservedCount > 0 ? folder : nil
}
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 = "owen.meetingnotes" private let serviceName = "net.jamesbone.meetingnotes"
private init() {} private init() {}
+187 -3
View File
@@ -6,10 +6,16 @@ import Foundation
/// Manages local file storage for meetings and app data /// Manages local file storage for meetings and app data
class LocalStorageManager { class LocalStorageManager {
static let shared = LocalStorageManager() static let shared = LocalStorageManager()
struct MeetingImportResult {
let importedCount: Int
let skippedCount: Int
}
private let documentsDirectory: URL private let documentsDirectory: URL
private let meetingsDirectory: URL private let meetingsDirectory: URL
private let templatesDirectory: URL private let templatesDirectory: URL
private let recoveryDirectory: URL
private init() { private init() {
// Get the app's documents directory // Get the app's documents directory
@@ -21,12 +27,16 @@ class LocalStorageManager {
// Create templates subdirectory // Create templates subdirectory
templatesDirectory = documentsDirectory.appendingPathComponent("Templates") templatesDirectory = documentsDirectory.appendingPathComponent("Templates")
recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes-Recovery")
// Ensure directories exist // Ensure directories exist
try? FileManager.default.createDirectory(at: meetingsDirectory, try? FileManager.default.createDirectory(at: meetingsDirectory,
withIntermediateDirectories: true) withIntermediateDirectories: true)
try? FileManager.default.createDirectory(at: templatesDirectory, try? FileManager.default.createDirectory(at: templatesDirectory,
withIntermediateDirectories: true) withIntermediateDirectories: true)
try? FileManager.default.createDirectory(at: recoveryDirectory,
withIntermediateDirectories: true)
} }
// MARK: - Meeting Management // MARK: - Meeting Management
@@ -47,7 +57,17 @@ class LocalStorageManager {
// Write atomically using a temp file then replace // Write atomically using a temp file then replace
let tmpURL = fileURL.appendingPathExtension("tmp") let tmpURL = fileURL.appendingPathExtension("tmp")
try data.write(to: tmpURL, options: .atomic) try data.write(to: tmpURL, options: .atomic)
try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL, backupItemName: nil, options: [], resultingItemURL: nil) if FileManager.default.fileExists(atPath: fileURL.path) {
try FileManager.default.replaceItem(
at: fileURL,
withItemAt: tmpURL,
backupItemName: nil,
options: [],
resultingItemURL: nil
)
} else {
try FileManager.default.moveItem(at: tmpURL, to: fileURL)
}
print("✅ Saved meeting: \(meeting.id)") print("✅ Saved meeting: \(meeting.id)")
return true return true
@@ -128,6 +148,147 @@ class LocalStorageManager {
return false return false
} }
} }
// MARK: - Recovery Audio
func recoveryAudioFolder(named name: String) -> URL? {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty,
URL(fileURLWithPath: trimmedName).lastPathComponent == trimmedName else {
return nil
}
let folder = recoveryDirectory.appendingPathComponent(trimmedName, isDirectory: true)
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: folder.path, isDirectory: &isDirectory),
isDirectory.boolValue,
!recoveryAudioFiles(in: folder).isEmpty else {
return nil
}
return folder
}
func recoveryAudioFiles(in folder: URL) -> [(url: URL, source: AudioSource)] {
guard let files = try? FileManager.default.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: [.creationDateKey, .contentModificationDateKey, .isRegularFileKey],
options: [.skipsHiddenFiles]
) else {
return []
}
return files.compactMap { url in
let name = url.lastPathComponent.lowercased()
guard ["m4a", "mp3", "wav", "flac", "webm", "mp4"].contains(url.pathExtension.lowercased()) else {
return nil
}
if name.contains("-mic.") {
return (url, .mic)
}
if name.contains("-system.") {
return (url, .system)
}
return nil
}
}
func findRecoveryAudioFolder(for meeting: Meeting) -> URL? {
if let name = meeting.recoveryAudioFolderName,
let folder = recoveryAudioFolder(named: name) {
return folder
}
let claimedFolderNames = Set(
loadMeetings()
.filter { $0.id != meeting.id }
.compactMap(\.recoveryAudioFolderName)
)
guard let folders = try? FileManager.default.contentsOfDirectory(
at: recoveryDirectory,
includingPropertiesForKeys: [.isDirectoryKey, .creationDateKey, .contentModificationDateKey],
options: [.skipsHiddenFiles]
) else {
return nil
}
let candidates = folders.compactMap { folder -> (url: URL, distance: TimeInterval)? in
let folderValues = try? folder.resourceValues(
forKeys: [.isDirectoryKey, .creationDateKey, .contentModificationDateKey]
)
let files = recoveryAudioFiles(in: folder)
guard folderValues?.isDirectory == true,
!claimedFolderNames.contains(folder.lastPathComponent),
!files.isEmpty else {
return nil
}
let dates = files.compactMap { file -> Date? in
let values = try? file.url.resourceValues(forKeys: [.creationDateKey, .contentModificationDateKey])
return values?.creationDate ?? values?.contentModificationDate
}
let referenceDate = dates.min()
?? folderValues?.creationDate
?? folderValues?.contentModificationDate
guard let referenceDate else { return nil }
return (folder, abs(referenceDate.timeIntervalSince(meeting.date)))
}
// This fallback links recovery files created by older app versions.
return candidates
.filter { $0.distance <= 12 * 60 * 60 }
.min(by: { $0.distance < $1.distance })?
.url
}
func deleteRecoveryAudioFolder(_ folder: URL) {
guard folder.deletingLastPathComponent().standardizedFileURL == recoveryDirectory.standardizedFileURL else {
return
}
try? FileManager.default.removeItem(at: folder)
}
/// Imports meeting JSON files from a folder selected by the user.
func importMeetings(from directory: URL) throws -> MeetingImportResult {
let didStartAccess = directory.startAccessingSecurityScopedResource()
defer {
if didStartAccess {
directory.stopAccessingSecurityScopedResource()
}
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let existingIDs = Set(loadMeetings().map(\.id))
var importedIDs = Set<UUID>()
var skippedCount = 0
guard let enumerator = FileManager.default.enumerator(
at: directory,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles, .skipsPackageDescendants]
) else {
throw CocoaError(.fileReadUnknown)
}
for case let fileURL as URL in enumerator where fileURL.pathExtension.lowercased() == "json" {
do {
let values = try fileURL.resourceValues(forKeys: [.isRegularFileKey])
guard values.isRegularFile == true else { continue }
let data = try Data(contentsOf: fileURL)
let meeting = try decoder.decode(Meeting.self, from: data)
guard meeting.dataVersion <= Meeting.currentDataVersion,
!existingIDs.contains(meeting.id),
!importedIDs.contains(meeting.id),
saveMeeting(meeting) else {
skippedCount += 1
continue
}
importedIDs.insert(meeting.id)
} catch {
skippedCount += 1
}
}
return MeetingImportResult(importedCount: importedIDs.count, skippedCount: skippedCount)
}
// MARK: - Template Management // MARK: - Template Management
@@ -146,7 +307,17 @@ class LocalStorageManager {
// Write atomically using a temp file then replace // Write atomically using a temp file then replace
let tmpURL = fileURL.appendingPathExtension("tmp") let tmpURL = fileURL.appendingPathExtension("tmp")
try data.write(to: tmpURL, options: .atomic) try data.write(to: tmpURL, options: .atomic)
try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL, backupItemName: nil, options: [], resultingItemURL: nil) if FileManager.default.fileExists(atPath: fileURL.path) {
try FileManager.default.replaceItem(
at: fileURL,
withItemAt: tmpURL,
backupItemName: nil,
options: [],
resultingItemURL: nil
)
} else {
try FileManager.default.moveItem(at: tmpURL, to: fileURL)
}
print("✅ Saved template: \(template.id)") print("✅ Saved template: \(template.id)")
return true return true
@@ -199,6 +370,19 @@ class LocalStorageManager {
return templates.sorted { $0.title < $1.title } return templates.sorted { $0.title < $1.title }
} }
func preferredTemplateID(in templates: [NoteTemplate]? = nil) -> UUID? {
let availableTemplates = templates ?? loadTemplates()
if let selectedTemplateID = UserDefaultsManager.shared.selectedTemplateId,
availableTemplates.contains(where: { $0.id == selectedTemplateID }) {
return selectedTemplateID
}
let fallbackTemplateID = availableTemplates.first(where: { $0.title == "Standard Meeting" })?.id
?? availableTemplates.first?.id
UserDefaultsManager.shared.selectedTemplateId = fallbackTemplateID
return fallbackTemplateID
}
/// Deletes a template from local storage /// Deletes a template from local storage
/// - Parameter template: The template to delete /// - Parameter template: The template to delete
@@ -241,4 +425,4 @@ class LocalStorageManager {
var meetingsDirectoryURL: URL { var meetingsDirectoryURL: URL {
meetingsDirectory meetingsDirectory
} }
} }
@@ -112,6 +112,10 @@ class RecordingSessionManager: ObservableObject {
func isRecordingMeeting(_ meetingId: UUID) -> Bool { func isRecordingMeeting(_ meetingId: UUID) -> Bool {
return isRecording && activeMeetingId == meetingId return isRecording && activeMeetingId == meetingId
} }
var lastRecoveryAudioFolderName: String? {
audioManager.lastRecoveryAudioFolderName
}
private func updateActiveMeetingTranscript(meetingId: UUID, chunks: [TranscriptChunk]) { private func updateActiveMeetingTranscript(meetingId: UUID, chunks: [TranscriptChunk]) {
// Load all meetings // Load all meetings
+122 -2
View File
@@ -6,12 +6,14 @@
// //
import SwiftUI import SwiftUI
import AppKit
import Sparkle import Sparkle
import PostHog import PostHog
@main @main
struct MeetingnotesApp: App { struct MeetingnotesApp: App {
private let updaterController: SPUStandardUpdaterController private let updaterController: SPUStandardUpdaterController
@StateObject private var recordingSessionManager = RecordingSessionManager.shared
init() { init() {
updaterController = SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil) updaterController = SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil)
@@ -34,17 +36,135 @@ struct MeetingnotesApp: App {
} }
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup("Meetingnotes", id: "main") {
ContentView() ContentView()
.frame(minWidth: 700, minHeight: 400) .frame(minWidth: 700, minHeight: 400)
.background(MainWindowConfigurator())
} }
.windowResizability(.contentSize) .windowResizability(.automatic)
.defaultSize(width: 1000, height: 600) .defaultSize(width: 1000, height: 600)
.commands { .commands {
CommandGroup(after: .appInfo) { CommandGroup(after: .appInfo) {
CheckForUpdatesView(updater: updaterController.updater) CheckForUpdatesView(updater: updaterController.updater)
} }
} }
MenuBarExtra {
MeetingnotesMenu(
recordingSessionManager: recordingSessionManager,
updater: updaterController.updater
)
} label: {
Image(systemName: recordingSessionManager.isRecording ? "record.circle.fill" : "waveform")
.accessibilityLabel(recordingSessionManager.isRecording ? "Meetingnotes recording" : "Meetingnotes")
}
}
}
private struct MeetingnotesMenu: View {
@ObservedObject var recordingSessionManager: RecordingSessionManager
let updater: SPUUpdater
@Environment(\.openWindow) private var openWindow
var body: some View {
if recordingSessionManager.isRecording {
Label("Recording", systemImage: "record.circle.fill")
} else if recordingSessionManager.isProcessing {
Label("Processing audio", systemImage: "hourglass")
}
Button {
MainWindowController.shared.show(using: openWindow)
} label: {
Label("Open Meetingnotes", systemImage: "macwindow")
}
Divider()
Button {
updater.checkForUpdates()
} label: {
Label("Check for Updates...", systemImage: "arrow.triangle.2.circlepath")
}
.keyboardShortcut("u")
Button {
NSApplication.shared.terminate(nil)
} label: {
Label("Quit Meetingnotes", systemImage: "power")
}
.keyboardShortcut("q")
}
}
@MainActor
private final class MainWindowController: NSObject {
static let shared = MainWindowController()
private weak var window: NSWindow?
func configure(_ window: NSWindow) {
if self.window !== window {
if let previousWindow = self.window {
NotificationCenter.default.removeObserver(
self,
name: NSWindow.willMiniaturizeNotification,
object: previousWindow
)
}
self.window = window
NotificationCenter.default.addObserver(
self,
selector: #selector(windowWillMiniaturize(_:)),
name: NSWindow.willMiniaturizeNotification,
object: window
)
}
if let minimizeButton = window.standardWindowButton(.miniaturizeButton) {
minimizeButton.target = self
minimizeButton.action = #selector(hideWindow(_:))
}
}
func show(using openWindow: OpenWindowAction) {
if let window {
window.makeKeyAndOrderFront(nil)
} else {
openWindow(id: "main")
}
NSApplication.shared.activate(ignoringOtherApps: true)
}
@objc private func hideWindow(_ sender: NSButton) {
sender.window?.orderOut(nil)
}
@objc private func windowWillMiniaturize(_ notification: Notification) {
guard let window = notification.object as? NSWindow else { return }
DispatchQueue.main.async {
window.deminiaturize(nil)
window.orderOut(nil)
}
}
}
private struct MainWindowConfigurator: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = NSView()
configureWindow(for: view)
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
configureWindow(for: nsView)
}
private func configureWindow(for view: NSView) {
DispatchQueue.main.async {
guard let window = view.window else { return }
MainWindowController.shared.configure(window)
}
} }
} }
+27 -71
View File
@@ -1,5 +1,17 @@
import Foundation import Foundation
enum TranscriptTimestampFormatter {
static let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter
}()
static func string(from date: Date) -> String {
formatter.string(from: date)
}
}
enum AudioSource: String, Codable, CaseIterable { enum AudioSource: String, Codable, CaseIterable {
case mic = "MIC" case mic = "MIC"
case system = "SYS" case system = "SYS"
@@ -70,6 +82,7 @@ struct Meeting: Codable, Identifiable, Hashable {
var userNotes: String var userNotes: String
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?
// 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
@@ -83,6 +96,7 @@ struct Meeting: Codable, Identifiable, Hashable {
userNotes: String = "", userNotes: String = "",
generatedNotes: String = "", generatedNotes: String = "",
templateId: UUID? = nil, templateId: UUID? = nil,
recoveryAudioFolderName: String? = nil,
dataVersion: Int = Meeting.currentDataVersion) { dataVersion: Int = Meeting.currentDataVersion) {
self.id = id self.id = id
self.date = date self.date = date
@@ -91,6 +105,7 @@ struct Meeting: Codable, Identifiable, Hashable {
self.userNotes = userNotes self.userNotes = userNotes
self.generatedNotes = generatedNotes self.generatedNotes = generatedNotes
self.templateId = templateId self.templateId = templateId
self.recoveryAudioFolderName = recoveryAudioFolderName
self.dataVersion = dataVersion self.dataVersion = dataVersion
} }
@@ -107,81 +122,22 @@ struct Meeting: Codable, Identifiable, Hashable {
// Formatted transcript for copying with collapsed sequential chunks // Formatted transcript for copying with collapsed sequential chunks
var formattedTranscript: String { var formattedTranscript: String {
let finalChunks = transcriptChunks.filter { $0.isFinal } let finalChunks = transcriptChunks.filter { $0.isFinal }
guard !finalChunks.isEmpty else { return "" } return finalChunks.map { chunk in
"[\(TranscriptTimestampFormatter.string(from: chunk.timestamp))] \(chunk.source.copyPrefix): \(chunk.text)"
var result: [String] = [] }.joined(separator: "\n")
var currentSource: AudioSource?
var currentTexts: [String] = []
for chunk in finalChunks {
if chunk.source != currentSource {
// Finish previous section if exists
if let source = currentSource, !currentTexts.isEmpty {
let combinedText = currentTexts.joined(separator: " ")
result.append("\(source.copyPrefix): \(combinedText)")
}
// Start new section
currentSource = chunk.source
currentTexts = [chunk.text]
} else {
// Same source, add to current section
currentTexts.append(chunk.text)
}
}
// Finish last section
if let source = currentSource, !currentTexts.isEmpty {
let combinedText = currentTexts.joined(separator: " ")
result.append("\(source.copyPrefix): \(combinedText)")
}
return result.joined(separator: " \n")
} }
// Collapsed chunks for UI display // Collapsed chunks for UI display
var collapsedTranscriptChunks: [CollapsedTranscriptChunk] { var collapsedTranscriptChunks: [CollapsedTranscriptChunk] {
guard !transcriptChunks.isEmpty else { return [] } transcriptChunks.filter(\.isFinal).map { chunk in
CollapsedTranscriptChunk(
var result: [CollapsedTranscriptChunk] = [] id: chunk.id,
var currentSource: AudioSource? timestamp: chunk.timestamp,
var currentTexts: [String] = [] source: chunk.source,
var currentTimestamp: Date? combinedText: chunk.text
)
for chunk in transcriptChunks {
if chunk.source != currentSource {
// Finish previous section if exists
if let source = currentSource, !currentTexts.isEmpty, let timestamp = currentTimestamp {
let combinedText = currentTexts.joined(separator: " ")
result.append(CollapsedTranscriptChunk(
timestamp: timestamp,
source: source,
combinedText: combinedText
))
}
// Start new section
currentSource = chunk.source
currentTexts = [chunk.text]
currentTimestamp = chunk.timestamp
} else {
// Same source, add to current section
currentTexts.append(chunk.text)
}
} }
// Finish last section
if let source = currentSource, !currentTexts.isEmpty, let timestamp = currentTimestamp {
let combinedText = currentTexts.joined(separator: " ")
result.append(CollapsedTranscriptChunk(
timestamp: timestamp,
source: source,
combinedText: combinedText
))
}
return result
} }
// Separate computed properties for mic and system transcripts // Separate computed properties for mic and system transcripts
@@ -198,4 +154,4 @@ struct Meeting: Codable, Identifiable, Hashable {
.map { $0.text } .map { $0.text }
.joined(separator: " ") .joined(separator: " ")
} }
} }
@@ -49,7 +49,7 @@ extension String: @retroactive LocalizedError {
@Observable @Observable
final class AudioProcessController { final class AudioProcessController {
private let logger = Logger(subsystem: "owen.meetingnotes", category: String(describing: AudioProcessController.self)) private let logger = Logger(subsystem: "net.jamesbone.meetingnotes", category: String(describing: AudioProcessController.self))
private(set) var processes = [AudioProcess]() { private(set) var processes = [AudioProcess]() {
didSet { didSet {
@@ -236,4 +236,4 @@ private extension URL {
var isApp: Bool { var isApp: Bool {
(try? resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .application) == true (try? resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .application) == true
} }
} }
@@ -5,7 +5,7 @@ import OSLog
/// Uses TCC SPI in order to check/request system audio recording permission. /// Uses TCC SPI in order to check/request system audio recording permission.
@Observable @Observable
final class AudioRecordingPermission { final class AudioRecordingPermission {
private let logger = Logger(subsystem: "owen.meetingnotes", category: String(describing: AudioRecordingPermission.self)) private let logger = Logger(subsystem: "net.jamesbone.meetingnotes", category: String(describing: AudioRecordingPermission.self))
enum Status: String { enum Status: String {
case unknown case unknown
@@ -122,4 +122,4 @@ final class AudioRecordingPermission {
return fn return fn
}() }()
#endif // ENABLE_TCC_SPI #endif // ENABLE_TCC_SPI
} }
+4 -4
View File
@@ -51,7 +51,7 @@ final class ProcessTap {
init(target: TapTarget, muteWhenRunning: Bool = false) { init(target: TapTarget, muteWhenRunning: Bool = false) {
self.target = target self.target = target
self.muteWhenRunning = muteWhenRunning self.muteWhenRunning = muteWhenRunning
self.logger = Logger(subsystem: "owen.meetingnotes", category: "\(String(describing: ProcessTap.self))(\(target.loggingProcessName))") self.logger = Logger(subsystem: "net.jamesbone.meetingnotes", category: "\(String(describing: ProcessTap.self))(\(target.loggingProcessName))")
} }
@ObservationIgnored @ObservationIgnored
@@ -141,7 +141,7 @@ final class ProcessTap {
if processObjectIDs.isEmpty { if processObjectIDs.isEmpty {
logger.warning("System audio tap configured with an empty list of processObjectIDs. This might not capture any audio or behave unexpectedly.") logger.warning("System audio tap configured with an empty list of processObjectIDs. This might not capture any audio or behave unexpectedly.")
} }
tapDescription = CATapDescription(stereoMixdownOfProcesses: processObjectIDs) tapDescription = CATapDescription(monoMixdownOfProcesses: processObjectIDs)
logger.debug("Configuring tap for system audio output using \(processObjectIDs.count) explicit processes.") logger.debug("Configuring tap for system audio output using \(processObjectIDs.count) explicit processes.")
} }
@@ -327,7 +327,7 @@ final class ProcessTapRecorder {
self.tapDisplayName = tap.displayName self.tapDisplayName = tap.displayName
self.fileURL = fileURL self.fileURL = fileURL
self._tap = tap self._tap = tap
self.logger = Logger(subsystem: "owen.meetingnotes", category: "\(String(describing: ProcessTapRecorder.self))(\(fileURL.lastPathComponent))") self.logger = Logger(subsystem: "net.jamesbone.meetingnotes", category: "\(String(describing: ProcessTapRecorder.self))(\(fileURL.lastPathComponent))")
self.icon = tap.target.iconImage self.icon = tap.target.iconImage
} }
@@ -496,4 +496,4 @@ final class ProcessTapRecorder {
self.currentAudioLevel = 0.0 self.currentAudioLevel = 0.0
} }
} }
} }
+31 -6
View File
@@ -48,6 +48,17 @@ enum CoderAPIError: LocalizedError {
final class CoderAPIClient { final class CoderAPIClient {
static let shared = CoderAPIClient() static let shared = CoderAPIClient()
struct Transcription {
struct Segment: Decodable {
let start: TimeInterval
let end: TimeInterval
let text: String
}
let text: String
let segments: [Segment]
}
private struct ModelsResponse: Decodable { private struct ModelsResponse: Decodable {
let data: [CoderModel] let data: [CoderModel]
} }
@@ -59,9 +70,17 @@ final class CoderAPIClient {
private struct TranscriptionResponse: Decodable { private struct TranscriptionResponse: Decodable {
let text: String let text: String
let segments: [Transcription.Segment]?
} }
private init() {} private let transcriptionSession: URLSession
private init() {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 2 * 60 * 60
configuration.timeoutIntervalForResource = 2 * 60 * 60
transcriptionSession = URLSession(configuration: configuration)
}
func models(baseURL: String, apiKey: String) async throws -> [CoderModel] { func models(baseURL: String, apiKey: String) async throws -> [CoderModel] {
var request = URLRequest(url: try endpoint(baseURL: baseURL, path: "models")) var request = URLRequest(url: try endpoint(baseURL: baseURL, path: "models"))
@@ -78,7 +97,11 @@ final class CoderAPIClient {
) )
} }
func streamChat(systemPrompt: String, model: String) -> AsyncThrowingStream<String, Error> { func streamChat(
systemPrompt: String,
userPrompt: String = "Create the meeting notes now.",
model: String
) -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in AsyncThrowingStream { continuation in
Task { Task {
do { do {
@@ -94,7 +117,7 @@ final class CoderAPIClient {
"model": selectedModel, "model": selectedModel,
"messages": [ "messages": [
["role": "system", "content": systemPrompt], ["role": "system", "content": systemPrompt],
["role": "user", "content": "Create the meeting notes now."] ["role": "user", "content": userPrompt]
], ],
"stream": true "stream": true
]) ])
@@ -128,7 +151,7 @@ final class CoderAPIClient {
} }
} }
func transcribe(fileURL: URL, model: String, language: String = "en") async throws -> String { func transcribe(fileURL: URL, model: String, language: String = "en") async throws -> Transcription {
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() ?? "")
@@ -149,9 +172,10 @@ 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 URLSession.shared.upload(for: request, fromFile: bodyURL) let (data, response) = try await transcriptionSession.upload(for: request, fromFile: bodyURL)
try validate(response: response, data: data) try validate(response: response, data: data)
return try JSONDecoder().decode(TranscriptionResponse.self, from: data).text let decoded = try JSONDecoder().decode(TranscriptionResponse.self, from: data)
return Transcription(text: decoded.text, segments: decoded.segments ?? [])
} }
private func endpoint(baseURL: String, path: String) throws -> URL { private func endpoint(baseURL: String, path: String) throws -> URL {
@@ -194,6 +218,7 @@ final class CoderAPIClient {
} }
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\n\(model)\r\n") try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\n\(model)\r\n")
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"language\"\r\n\r\n\(language)\r\n") try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"language\"\r\n\r\n\(language)\r\n")
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"response_format\"\r\n\r\nverbose_json\r\n")
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(audioURL.lastPathComponent)\"\r\nContent-Type: audio/mp4\r\n\r\n") try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(audioURL.lastPathComponent)\"\r\nContent-Type: audio/mp4\r\n\r\n")
let input = try FileHandle(forReadingFrom: audioURL) let input = try FileHandle(forReadingFrom: audioURL)
defer { try? input.close() } defer { try? input.close() }
+22 -4
View File
@@ -244,7 +244,7 @@ private final class LocalRecordingController {
return statusPayload() return statusPayload()
} }
let meeting = Meeting() let meeting = Meeting(templateId: LocalStorageManager.shared.preferredTemplateID())
guard LocalStorageManager.shared.saveMeeting(meeting) else { guard LocalStorageManager.shared.saveMeeting(meeting) else {
throw LocalRecordingError.saveFailed throw LocalRecordingError.saveFailed
} }
@@ -286,11 +286,10 @@ private final class LocalRecordingController {
} }
meeting.transcriptChunks = chunks meeting.transcriptChunks = chunks
meeting.recoveryAudioFolderName = recordingManager.lastRecoveryAudioFolderName
let templates = LocalStorageManager.shared.loadTemplates() let templates = LocalStorageManager.shared.loadTemplates()
if meeting.templateId == nil { if meeting.templateId == nil {
meeting.templateId = UserDefaultsManager.shared.selectedTemplateId meeting.templateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
?? templates.first(where: { $0.title == "Standard Meeting" })?.id
?? templates.first?.id
} }
_ = LocalStorageManager.shared.saveMeeting(meeting) _ = LocalStorageManager.shared.saveMeeting(meeting)
NotificationCenter.default.post(name: .meetingSaved, object: meeting) NotificationCenter.default.post(name: .meetingSaved, object: meeting)
@@ -311,8 +310,27 @@ private final class LocalRecordingController {
generatedNotes = "" generatedNotes = ""
} }
} }
var meetingChanged = false
if !generatedNotes.isEmpty { if !generatedNotes.isEmpty {
meeting.generatedNotes = generatedNotes meeting.generatedNotes = generatedNotes
meetingChanged = true
}
if meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
do {
if let title = try await NotesGenerator.shared.generateMeetingTitle(
meeting: meeting,
generatedNotes: generatedNotes,
templateId: meeting.templateId
) {
meeting.title = title
meetingChanged = true
}
} catch {
// The recording and generated notes remain valid without a generated title.
print("Meeting title generation failed: \(error)")
}
}
if meetingChanged {
_ = LocalStorageManager.shared.saveMeeting(meeting) _ = LocalStorageManager.shared.saveMeeting(meeting)
NotificationCenter.default.post(name: .meetingSaved, object: meeting) NotificationCenter.default.post(name: .meetingSaved, object: meeting)
} }
+83 -1
View File
@@ -105,6 +105,88 @@ class NotesGenerator {
} }
} }
} }
/// Generates a short title after meeting notes have been created.
func generateMeetingTitle(
meeting: Meeting,
generatedNotes: String,
templateId: UUID?
) async throws -> String? {
let templates = LocalStorageManager.shared.loadTemplates()
let template = templateId.flatMap { id in
templates.first(where: { $0.id == id })
}
let meetingContext: String
if let template {
meetingContext = "\(template.title): \(template.context)"
} else {
meetingContext = "General meeting"
}
let trimmedNotes = generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines)
let meetingContent = trimmedNotes.isEmpty ? meeting.formattedTranscript : trimmedNotes
guard !meetingContent.isEmpty else { return nil }
let systemPrompt = """
Create a concise, descriptive title for a recorded meeting from its context and content.
Return only the title in 3 to 8 words. Do not use quotation marks, Markdown, or a trailing period.
Avoid generic titles such as Meeting, Discussion, Meeting Notes, or Untitled Meeting.
"""
let userPrompt = """
Meeting context:
\(meetingContext)
Meeting content:
\(meetingContent)
"""
var generatedTitle = ""
let stream = CoderAPIClient.shared.streamChat(
systemPrompt: systemPrompt,
userPrompt: userPrompt,
model: UserDefaultsManager.shared.notesModel
)
for try await content in stream {
generatedTitle += content
}
return normalizedTitle(generatedTitle)
}
private func normalizedTitle(_ value: String) -> String? {
guard var title = value
.split(whereSeparator: \Character.isNewline)
.map(String.init)
.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })?
.trimmingCharacters(in: .whitespacesAndNewlines) else {
return nil
}
while title.hasPrefix("#") {
title.removeFirst()
title = title.trimmingCharacters(in: .whitespaces)
}
if title.lowercased().hasPrefix("title:") {
title = String(title.dropFirst("title:".count))
.trimmingCharacters(in: .whitespacesAndNewlines)
}
title = title.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
if title.hasSuffix(".") {
title.removeLast()
}
if title.count > 80 {
let prefix = String(title.prefix(80))
if let lastSpace = prefix.lastIndex(of: " ") {
title = String(prefix[..<lastSpace])
} else {
title = prefix
}
}
title = title.trimmingCharacters(in: .whitespacesAndNewlines)
return title.isEmpty ? nil : title
}
/// Validates if the Coder service token is configured /// Validates if the Coder service token is configured
/// - Returns: True if API key exists, false otherwise /// - Returns: True if API key exists, false otherwise
@@ -115,4 +197,4 @@ class NotesGenerator {
} }
return true return true
} }
} }
@@ -68,11 +68,11 @@ class MeetingListViewModel: ObservableObject {
} }
func createNewMeeting() -> Meeting { func createNewMeeting() -> Meeting {
let newMeeting = Meeting() let newMeeting = Meeting(templateId: LocalStorageManager.shared.preferredTemplateID())
meetings.insert(newMeeting, at: 0) meetings.insert(newMeeting, at: 0)
_ = LocalStorageManager.shared.saveMeeting(newMeeting) _ = LocalStorageManager.shared.saveMeeting(newMeeting)
// Track meeting creation event // Track meeting creation event
PostHogSDK.shared.capture("meeting_created") PostHogSDK.shared.capture("meeting_created")
return newMeeting return newMeeting
} }
} }
+80 -28
View File
@@ -12,7 +12,7 @@ extension Notification.Name {
enum MeetingViewTab: String, CaseIterable { enum MeetingViewTab: String, CaseIterable {
case myNotes = "My Notes" case myNotes = "My Notes"
case transcript = "Transcript" case transcript = "Transcript"
case enhancedNotes = "Enhanced Notes" case enhancedNotes = "Meeting Notes"
} }
@@ -25,6 +25,8 @@ class MeetingViewModel: ObservableObject {
@Published private var recordingStateChanged = false // Trigger SwiftUI updates @Published private var recordingStateChanged = false // Trigger SwiftUI updates
@Published var isValidatingKey = false // Indicates API key validation in progress @Published var isValidatingKey = false // Indicates API key validation in progress
@Published var isStartingRecording = false // Indicates recording start in progress @Published var isStartingRecording = false // Indicates recording start in progress
@Published var isRetryingTranscription = false
@Published private(set) var recoveryAudioFolderURL: URL?
// Computed property to determine if Generate button should animate // Computed property to determine if Generate button should animate
var shouldAnimateGenerateButton: Bool { var shouldAnimateGenerateButton: Bool {
@@ -44,7 +46,13 @@ class MeetingViewModel: ObservableObject {
} }
var isProcessing: Bool { var isProcessing: Bool {
return recordingSessionManager.isProcessing && recordingSessionManager.activeMeetingId == meeting.id return isRetryingTranscription ||
(recordingSessionManager.isProcessing && recordingSessionManager.activeMeetingId == meeting.id)
}
var canRetryTranscription: Bool {
recoveryAudioFolderURL != nil &&
!isRecording && !isProcessing && !isStartingRecording && !isValidatingKey
} }
@Published var selectedTab: MeetingViewTab = .transcript // Default to transcript tab @Published var selectedTab: MeetingViewTab = .transcript // Default to transcript tab
@@ -54,15 +62,6 @@ class MeetingViewModel: ObservableObject {
private let recordingSessionManager = RecordingSessionManager.shared private let recordingSessionManager = RecordingSessionManager.shared
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var isNewMeeting = false
// Computed property to check if meeting is empty
var isEmpty: Bool {
return meeting.transcriptChunks.isEmpty &&
meeting.userNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
meeting.generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
init(meeting: Meeting = Meeting()) { init(meeting: Meeting = Meeting()) {
// Load the latest version of the meeting from storage if it exists // Load the latest version of the meeting from storage if it exists
@@ -76,9 +75,6 @@ class MeetingViewModel: ObservableObject {
// Detect if this is a new meeting based on content, not storage existence
isNewMeeting = isEmpty
// Set initial tab based on notes existence // Set initial tab based on notes existence
if !self.meeting.generatedNotes.isEmpty { if !self.meeting.generatedNotes.isEmpty {
selectedTab = .enhancedNotes selectedTab = .enhancedNotes
@@ -88,6 +84,7 @@ class MeetingViewModel: ObservableObject {
// Load templates and selected template // Load templates and selected template
loadTemplates() loadTemplates()
refreshRecoveryAudioFolder()
// Observe template selection: save to meeting and regenerate notes on changes (skip initial) // Observe template selection: save to meeting and regenerate notes on changes (skip initial)
$selectedTemplateId $selectedTemplateId
.dropFirst() .dropFirst()
@@ -217,6 +214,8 @@ class MeetingViewModel: ObservableObject {
Task { Task {
let chunks = await recordingSessionManager.stopRecording() let chunks = await recordingSessionManager.stopRecording()
meeting.transcriptChunks = chunks meeting.transcriptChunks = chunks
meeting.recoveryAudioFolderName = recordingSessionManager.lastRecoveryAudioFolderName
refreshRecoveryAudioFolder()
saveMeeting() saveMeeting()
if !meeting.formattedTranscript.isEmpty { if !meeting.formattedTranscript.isEmpty {
await generateNotes() await generateNotes()
@@ -224,15 +223,56 @@ class MeetingViewModel: ObservableObject {
isStartingRecording = false isStartingRecording = false
} }
} }
func retryTranscription() {
guard let recoveryAudioFolderURL, canRetryTranscription else { return }
isRetryingTranscription = true
errorMessage = nil
Task {
defer { isRetryingTranscription = false }
do {
let chunks = try await AudioManager.shared.transcribeRecoveryAudio(
in: recoveryAudioFolderURL,
captureStartedAt: meeting.date
)
meeting.transcriptChunks = chunks
meeting.recoveryAudioFolderName = recoveryAudioFolderURL.lastPathComponent
selectedTab = .transcript
guard saveMeeting() else {
throw CocoaError(.fileWriteUnknown)
}
LocalStorageManager.shared.deleteRecoveryAudioFolder(recoveryAudioFolderURL)
meeting.recoveryAudioFolderName = nil
self.recoveryAudioFolderURL = nil
saveMeeting()
await generateNotes()
} catch {
errorMessage = error.localizedDescription
print("Retry transcription failed: \(error)")
}
}
}
private func refreshRecoveryAudioFolder() {
recoveryAudioFolderURL = LocalStorageManager.shared.findRecoveryAudioFolder(for: meeting)
if let recoveryAudioFolderURL {
meeting.recoveryAudioFolderName = recoveryAudioFolderURL.lastPathComponent
}
}
func loadTemplates() { func loadTemplates() {
templates = LocalStorageManager.shared.loadTemplates() templates = LocalStorageManager.shared.loadTemplates()
// Load per-meeting template or default to Standard Meeting // Keep an existing meeting's template, otherwise use the configured default.
if let meetingTemplateId = meeting.templateId { if let meetingTemplateId = meeting.templateId,
templates.contains(where: { $0.id == meetingTemplateId }) {
selectedTemplateId = meetingTemplateId selectedTemplateId = meetingTemplateId
} else if let defaultTemplate = templates.first(where: { $0.title == "Standard Meeting" }) { } else {
selectedTemplateId = defaultTemplate.id selectedTemplateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
meeting.templateId = selectedTemplateId
} }
} }
@@ -270,20 +310,40 @@ class MeetingViewModel: ObservableObject {
// Only save if there was no error // Only save if there was no error
if !hasError { if !hasError {
await generateTitleIfNeeded()
saveMeeting() saveMeeting()
} }
isGeneratingNotes = false isGeneratingNotes = false
} }
private func generateTitleIfNeeded() async {
guard meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
do {
if let title = try await NotesGenerator.shared.generateMeetingTitle(
meeting: meeting,
generatedNotes: meeting.generatedNotes,
templateId: selectedTemplateId
) {
meeting.title = title
}
} catch {
// A title is helpful but should never prevent completed notes from being saved.
print("Meeting title generation failed: \(error)")
}
}
func saveMeeting() { @discardableResult
if isDeleted { return } func saveMeeting() -> Bool {
if isDeleted { return false }
print("💾 Saving meeting: \(meeting.id)") print("💾 Saving meeting: \(meeting.id)")
let success = LocalStorageManager.shared.saveMeeting(meeting) let success = LocalStorageManager.shared.saveMeeting(meeting)
print("💾 Save result: \(success ? "SUCCESS" : "FAILED")") print("💾 Save result: \(success ? "SUCCESS" : "FAILED")")
if success { if success {
NotificationCenter.default.post(name: .meetingSaved, object: meeting) NotificationCenter.default.post(name: .meetingSaved, object: meeting)
} }
return success
} }
func copyCurrentTabContent() { func copyCurrentTabContent() {
@@ -331,12 +391,4 @@ class MeetingViewModel: ObservableObject {
} }
} }
func deleteIfEmpty() {
if isEmpty && !isRecording && !isProcessing {
print("🗑️ Auto-deleting empty meeting")
deleteMeeting()
} else {
saveMeeting()
}
}
} }
@@ -52,24 +52,7 @@ class SettingsViewModel: ObservableObject {
func loadTemplates() { func loadTemplates() {
templates = LocalStorageManager.shared.loadTemplates() templates = LocalStorageManager.shared.loadTemplates()
settings.selectedTemplateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
// Validate that the selected template still exists
if let selectedId = settings.selectedTemplateId {
if !templates.contains(where: { $0.id == selectedId }) {
// Selected template was deleted, clear the selection
settings.selectedTemplateId = nil
}
}
// If no template is selected, select the first default template
if settings.selectedTemplateId == nil {
if let defaultTemplate = templates.first(where: { $0.title == "Standard Meeting" }) {
settings.selectedTemplateId = defaultTemplate.id
} else if let firstTemplate = templates.first {
// Fallback to first available template
settings.selectedTemplateId = firstTemplate.id
}
}
} }
func saveSettings(showMessage: Bool = true) { func saveSettings(showMessage: Bool = true) {
@@ -4,6 +4,7 @@ import SwiftUI
@MainActor @MainActor
class TemplatesViewModel: ObservableObject { class TemplatesViewModel: ObservableObject {
@Published var templates: [NoteTemplate] = [] @Published var templates: [NoteTemplate] = []
@Published private(set) var defaultTemplateID: UUID? = nil
@Published var isLoading = false @Published var isLoading = false
@Published var errorMessage: String? @Published var errorMessage: String?
@@ -14,8 +15,14 @@ class TemplatesViewModel: ObservableObject {
func loadTemplates() { func loadTemplates() {
isLoading = true isLoading = true
templates = LocalStorageManager.shared.loadTemplates() templates = LocalStorageManager.shared.loadTemplates()
defaultTemplateID = LocalStorageManager.shared.preferredTemplateID(in: templates)
isLoading = false isLoading = false
} }
func setDefaultTemplate(_ template: NoteTemplate) {
UserDefaultsManager.shared.selectedTemplateId = template.id
defaultTemplateID = template.id
}
func saveTemplate(_ template: NoteTemplate) { func saveTemplate(_ template: NoteTemplate) {
if LocalStorageManager.shared.saveTemplate(template) { if LocalStorageManager.shared.saveTemplate(template) {
@@ -42,4 +49,4 @@ class TemplatesViewModel: ObservableObject {
] ]
) )
} }
} }
+21 -4
View File
@@ -197,6 +197,11 @@ struct CollapsedTranscriptChunkView: View {
var body: some View { var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(TranscriptTimestampFormatter.string(from: chunk.timestamp))
.font(.caption.monospacedDigit())
.foregroundColor(.secondary)
.frame(width: 58, alignment: .leading)
// Source indicator // Source indicator
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: chunk.source.icon) Image(systemName: chunk.source.icon)
@@ -254,6 +259,17 @@ struct MeetingDetailContentView: View {
// Ellipsis menu // Ellipsis menu
Menu { Menu {
if viewModel.recoveryAudioFolderURL != nil {
Button {
viewModel.retryTranscription()
} label: {
Label("Retry Transcription", systemImage: "arrow.clockwise")
}
.disabled(!viewModel.canRetryTranscription)
Divider()
}
Button("Delete Meeting", role: .destructive) { Button("Delete Meeting", role: .destructive) {
showDeleteAlert = true showDeleteAlert = true
} }
@@ -322,7 +338,7 @@ struct MeetingDetailContentView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isProcessing || viewModel.isStartingRecording) .disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isProcessing || viewModel.isStartingRecording)
.help("Generate enhanced notes using a template") .help("Generate meeting notes using a template")
// Recording Button // Recording Button
Button(action: { Button(action: {
@@ -369,7 +385,7 @@ struct MeetingDetailContentView: View {
Spacer() Spacer()
// Edit/Preview button (for My Notes and Enhanced Notes) // Edit/Preview button (for My Notes and Meeting Notes)
if viewModel.selectedTab == .myNotes || viewModel.selectedTab == .enhancedNotes { if viewModel.selectedTab == .myNotes || viewModel.selectedTab == .enhancedNotes {
Button(action: { Button(action: {
isEditing.toggle() isEditing.toggle()
@@ -440,8 +456,9 @@ struct MeetingDetailContentView: View {
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.")
} }
.onDisappear { .onDisappear {
// Auto-delete empty meetings when leaving, otherwise save // A failed recording may still be empty. Keep it until the user
viewModel.deleteIfEmpty() // explicitly deletes it so app updates cannot erase history.
viewModel.saveMeeting()
} }
} }
+44
View File
@@ -1,10 +1,14 @@
import SwiftUI import SwiftUI
import UniformTypeIdentifiers
struct SettingsView: View { struct SettingsView: View {
@ObservedObject var viewModel: SettingsViewModel @ObservedObject var viewModel: SettingsViewModel
@StateObject private var localAPIServer = LocalAPIServer.shared @StateObject private var localAPIServer = LocalAPIServer.shared
@State private var showingTemplateManager = false @State private var showingTemplateManager = false
@State private var confirmingTokenRegeneration = false @State private var confirmingTokenRegeneration = false
@State private var showingMeetingImporter = false
@State private var meetingImportMessage = ""
@State private var showingMeetingImportResult = false
@Binding var navigationPath: NavigationPath @Binding var navigationPath: NavigationPath
init(viewModel: SettingsViewModel, navigationPath: Binding<NavigationPath> = .constant(NavigationPath())) { init(viewModel: SettingsViewModel, navigationPath: Binding<NavigationPath> = .constant(NavigationPath())) {
@@ -119,6 +123,17 @@ struct SettingsView: View {
} }
} }
} }
VStack(alignment: .leading, spacing: 8) {
Text("Meeting Storage")
.font(.headline)
Button {
showingMeetingImporter = true
} label: {
Label("Import Meetings...", systemImage: "square.and.arrow.down")
}
}
// Note Templates Section: only the Manage Templates button // Note Templates Section: only the Manage Templates button
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@@ -129,6 +144,12 @@ struct SettingsView: View {
Text("Create and manage note templates") Text("Create and manage note templates")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Picker("Default template", selection: $viewModel.settings.selectedTemplateId) {
ForEach(viewModel.templates) { template in
Text(template.title).tag(Optional(template.id))
}
}
Button { Button {
navigationPath.append("templates") navigationPath.append("templates")
@@ -276,6 +297,29 @@ struct SettingsView: View {
} message: { } message: {
Text(viewModel.saveMessage) Text(viewModel.saveMessage)
} }
.fileImporter(
isPresented: $showingMeetingImporter,
allowedContentTypes: [.folder],
allowsMultipleSelection: false
) { result in
do {
guard let directory = try result.get().first else { return }
let importResult = try LocalStorageManager.shared.importMeetings(from: directory)
meetingImportMessage = "Imported \(importResult.importedCount) meeting\(importResult.importedCount == 1 ? "" : "s")."
if importResult.skippedCount > 0 {
meetingImportMessage += " Skipped \(importResult.skippedCount) existing or invalid file\(importResult.skippedCount == 1 ? "" : "s")."
}
NotificationCenter.default.post(name: .meetingSaved, object: nil)
} catch {
meetingImportMessage = "Meeting import failed: \(error.localizedDescription)"
}
showingMeetingImportResult = true
}
.alert("Meeting Import", isPresented: $showingMeetingImportResult) {
Button("OK") { }
} message: {
Text(meetingImportMessage)
}
.confirmationDialog("Regenerate API token?", isPresented: $confirmingTokenRegeneration, titleVisibility: .visible) { .confirmationDialog("Regenerate API token?", isPresented: $confirmingTokenRegeneration, titleVisibility: .visible) {
Button("Regenerate", role: .destructive) { Button("Regenerate", role: .destructive) {
viewModel.regenerateMuteDeckAPIToken() viewModel.regenerateMuteDeckAPIToken()
+32 -29
View File
@@ -36,13 +36,13 @@ struct TemplateEditView: View {
TextEditor(text: $template.context) TextEditor(text: $template.context)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.padding(8) .padding(8)
.background(Color.gray.opacity(0.05)) .frame(height: 140)
.cornerRadius(8) .background(Color.secondary.opacity(0.06))
.frame(minHeight: 100) .overlay {
.overlay( RoundedRectangle(cornerRadius: 6)
RoundedRectangle(cornerRadius: 8) .stroke(Color.secondary.opacity(0.25), lineWidth: 1)
.stroke(Color.gray.opacity(0.3), lineWidth: 1) }
) .clipShape(RoundedRectangle(cornerRadius: 6))
} }
// Sections // Sections
@@ -55,14 +55,12 @@ struct TemplateEditView: View {
Spacer() Spacer()
Button { Button {
withAnimation { template.sections.append(
template.sections.append( TemplateSection(
TemplateSection( title: "New Section",
title: "New Section", description: "Description of this section"
description: "Description of this section"
)
) )
} )
} label: { } label: {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "plus") Image(systemName: "plus")
@@ -77,29 +75,27 @@ struct TemplateEditView: View {
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
ForEach(template.sections.indices, id: \.self) { index in ForEach($template.sections) { $section in
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
TextField("Section Title", text: $template.sections[index].title) TextField("Section Title", text: $section.title)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
TextEditor(text: $template.sections[index].description) TextEditor(text: $section.description)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.padding(8) .padding(8)
.background(Color.gray.opacity(0.05)) .frame(height: 90)
.cornerRadius(8) .background(Color.secondary.opacity(0.06))
.frame(minHeight: 60) .overlay {
.overlay( RoundedRectangle(cornerRadius: 6)
RoundedRectangle(cornerRadius: 8) .stroke(Color.secondary.opacity(0.25), lineWidth: 1)
.stroke(Color.gray.opacity(0.3), lineWidth: 1) }
) .clipShape(RoundedRectangle(cornerRadius: 6))
} }
Button { Button {
withAnimation { template.sections.removeAll { $0.id == section.id }
let _ = template.sections.remove(at: index)
}
} label: { } label: {
Image(systemName: "trash") Image(systemName: "trash")
.foregroundColor(.red) .foregroundColor(.red)
@@ -131,12 +127,19 @@ struct TemplateEditView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.top) .padding(.top)
.disabled(template.title.isEmpty || template.sections.isEmpty) .disabled(!canSave)
} }
.padding(24) .padding(24)
} }
.navigationTitle("Edit Template") .navigationTitle("Edit Template")
} }
private var canSave: Bool {
let hasTitle = !template.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let hasInstructions = !template.context.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| !template.sections.isEmpty
return hasTitle && hasInstructions
}
} }
#Preview { #Preview {
@@ -152,4 +155,4 @@ struct TemplateEditView: View {
) )
) { _ in } ) { _ in }
} }
} }
+40 -9
View File
@@ -2,21 +2,21 @@ import SwiftUI
struct TemplateListView: View { struct TemplateListView: View {
@StateObject private var viewModel = TemplatesViewModel() @StateObject private var viewModel = TemplatesViewModel()
@Environment(\.dismiss) private var dismiss @State private var editingTemplate: NoteTemplate?
var body: some View { var body: some View {
List { List {
ForEach(viewModel.templates) { template in ForEach(viewModel.templates) { template in
HStack { HStack {
NavigationLink(destination: TemplateEditView(template: template) { updatedTemplate in Button {
viewModel.saveTemplate(updatedTemplate) presentEditor(for: template)
}) { } label: {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack { HStack {
Text(template.title) Text(template.title)
.font(.headline) .font(.headline)
if template.isDefault { if template.isDefault {
Text("Default") Text("Built-in")
.font(.caption) .font(.caption)
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 2) .padding(.vertical, 2)
@@ -36,8 +36,18 @@ struct TemplateListView: View {
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
.buttonStyle(.plain)
Spacer() Spacer()
Button {
viewModel.setDefaultTemplate(template)
} label: {
Image(systemName: viewModel.defaultTemplateID == template.id ? "star.fill" : "star")
.foregroundColor(viewModel.defaultTemplateID == template.id ? .accentColor : .secondary)
}
.buttonStyle(.plain)
.help(viewModel.defaultTemplateID == template.id ? "Default template" : "Use as default template")
if !template.isDefault { if !template.isDefault {
Button(role: .destructive) { Button(role: .destructive) {
@@ -50,6 +60,14 @@ struct TemplateListView: View {
} }
} }
.contextMenu { .contextMenu {
if viewModel.defaultTemplateID != template.id {
Button {
viewModel.setDefaultTemplate(template)
} label: {
Label("Use as Default", systemImage: "star")
}
}
if !template.isDefault { if !template.isDefault {
Button(role: .destructive) { Button(role: .destructive) {
viewModel.deleteTemplate(template) viewModel.deleteTemplate(template)
@@ -70,9 +88,9 @@ struct TemplateListView: View {
.navigationTitle("Note Templates") .navigationTitle("Note Templates")
.toolbar { .toolbar {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
NavigationLink(destination: TemplateEditView(template: viewModel.createNewTemplate()) { updatedTemplate in Button {
viewModel.saveTemplate(updatedTemplate) presentEditor(for: viewModel.createNewTemplate())
}) { } label: {
Image(systemName: "plus") Image(systemName: "plus")
} }
} }
@@ -89,9 +107,22 @@ struct TemplateListView: View {
} message: { } message: {
Text(viewModel.errorMessage ?? "") Text(viewModel.errorMessage ?? "")
} }
.sheet(item: $editingTemplate) { template in
NavigationStack {
TemplateEditView(template: template) { updatedTemplate in
viewModel.saveTemplate(updatedTemplate)
}
}
.frame(minWidth: 680, minHeight: 620)
}
}
private func presentEditor(for template: NoteTemplate) {
guard editingTemplate == nil else { return }
editingTemplate = template
} }
} }
#Preview { #Preview {
TemplateListView() TemplateListView()
} }
+2 -6
View File
@@ -4,10 +4,6 @@
<dict> <dict>
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.app-sandbox</key>
<true/> <true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.owen.meetingnotes</string>
</array>
<key>com.apple.security.device.audio-input</key> <key>com.apple.security.device.audio-input</key>
<true/> <true/>
<key>com.apple.security.device.screen-capture</key> <key>com.apple.security.device.screen-capture</key>
@@ -20,8 +16,8 @@
<true/> <true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key> <key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array> <array>
<string>owen.meetingnotes-spks</string> <string>net.jamesbone.meetingnotes-spks</string>
<string>owen.meetingnotes-spki</string> <string>net.jamesbone.meetingnotes-spki</string>
</array> </array>
</dict> </dict>
</plist> </plist>
+2 -2
View File
@@ -7,7 +7,7 @@ set -e # Exit on any error
# Configuration # Configuration
APP_NAME="Meetingnotes" APP_NAME="Meetingnotes"
BUNDLE_ID="owen.meetingnotes" BUNDLE_ID="net.jamesbone.meetingnotes"
VERSION=$(grep -m1 "MARKETING_VERSION" Meetingnotes.xcodeproj/project.pbxproj | sed 's/.*= \(.*\);/\1/') VERSION=$(grep -m1 "MARKETING_VERSION" Meetingnotes.xcodeproj/project.pbxproj | sed 's/.*= \(.*\);/\1/')
# Source environment variables if .env file exists # Source environment variables if .env file exists
@@ -379,4 +379,4 @@ echo " 1. Test the DMG on another Mac"
echo " 2. Create a GitHub release with tag v${VERSION}" echo " 2. Create a GitHub release with tag v${VERSION}"
echo " 3. Upload the DMG to the GitHub release" echo " 3. Upload the DMG to the GitHub release"
echo " 4. Commit and push the appcast.xml file" echo " 4. Commit and push the appcast.xml file"
echo " 5. Your users will get auto-update notifications!" echo " 5. Your users will get auto-update notifications!"
+34
View File
@@ -0,0 +1,34 @@
#!/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
@@ -0,0 +1,139 @@
#!/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"
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="Meetingnotes"
PROJECT="Meetingnotes.xcodeproj"
SCHEME="meetingnotes"
RUNNER_TEMP="${RUNNER_TEMP:-/tmp}"
BUILD_ROOT="${BUILD_ROOT:-$RUNNER_TEMP/meetingnotes-release}"
DERIVED_DATA="$BUILD_ROOT/DerivedData"
RELEASE_DIR="$BUILD_ROOT/release"
APP_PATH="$DERIVED_DATA/Build/Products/Release/$APP_NAME.app"
required_variables=(
VERSION
SIGNING_IDENTITY
SPARKLE_PRIVATE_KEY
GITHUB_REPOSITORY
)
for variable in "${required_variables[@]}"; do
if [[ -z "${!variable:-}" ]]; then
echo "Missing required environment variable: $variable" >&2
exit 1
fi
done
project_version=$(grep -m1 'MARKETING_VERSION' "$PROJECT/project.pbxproj" | sed 's/.*= \(.*\);/\1/')
if [[ "$project_version" != "$VERSION" ]]; then
echo "Release version $VERSION does not match project version $project_version" >&2
exit 1
fi
rm -rf "$BUILD_ROOT"
mkdir -p "$RELEASE_DIR"
xcodebuild \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Release \
-destination 'generic/platform=macOS' \
-derivedDataPath "$DERIVED_DATA" \
ARCHS="arm64 x86_64" \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=NO \
clean build
if [[ ! -d "$APP_PATH" ]]; then
echo "Built app not found at $APP_PATH" >&2
exit 1
fi
SPARKLE_FRAMEWORK="$APP_PATH/Contents/Frameworks/Sparkle.framework"
SPARKLE_CONTENTS="$SPARKLE_FRAMEWORK/Versions/B"
sign_component() {
codesign --force --timestamp --options runtime --sign "$SIGNING_IDENTITY" "$1"
}
sign_component "$SPARKLE_CONTENTS/XPCServices/Installer.xpc"
if [[ -d "$SPARKLE_CONTENTS/XPCServices/Downloader.xpc" ]]; then
codesign --force --timestamp --options runtime \
--preserve-metadata=entitlements \
--sign "$SIGNING_IDENTITY" \
"$SPARKLE_CONTENTS/XPCServices/Downloader.xpc"
fi
sign_component "$SPARKLE_CONTENTS/Autoupdate"
sign_component "$SPARKLE_CONTENTS/Updater.app"
sign_component "$SPARKLE_FRAMEWORK"
codesign --force --timestamp --options runtime \
--entitlements meetingnotes/meetingnotes.entitlements \
--sign "$SIGNING_IDENTITY" \
"$APP_PATH"
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
codesign -d --entitlements :- "$APP_PATH" 2>&1 | grep -q 'com.apple.security.app-sandbox'
ARCHIVE_NAME="$APP_NAME-$VERSION.zip"
ARCHIVE_PATH="$RELEASE_DIR/$ARCHIVE_NAME"
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ARCHIVE_PATH"
GENERATE_APPCAST=$(find "$DERIVED_DATA/SourcePackages/artifacts" -type f -name generate_appcast -print -quit)
if [[ -z "$GENERATE_APPCAST" ]]; then
echo "Sparkle generate_appcast tool was not found" >&2
exit 1
fi
DOWNLOAD_URL="https://github.com/$GITHUB_REPOSITORY/releases/download/v$VERSION/"
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"
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
printf 'Built and Developer ID-signed Meetingnotes %s. The GitHub release is ready to publish.\n' \
"$VERSION" >> "$GITHUB_STEP_SUMMARY"
fi
echo "Signed release artifacts are ready in $RELEASE_DIR"