Compare commits

..
Author SHA1 Message Date
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
29 changed files with 748 additions and 352 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 = 14; CURRENT_PROJECT_VERSION = 19;
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.2; MARKETING_VERSION = 1.1.7;
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 = 14; CURRENT_PROJECT_VERSION = 19;
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.2; MARKETING_VERSION = 1.1.7;
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>
+6 -2
View File
@@ -12,9 +12,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>
+26 -16
View File
@@ -183,9 +183,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()
@@ -303,11 +307,13 @@ final class AudioManager: NSObject, ObservableObject {
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 else { return }
let buffer = AVAudioPCMBuffer(pcmFormat: inputFormat, bufferListNoCopy: inputData, deallocator: nil), self.processAudioBuffer(
buffer.frameLength > 0 else { return } { AVAudioPCMBuffer(pcmFormat: inputFormat, bufferListNoCopy: inputData, deallocator: nil) },
self.updateAudioLevel(buffer, source: .system) converter: converter,
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .system) 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() }
@@ -315,11 +321,20 @@ final class AudioManager: NSObject, ObservableObject {
} }
private func processAudioBuffer( private func processAudioBuffer(
_ inputBuffer: AVAudioPCMBuffer, _ inputBufferProvider: () -> AVAudioPCMBuffer?,
converter: AVAudioConverter, converter: AVAudioConverter,
targetFormat: AVAudioFormat, targetFormat: AVAudioFormat,
source: AudioSource source: AudioSource
) { ) {
// Keep callback-owned buffers alive until conversion finishes. Teardown
// takes this same lock before invalidating the Core Audio process tap.
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 }
@@ -336,11 +351,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:
@@ -378,8 +388,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()
+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() {}
@@ -6,6 +6,11 @@ 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
@@ -47,7 +52,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 +143,51 @@ class LocalStorageManager {
return false return false
} }
} }
/// 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 +206,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 +269,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 +324,4 @@ class LocalStorageManager {
var meetingsDirectoryURL: URL { var meetingsDirectoryURL: URL {
meetingsDirectory meetingsDirectory
} }
} }
+1 -1
View File
@@ -38,7 +38,7 @@ struct MeetingnotesApp: App {
ContentView() ContentView()
.frame(minWidth: 700, minHeight: 400) .frame(minWidth: 700, minHeight: 400)
} }
.windowResizability(.contentSize) .windowResizability(.automatic)
.defaultSize(width: 1000, height: 600) .defaultSize(width: 1000, height: 600)
.commands { .commands {
CommandGroup(after: .appInfo) { CommandGroup(after: .appInfo) {
@@ -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
} }
+2 -2
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
@@ -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
} }
+2 -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
} }
@@ -288,9 +288,7 @@ private final class LocalRecordingController {
meeting.transcriptChunks = chunks meeting.transcriptChunks = chunks
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)
@@ -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
} }
} }
@@ -228,11 +228,13 @@ class MeetingViewModel: ObservableObject {
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
} }
} }
@@ -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 {
] ]
) )
} }
} }
+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"