Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19b17fc9a5 | ||
|
|
1268149114 | ||
|
|
0f45f2d066 | ||
|
|
f1d57da227 | ||
|
|
94d8469eba | ||
|
|
628587ae92 | ||
|
|
f992457cfe | ||
|
|
ad995b59ad | ||
|
|
a13153b8fe | ||
|
|
0008bd3081 | ||
|
|
6bcdd51201 | ||
|
|
ea44104458 | ||
|
|
336fb95b07 | ||
|
|
c7e29828b4 | ||
|
|
97eb58d1dc | ||
|
|
bcc16cd53f | ||
|
|
25ae1ae710 | ||
|
|
47ca61a811 | ||
|
|
e3397e5e85 | ||
|
|
d69968101a | ||
|
|
11dfd97801 | ||
|
|
e7d5455ec4 | ||
|
|
70a077492e | ||
|
|
5f128fd0cb | ||
|
|
6f60933930 | ||
|
|
f77b9f4c98 | ||
|
|
37e21bf398 |
@@ -35,8 +35,8 @@ jobs:
|
||||
| grep -q 'com.apple.security.network.server'
|
||||
- name: Smoke test local API
|
||||
run: |
|
||||
defaults write owen.meetingnotes muteDeckAPIEnabled -bool true
|
||||
defaults write owen.meetingnotes muteDeckAPIPort -int 19880
|
||||
defaults write net.jamesbone.meetingnotes muteDeckAPIEnabled -bool true
|
||||
defaults write net.jamesbone.meetingnotes muteDeckAPIPort -int 19880
|
||||
"$RUNNER_TEMP/DerivedData/Build/Products/Release/Meetingnotes.app/Contents/MacOS/Meetingnotes" >"$RUNNER_TEMP/meetingnotes.log" 2>&1 &
|
||||
app_pid=$!
|
||||
trap 'kill "$app_pid" 2>/dev/null || true' EXIT
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 you’re not comfortable with this, please feel free to contact me or open an issue to discuss alternatives.
|
||||
|
||||
@@ -276,9 +276,9 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 14;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = ML6HYR5LUR;
|
||||
DEVELOPMENT_TEAM = G9LVHZAJNX;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -290,10 +290,10 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 1.1.2;
|
||||
MARKETING_VERSION = 1.1.17;
|
||||
ONLY_ACTIVE_ARCH = NO;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -312,9 +312,9 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 14;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = ML6HYR5LUR;
|
||||
DEVELOPMENT_TEAM = G9LVHZAJNX;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -326,10 +326,10 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 1.1.2;
|
||||
MARKETING_VERSION = 1.1.17;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -366,7 +366,7 @@
|
||||
repositoryURL = "https://github.com/sparkle-project/Sparkle.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.7.1;
|
||||
minimumVersion = 2.9.4;
|
||||
};
|
||||
};
|
||||
CCC33F7F2E236B6F00EDE382 /* XCRemoteSwiftPackageReference "posthog-ios" */ = {
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sparkle-project/Sparkle.git",
|
||||
"state" : {
|
||||
"revision" : "df074165274afaa39539c05d57b0832620775b11",
|
||||
"version" : "2.7.1"
|
||||
"revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
|
||||
"version" : "2.9.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,95 +6,9 @@
|
||||
|
||||
<p align="center">
|
||||
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>
|
||||
</div>
|
||||
|
||||
## Recall.ai - Meeting Transcription API
|
||||
|
||||
Meetingnotes runs locally, capturing two streams: system & mic.
|
||||
|
||||
If you’re 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
|
||||
|
||||

|
||||
|
||||
(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
|
||||
|
||||
Implemented:
|
||||
@@ -128,18 +42,10 @@ Later:
|
||||
- AI chat for asking questions about a meeting
|
||||
- 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
|
||||
|
||||
Follow these steps to create a new release with auto-updates:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Homebrew packages: `brew install create-dmg sparkle`
|
||||
- Make scripts executable: `chmod +x scripts/update_version.sh scripts/build_release.sh`
|
||||
Production releases are Developer ID signed, notarized by Apple, published to
|
||||
GitHub Releases, and signed for Sparkle auto-updates.
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
2. **Build the release:**
|
||||
2. Commit and push the version change to `main`.
|
||||
|
||||
```bash
|
||||
./scripts/build_release.sh
|
||||
```
|
||||
3. Run the `Release` workflow from GitHub Actions and enter the version without
|
||||
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
|
||||
- Create a signed DMG file
|
||||
- Generate the appcast.xml for auto-updates
|
||||
### Recovering Meetings
|
||||
|
||||
3. **Create GitHub Release:**
|
||||
|
||||
- Go to [GitHub Releases](https://github.com/owengretzinger/meetingnotes/releases)
|
||||
- Click "Create a new release"
|
||||
- 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
|
||||
```
|
||||
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
|
||||
old `Meetings` folder. After this one-time transition, the stable signing
|
||||
identity keeps the same sandbox container across updates.
|
||||
|
||||
+5
-77
@@ -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">
|
||||
<channel>
|
||||
<title>Meetingnotes</title>
|
||||
<item>
|
||||
<title>1.1.1</title>
|
||||
<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>
|
||||
<channel>
|
||||
<title>Meetingnotes</title>
|
||||
</channel>
|
||||
</rss>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
@@ -12,9 +14,13 @@
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Meetingnotes needs access to your microphone for transcription.</string>
|
||||
<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>
|
||||
<string>BVXHOV8ZxPxKZ1swhFndymzew9nyd3si7849JA9cqsg=</string>
|
||||
<string>9ZuN9G9ERB3Qoyyd/4FsF+6LMUv5jzAGP26OXAHBiW0=</string>
|
||||
<key>SUEnableAutomaticChecks</key>
|
||||
<true/>
|
||||
<key>SUAutomaticallyUpdate</key>
|
||||
<true/>
|
||||
<key>SUEnableInstallerLauncherService</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
@@ -3,6 +3,23 @@ import Combine
|
||||
import Foundation
|
||||
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.
|
||||
@MainActor
|
||||
final class AudioManager: NSObject, ObservableObject {
|
||||
@@ -14,22 +31,20 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
@Published var errorMessage: String?
|
||||
@Published var micAudioLevel: Float = 0
|
||||
@Published var systemAudioLevel: Float = 0
|
||||
private(set) var lastRecoveryAudioFolderName: String?
|
||||
|
||||
private var audioEngine = AVAudioEngine()
|
||||
private var sessionID = UUID()
|
||||
private var meetingID = UUID()
|
||||
private var processTap: ProcessTap?
|
||||
private let audioProcessController = AudioProcessController()
|
||||
private let permission = AudioRecordingPermission()
|
||||
private let tapQueue = DispatchQueue(label: "io.meetingnotes.audiotap", qos: .userInitiated)
|
||||
private let audioFileLock = NSLock()
|
||||
private var isTapActive = false
|
||||
private var isRestartingSystemTap = false
|
||||
private var isAcceptingAudio = false
|
||||
private var micRetryCount = 0
|
||||
private var pendingMicRestart: DispatchWorkItem?
|
||||
private let maxMicRetries = 3
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private var micAudioFile: AVAudioFile?
|
||||
private var systemAudioFile: AVAudioFile?
|
||||
private var micAudioURL: URL?
|
||||
@@ -39,24 +54,18 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
private override init() {
|
||||
super.init()
|
||||
observeAudioEngine()
|
||||
audioProcessController.activate()
|
||||
NSWorkspace.shared.publisher(for: \.runningApplications)
|
||||
.debounce(for: .seconds(1), scheduler: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self, self.isTapActive else { return }
|
||||
Task { await self.restartSystemAudioTapIfNeeded() }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
func startRecording() {
|
||||
func startRecording(for meetingID: UUID) {
|
||||
errorMessage = nil
|
||||
lastRecoveryAudioFolderName = nil
|
||||
cancelCapture(removeFiles: true)
|
||||
sessionID = UUID()
|
||||
self.meetingID = meetingID
|
||||
recordingStartedAt = Date()
|
||||
do {
|
||||
try prepareAudioFiles()
|
||||
@@ -69,7 +78,7 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
func stopRecordingAndTranscribe() async -> [TranscriptChunk] {
|
||||
let completedSessionID = sessionID
|
||||
let completedMeetingID = meetingID
|
||||
let captureStartedAt = recordingStartedAt
|
||||
let files = stopCaptureAndCloseFiles()
|
||||
isProcessing = true
|
||||
@@ -83,7 +92,78 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
let (micTranscription, systemTranscription) = await (micResult, systemResult)
|
||||
let results = [micTranscription, systemTranscription]
|
||||
|
||||
var updated = transcriptChunks.filter(\.isFinal)
|
||||
let (updated, failures) = buildTranscriptChunks(
|
||||
from: results,
|
||||
captureStartedAt: captureStartedAt,
|
||||
existingChunks: transcriptChunks.filter(\.isFinal)
|
||||
)
|
||||
transcriptChunks = updated
|
||||
let completedFiles = files.compactMap { $0 }
|
||||
let audioFolder = preserveAudioFiles(completedFiles, meetingID: completedMeetingID)
|
||||
lastRecoveryAudioFolderName = audioFolder?.lastPathComponent
|
||||
if !failures.isEmpty {
|
||||
let retentionDays = UserDefaultsManager.shared.audioRetentionDays
|
||||
let retentionUnit = retentionDays == 1 ? "day" : "days"
|
||||
let recoveryMessage = audioFolder == nil
|
||||
? " The audio remains in the app's temporary folder."
|
||||
: " Audio was kept for \(retentionDays) \(retentionUnit). Use Show Audio Folder in the Meetingnotes menu to find it."
|
||||
errorMessage = "Transcription failed for " + failures.joined(separator: "; ") + recoveryMessage
|
||||
} else if audioFolder == nil, !completedFiles.isEmpty {
|
||||
errorMessage = "The transcript completed, but Meetingnotes could not move the audio into its retention folder."
|
||||
}
|
||||
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() {
|
||||
cancelCapture(removeFiles: true)
|
||||
lastRecoveryAudioFolderName = nil
|
||||
}
|
||||
|
||||
private func transcribe(_ fileURL: URL?, model: String) async -> Result<CoderAPIClient.Transcription, Error>? {
|
||||
guard let fileURL else { return nil }
|
||||
do {
|
||||
return .success(try await CoderAPIClient.shared.transcribe(fileURL: fileURL, model: model))
|
||||
} catch {
|
||||
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 }
|
||||
@@ -114,31 +194,7 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
if $0.timestamp != $1.timestamp { return $0.timestamp < $1.timestamp }
|
||||
return $0.source.rawValue < $1.source.rawValue
|
||||
}
|
||||
transcriptChunks = updated
|
||||
let completedFiles = files.compactMap { $0 }
|
||||
if failures.isEmpty {
|
||||
removeAudioFiles(completedFiles)
|
||||
} else {
|
||||
let recoveryFolder = preserveAudioFiles(completedFiles, sessionID: completedSessionID)
|
||||
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
|
||||
}
|
||||
|
||||
func cancelRecording() {
|
||||
cancelCapture(removeFiles: true)
|
||||
}
|
||||
|
||||
private func transcribe(_ fileURL: URL?, model: String) async -> Result<CoderAPIClient.Transcription, Error>? {
|
||||
guard let fileURL else { return nil }
|
||||
do {
|
||||
return .success(try await CoderAPIClient.shared.transcribe(fileURL: fileURL, model: model))
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
return (updated, failures)
|
||||
}
|
||||
|
||||
private func prepareAudioFiles() throws {
|
||||
@@ -183,9 +239,13 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported microphone format"])
|
||||
}
|
||||
inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in
|
||||
guard let self, buffer.frameLength > 0 else { return }
|
||||
self.updateAudioLevel(buffer, source: .mic)
|
||||
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .mic)
|
||||
guard let self else { return }
|
||||
self.processAudioBuffer(
|
||||
{ buffer },
|
||||
converter: converter,
|
||||
targetFormat: targetFormat,
|
||||
source: .mic
|
||||
)
|
||||
}
|
||||
audioEngine.prepare()
|
||||
try audioEngine.start()
|
||||
@@ -235,8 +295,7 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
let processIDs = audioProcessController.processes.map(\.objectID)
|
||||
let newTap = ProcessTap(target: .systemAudio(processObjectIDs: processIDs))
|
||||
let newTap = ProcessTap(target: .systemAudio)
|
||||
newTap.activate()
|
||||
if let tapError = newTap.errorMessage {
|
||||
errorMessage = "Failed to activate system audio capture: \(tapError)"
|
||||
@@ -260,21 +319,8 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func restartSystemAudioTapIfNeeded() async {
|
||||
let next = Set(audioProcessController.processes.map(\.objectID))
|
||||
let current: Set<AudioObjectID>
|
||||
if case .systemAudio(let processIDs) = processTap?.target {
|
||||
current = Set(processIDs)
|
||||
} else {
|
||||
current = []
|
||||
}
|
||||
if next != current { await restartSystemAudioTap() }
|
||||
}
|
||||
|
||||
private func restartSystemAudioTap() async {
|
||||
guard isRecording else { return }
|
||||
isRestartingSystemTap = true
|
||||
defer { isRestartingSystemTap = false }
|
||||
if isTapActive {
|
||||
processTap?.invalidate()
|
||||
processTap = nil
|
||||
@@ -303,23 +349,72 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported system audio format"])
|
||||
}
|
||||
try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in
|
||||
guard let self,
|
||||
let buffer = AVAudioPCMBuffer(pcmFormat: inputFormat, bufferListNoCopy: inputData, deallocator: nil),
|
||||
buffer.frameLength > 0 else { return }
|
||||
self.updateAudioLevel(buffer, source: .system)
|
||||
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .system)
|
||||
guard let self else { return }
|
||||
// The tap queue is serial. Reusing the converter preserves its
|
||||
// resampler state instead of discarding audio at every callback.
|
||||
self.processAudioBuffer(
|
||||
{ self.copyAudioBuffer(from: inputData, format: inputFormat) },
|
||||
converter: converter,
|
||||
targetFormat: targetFormat,
|
||||
source: .system
|
||||
)
|
||||
} invalidationHandler: { [weak self] _ in
|
||||
guard let self, !self.isRestartingSystemTap, self.isRecording else { return }
|
||||
guard let self, self.isRecording else { return }
|
||||
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(
|
||||
_ inputBuffer: AVAudioPCMBuffer,
|
||||
_ inputBufferProvider: () -> AVAudioPCMBuffer?,
|
||||
converter: AVAudioConverter,
|
||||
targetFormat: AVAudioFormat,
|
||||
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 capacity = max(1, AVAudioFrameCount(ceil(Double(inputBuffer.frameLength) * ratio)))
|
||||
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { return }
|
||||
@@ -336,11 +431,6 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
}
|
||||
guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { return }
|
||||
|
||||
audioFileLock.lock()
|
||||
defer { audioFileLock.unlock() }
|
||||
guard isAcceptingAudio else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
switch source {
|
||||
case .mic:
|
||||
@@ -378,8 +468,8 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
pendingMicRestart = nil
|
||||
AudioLevelManager.shared.updateRecordingState(false)
|
||||
|
||||
// Stop new writes and wait for any callback already writing before
|
||||
// AVAudioFile is finalized and released.
|
||||
// Stop new callbacks and wait for any active conversion/write before
|
||||
// invalidating callback-owned buffers or finalizing AVAudioFile.
|
||||
audioFileLock.lock()
|
||||
isAcceptingAudio = false
|
||||
audioFileLock.unlock()
|
||||
@@ -417,29 +507,8 @@ final class AudioManager: NSObject, ObservableObject {
|
||||
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 preserveAudioFiles(_ urls: [URL], meetingID: UUID) -> URL? {
|
||||
LocalStorageManager.shared.preserveAudioFiles(urls, for: meetingID)
|
||||
}
|
||||
|
||||
private func resetAudioLevels() {
|
||||
|
||||
@@ -8,7 +8,7 @@ import Security
|
||||
class KeychainHelper {
|
||||
static let shared = KeychainHelper()
|
||||
|
||||
private let serviceName = "owen.meetingnotes"
|
||||
private let serviceName = "net.jamesbone.meetingnotes"
|
||||
|
||||
private init() {}
|
||||
|
||||
|
||||
@@ -2,14 +2,21 @@
|
||||
// Handles local storage of meetings and app data
|
||||
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
/// Manages local file storage for meetings and app data
|
||||
class LocalStorageManager {
|
||||
static let shared = LocalStorageManager()
|
||||
|
||||
struct MeetingImportResult {
|
||||
let importedCount: Int
|
||||
let skippedCount: Int
|
||||
}
|
||||
|
||||
private let documentsDirectory: URL
|
||||
private let meetingsDirectory: URL
|
||||
private let templatesDirectory: URL
|
||||
private let recoveryDirectory: URL
|
||||
|
||||
private init() {
|
||||
// Get the app's documents directory
|
||||
@@ -21,12 +28,18 @@ class LocalStorageManager {
|
||||
|
||||
// Create templates subdirectory
|
||||
templatesDirectory = documentsDirectory.appendingPathComponent("Templates")
|
||||
|
||||
recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes Audio")
|
||||
|
||||
// Ensure directories exist
|
||||
try? FileManager.default.createDirectory(at: meetingsDirectory,
|
||||
withIntermediateDirectories: true)
|
||||
try? FileManager.default.createDirectory(at: templatesDirectory,
|
||||
withIntermediateDirectories: true)
|
||||
try? FileManager.default.createDirectory(at: recoveryDirectory,
|
||||
withIntermediateDirectories: true)
|
||||
migrateLegacyRecoveryAudio()
|
||||
purgeExpiredAudioFolders()
|
||||
}
|
||||
|
||||
// MARK: - Meeting Management
|
||||
@@ -47,7 +60,17 @@ class LocalStorageManager {
|
||||
// Write atomically using a temp file then replace
|
||||
let tmpURL = fileURL.appendingPathExtension("tmp")
|
||||
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)")
|
||||
return true
|
||||
@@ -128,6 +151,224 @@ class LocalStorageManager {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recovery Audio
|
||||
|
||||
func preserveAudioFiles(_ urls: [URL], for meetingID: UUID) -> URL? {
|
||||
guard !urls.isEmpty else { return nil }
|
||||
purgeExpiredAudioFolders()
|
||||
|
||||
let folder = recoveryDirectory.appendingPathComponent(meetingID.uuidString, isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
var preservedCount = 0
|
||||
for url in urls {
|
||||
let destination = folder.appendingPathComponent(url.lastPathComponent)
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
try FileManager.default.removeItem(at: destination)
|
||||
}
|
||||
try FileManager.default.moveItem(at: url, to: destination)
|
||||
preservedCount += 1
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return preservedCount > 0 ? folder : nil
|
||||
}
|
||||
|
||||
func purgeExpiredAudioFolders(now: Date = Date()) {
|
||||
guard let folders = try? FileManager.default.contentsOfDirectory(
|
||||
at: recoveryDirectory,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else { return }
|
||||
|
||||
let retentionInterval = TimeInterval(UserDefaultsManager.shared.audioRetentionDays) * 24 * 60 * 60
|
||||
let expirationDate = now.addingTimeInterval(-retentionInterval)
|
||||
for folder in folders {
|
||||
guard (try? folder.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue }
|
||||
let audioFiles = recoveryAudioFiles(in: folder)
|
||||
let newestDate = audioFiles.compactMap { file -> Date? in
|
||||
let values = try? file.url.resourceValues(forKeys: [.contentModificationDateKey, .creationDateKey])
|
||||
return values?.contentModificationDate ?? values?.creationDate
|
||||
}.max()
|
||||
|
||||
guard let newestDate else {
|
||||
try? FileManager.default.removeItem(at: folder)
|
||||
continue
|
||||
}
|
||||
if newestDate < expirationDate {
|
||||
try? FileManager.default.removeItem(at: folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func showAudioFolderInFinder(_ folder: URL? = nil) {
|
||||
let target = folder ?? recoveryDirectory
|
||||
try? FileManager.default.createDirectory(at: target, withIntermediateDirectories: true)
|
||||
NSWorkspace.shared.open(target)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private func migrateLegacyRecoveryAudio() {
|
||||
let legacyDirectory = documentsDirectory.appendingPathComponent("Meetingnotes-Recovery", isDirectory: true)
|
||||
guard let folders = try? FileManager.default.contentsOfDirectory(
|
||||
at: legacyDirectory,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else { return }
|
||||
|
||||
for folder in folders {
|
||||
let destination = recoveryDirectory.appendingPathComponent(folder.lastPathComponent, isDirectory: true)
|
||||
guard !FileManager.default.fileExists(atPath: destination.path) else { continue }
|
||||
try? FileManager.default.moveItem(at: folder, to: destination)
|
||||
}
|
||||
try? FileManager.default.removeItem(at: legacyDirectory)
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -146,7 +387,17 @@ class LocalStorageManager {
|
||||
// Write atomically using a temp file then replace
|
||||
let tmpURL = fileURL.appendingPathExtension("tmp")
|
||||
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)")
|
||||
return true
|
||||
@@ -199,6 +450,19 @@ class LocalStorageManager {
|
||||
|
||||
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
|
||||
/// - Parameter template: The template to delete
|
||||
@@ -241,4 +505,4 @@ class LocalStorageManager {
|
||||
var meetingsDirectoryURL: URL {
|
||||
meetingsDirectory
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ class RecordingSessionManager: ObservableObject {
|
||||
|
||||
activeMeetingId = meetingId
|
||||
recordingStartedAt = Date()
|
||||
audioManager.startRecording()
|
||||
audioManager.startRecording(for: meetingId)
|
||||
}
|
||||
|
||||
func stopRecording() async -> [TranscriptChunk] {
|
||||
@@ -112,6 +112,10 @@ class RecordingSessionManager: ObservableObject {
|
||||
func isRecordingMeeting(_ meetingId: UUID) -> Bool {
|
||||
return isRecording && activeMeetingId == meetingId
|
||||
}
|
||||
|
||||
var lastRecoveryAudioFolderName: String? {
|
||||
audioManager.lastRecoveryAudioFolderName
|
||||
}
|
||||
|
||||
private func updateActiveMeetingTranscript(meetingId: UUID, chunks: [TranscriptChunk]) {
|
||||
// Load all meetings
|
||||
|
||||
@@ -23,6 +23,7 @@ class UserDefaultsManager {
|
||||
static let transcriptionModel = "transcriptionModel"
|
||||
static let muteDeckAPIEnabled = "muteDeckAPIEnabled"
|
||||
static let muteDeckAPIPort = "muteDeckAPIPort"
|
||||
static let audioRetentionDays = "audioRetentionDays"
|
||||
}
|
||||
|
||||
// MARK: - User Blurb
|
||||
@@ -94,4 +95,12 @@ class UserDefaultsManager {
|
||||
}
|
||||
set { userDefaults.set(newValue, forKey: Keys.muteDeckAPIPort) }
|
||||
}
|
||||
|
||||
var audioRetentionDays: Int {
|
||||
get {
|
||||
guard userDefaults.object(forKey: Keys.audioRetentionDays) != nil else { return 3 }
|
||||
return min(max(userDefaults.integer(forKey: Keys.audioRetentionDays), 1), 365)
|
||||
}
|
||||
set { userDefaults.set(min(max(newValue, 1), 365), forKey: Keys.audioRetentionDays) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import Sparkle
|
||||
import PostHog
|
||||
|
||||
@main
|
||||
struct MeetingnotesApp: App {
|
||||
private let updaterController: SPUStandardUpdaterController
|
||||
@StateObject private var recordingSessionManager = RecordingSessionManager.shared
|
||||
|
||||
init() {
|
||||
updaterController = SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil)
|
||||
@@ -34,17 +36,145 @@ struct MeetingnotesApp: App {
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WindowGroup("Meetingnotes", id: "main") {
|
||||
ContentView()
|
||||
.frame(minWidth: 700, minHeight: 400)
|
||||
.background(MainWindowConfigurator())
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
.windowResizability(.automatic)
|
||||
.defaultSize(width: 1000, height: 600)
|
||||
.commands {
|
||||
CommandGroup(after: .appInfo) {
|
||||
CheckForUpdatesView(updater: updaterController.updater)
|
||||
Divider()
|
||||
Button("Show Audio Folder") {
|
||||
LocalStorageManager.shared.showAudioFolderInFinder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
Button {
|
||||
LocalStorageManager.shared.showAudioFolderInFinder()
|
||||
} label: {
|
||||
Label("Show Audio Folder", systemImage: "folder")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ struct Meeting: Codable, Identifiable, Hashable {
|
||||
var userNotes: String
|
||||
var generatedNotes: String
|
||||
var templateId: UUID? // Add property to track per-meeting template
|
||||
var recoveryAudioFolderName: String?
|
||||
// MARK: - Data versioning
|
||||
/// Version of this Meeting record on disk. Useful for migration.
|
||||
var dataVersion: Int
|
||||
@@ -95,6 +96,7 @@ struct Meeting: Codable, Identifiable, Hashable {
|
||||
userNotes: String = "",
|
||||
generatedNotes: String = "",
|
||||
templateId: UUID? = nil,
|
||||
recoveryAudioFolderName: String? = nil,
|
||||
dataVersion: Int = Meeting.currentDataVersion) {
|
||||
self.id = id
|
||||
self.date = date
|
||||
@@ -103,6 +105,7 @@ struct Meeting: Codable, Identifiable, Hashable {
|
||||
self.userNotes = userNotes
|
||||
self.generatedNotes = generatedNotes
|
||||
self.templateId = templateId
|
||||
self.recoveryAudioFolderName = recoveryAudioFolderName
|
||||
self.dataVersion = dataVersion
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,11 @@ struct Settings: Codable {
|
||||
set { UserDefaultsManager.shared.muteDeckAPIPort = newValue }
|
||||
}
|
||||
|
||||
var audioRetentionDays: Int {
|
||||
get { UserDefaultsManager.shared.audioRetentionDays }
|
||||
set { UserDefaultsManager.shared.audioRetentionDays = newValue }
|
||||
}
|
||||
|
||||
// System prompt default loading
|
||||
static func defaultSystemPrompt() -> String {
|
||||
guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"),
|
||||
|
||||
@@ -49,7 +49,7 @@ extension String: @retroactive LocalizedError {
|
||||
@Observable
|
||||
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]() {
|
||||
didSet {
|
||||
@@ -236,4 +236,4 @@ private extension URL {
|
||||
var isApp: Bool {
|
||||
(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.
|
||||
@Observable
|
||||
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 {
|
||||
case unknown
|
||||
@@ -122,4 +122,4 @@ final class AudioRecordingPermission {
|
||||
return fn
|
||||
}()
|
||||
#endif // ENABLE_TCC_SPI
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import AVFoundation
|
||||
|
||||
enum TapTarget {
|
||||
case singleProcess(AudioProcess)
|
||||
case systemAudio(processObjectIDs: [AudioObjectID])
|
||||
case systemAudio
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
@@ -51,7 +51,7 @@ final class ProcessTap {
|
||||
init(target: TapTarget, muteWhenRunning: Bool = false) {
|
||||
self.target = target
|
||||
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
|
||||
@@ -137,12 +137,9 @@ final class ProcessTap {
|
||||
case .singleProcess(let process):
|
||||
tapDescription = CATapDescription(stereoMixdownOfProcesses: [process.objectID])
|
||||
logger.debug("Configuring tap for single process objectID: \(process.objectID)")
|
||||
case .systemAudio(let processObjectIDs):
|
||||
if processObjectIDs.isEmpty {
|
||||
logger.warning("System audio tap configured with an empty list of processObjectIDs. This might not capture any audio or behave unexpectedly.")
|
||||
}
|
||||
tapDescription = CATapDescription(monoMixdownOfProcesses: processObjectIDs)
|
||||
logger.debug("Configuring tap for system audio output using \(processObjectIDs.count) explicit processes.")
|
||||
case .systemAudio:
|
||||
tapDescription = CATapDescription(monoGlobalTapButExcludeProcesses: [])
|
||||
logger.debug("Configuring a global system audio tap.")
|
||||
}
|
||||
|
||||
tapDescription.uuid = UUID()
|
||||
@@ -327,7 +324,7 @@ final class ProcessTapRecorder {
|
||||
self.tapDisplayName = tap.displayName
|
||||
self.fileURL = fileURL
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
struct CoderModel: Codable, Identifiable, Hashable {
|
||||
@@ -73,6 +74,13 @@ final class CoderAPIClient {
|
||||
let segments: [Transcription.Segment]?
|
||||
}
|
||||
|
||||
private struct AudioChunk {
|
||||
let url: URL
|
||||
let offset: TimeInterval
|
||||
let isTemporary: Bool
|
||||
}
|
||||
|
||||
private let transcriptionChunkDuration: TimeInterval = 3 * 60
|
||||
private let transcriptionSession: URLSession
|
||||
|
||||
private init() {
|
||||
@@ -97,7 +105,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
|
||||
Task {
|
||||
do {
|
||||
@@ -113,7 +125,7 @@ final class CoderAPIClient {
|
||||
"model": selectedModel,
|
||||
"messages": [
|
||||
["role": "system", "content": systemPrompt],
|
||||
["role": "user", "content": "Create the meeting notes now."]
|
||||
["role": "user", "content": userPrompt]
|
||||
],
|
||||
"stream": true
|
||||
])
|
||||
@@ -151,10 +163,67 @@ final class CoderAPIClient {
|
||||
let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") }
|
||||
let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "")
|
||||
let chunks = try makeAudioChunks(from: fileURL)
|
||||
defer {
|
||||
for chunk in chunks where chunk.isTemporary {
|
||||
try? FileManager.default.removeItem(at: chunk.url)
|
||||
}
|
||||
}
|
||||
|
||||
var textParts: [String] = []
|
||||
var segments: [Transcription.Segment] = []
|
||||
var lastNormalizedText = ""
|
||||
var consecutiveDuplicateCount = 0
|
||||
|
||||
for chunk in chunks {
|
||||
let transcription = try await transcribeChunk(
|
||||
chunk.url,
|
||||
model: selectedModel,
|
||||
language: language,
|
||||
apiKey: apiKey
|
||||
)
|
||||
if transcription.segments.isEmpty {
|
||||
let text = transcription.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !text.isEmpty {
|
||||
textParts.append(text)
|
||||
segments.append(.init(start: chunk.offset, end: chunk.offset, text: text))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for segment in transcription.segments {
|
||||
let text = segment.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { continue }
|
||||
let normalized = text.lowercased()
|
||||
if normalized == lastNormalizedText {
|
||||
consecutiveDuplicateCount += 1
|
||||
} else {
|
||||
lastNormalizedText = normalized
|
||||
consecutiveDuplicateCount = 1
|
||||
}
|
||||
guard consecutiveDuplicateCount <= 2 else { continue }
|
||||
textParts.append(text)
|
||||
segments.append(.init(
|
||||
start: segment.start + chunk.offset,
|
||||
end: segment.end + chunk.offset,
|
||||
text: text
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return Transcription(text: textParts.joined(separator: "\n"), segments: segments)
|
||||
}
|
||||
|
||||
private func transcribeChunk(
|
||||
_ fileURL: URL,
|
||||
model: String,
|
||||
language: String,
|
||||
apiKey: String
|
||||
) async throws -> Transcription {
|
||||
let boundary = "Meetingnotes-\(UUID().uuidString)"
|
||||
let bodyURL = try makeMultipartBody(
|
||||
audioURL: fileURL,
|
||||
model: selectedModel,
|
||||
model: model,
|
||||
language: language,
|
||||
boundary: boundary
|
||||
)
|
||||
@@ -174,6 +243,80 @@ final class CoderAPIClient {
|
||||
return Transcription(text: decoded.text, segments: decoded.segments ?? [])
|
||||
}
|
||||
|
||||
private func makeAudioChunks(from fileURL: URL) throws -> [AudioChunk] {
|
||||
let input = try AVAudioFile(forReading: fileURL)
|
||||
let format = input.processingFormat
|
||||
guard format.sampleRate > 0 else {
|
||||
return [AudioChunk(url: fileURL, offset: 0, isTemporary: false)]
|
||||
}
|
||||
|
||||
let duration = Double(input.length) / format.sampleRate
|
||||
guard duration > transcriptionChunkDuration else {
|
||||
return [AudioChunk(url: fileURL, offset: 0, isTemporary: false)]
|
||||
}
|
||||
|
||||
let framesPerChunk = AVAudioFramePosition(format.sampleRate * transcriptionChunkDuration)
|
||||
var chunks: [AudioChunk] = []
|
||||
var frameOffset: AVAudioFramePosition = 0
|
||||
|
||||
do {
|
||||
while frameOffset < input.length {
|
||||
let frameCount = min(framesPerChunk, input.length - frameOffset)
|
||||
let chunkURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("meetingnotes-transcription-\(UUID().uuidString).m4a")
|
||||
try writeAudioChunk(
|
||||
from: input,
|
||||
frameCount: frameCount,
|
||||
format: format,
|
||||
to: chunkURL
|
||||
)
|
||||
chunks.append(AudioChunk(
|
||||
url: chunkURL,
|
||||
offset: Double(frameOffset) / format.sampleRate,
|
||||
isTemporary: true
|
||||
))
|
||||
frameOffset += frameCount
|
||||
}
|
||||
return chunks
|
||||
} catch {
|
||||
for chunk in chunks {
|
||||
try? FileManager.default.removeItem(at: chunk.url)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func writeAudioChunk(
|
||||
from input: AVAudioFile,
|
||||
frameCount: AVAudioFramePosition,
|
||||
format: AVAudioFormat,
|
||||
to outputURL: URL
|
||||
) throws {
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: format.sampleRate,
|
||||
AVNumberOfChannelsKey: format.channelCount,
|
||||
AVEncoderBitRateKey: 48_000 * max(1, Int(format.channelCount))
|
||||
]
|
||||
let output = try AVAudioFile(
|
||||
forWriting: outputURL,
|
||||
settings: settings,
|
||||
commonFormat: format.commonFormat,
|
||||
interleaved: format.isInterleaved
|
||||
)
|
||||
var remaining = frameCount
|
||||
while remaining > 0 {
|
||||
let requestedFrames = AVAudioFrameCount(min(remaining, 8_192))
|
||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: requestedFrames) else {
|
||||
throw CoderAPIError.invalidResponse
|
||||
}
|
||||
try input.read(into: buffer, frameCount: requestedFrames)
|
||||
guard buffer.frameLength > 0 else { break }
|
||||
try output.write(from: buffer)
|
||||
remaining -= AVAudioFramePosition(buffer.frameLength)
|
||||
}
|
||||
}
|
||||
|
||||
private func endpoint(baseURL: String, path: String) throws -> URL {
|
||||
guard var components = URLComponents(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
|
||||
@@ -244,7 +244,7 @@ private final class LocalRecordingController {
|
||||
return statusPayload()
|
||||
}
|
||||
|
||||
let meeting = Meeting()
|
||||
let meeting = Meeting(templateId: LocalStorageManager.shared.preferredTemplateID())
|
||||
guard LocalStorageManager.shared.saveMeeting(meeting) else {
|
||||
throw LocalRecordingError.saveFailed
|
||||
}
|
||||
@@ -286,11 +286,10 @@ private final class LocalRecordingController {
|
||||
}
|
||||
|
||||
meeting.transcriptChunks = chunks
|
||||
meeting.recoveryAudioFolderName = recordingManager.lastRecoveryAudioFolderName
|
||||
let templates = LocalStorageManager.shared.loadTemplates()
|
||||
if meeting.templateId == nil {
|
||||
meeting.templateId = UserDefaultsManager.shared.selectedTemplateId
|
||||
?? templates.first(where: { $0.title == "Standard Meeting" })?.id
|
||||
?? templates.first?.id
|
||||
meeting.templateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
|
||||
}
|
||||
_ = LocalStorageManager.shared.saveMeeting(meeting)
|
||||
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
|
||||
@@ -311,8 +310,27 @@ private final class LocalRecordingController {
|
||||
generatedNotes = ""
|
||||
}
|
||||
}
|
||||
var meetingChanged = false
|
||||
if !generatedNotes.isEmpty {
|
||||
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)
|
||||
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// - Returns: True if API key exists, false otherwise
|
||||
@@ -115,4 +197,4 @@ class NotesGenerator {
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,11 +68,11 @@ class MeetingListViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func createNewMeeting() -> Meeting {
|
||||
let newMeeting = Meeting()
|
||||
let newMeeting = Meeting(templateId: LocalStorageManager.shared.preferredTemplateID())
|
||||
meetings.insert(newMeeting, at: 0)
|
||||
_ = LocalStorageManager.shared.saveMeeting(newMeeting)
|
||||
// Track meeting creation event
|
||||
PostHogSDK.shared.capture("meeting_created")
|
||||
return newMeeting
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ extension Notification.Name {
|
||||
enum MeetingViewTab: String, CaseIterable {
|
||||
case myNotes = "My Notes"
|
||||
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 var isValidatingKey = false // Indicates API key validation 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
|
||||
var shouldAnimateGenerateButton: Bool {
|
||||
@@ -44,7 +46,13 @@ class MeetingViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -54,15 +62,6 @@ class MeetingViewModel: ObservableObject {
|
||||
|
||||
private let recordingSessionManager = RecordingSessionManager.shared
|
||||
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()) {
|
||||
// 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
|
||||
if !self.meeting.generatedNotes.isEmpty {
|
||||
selectedTab = .enhancedNotes
|
||||
@@ -88,6 +84,7 @@ class MeetingViewModel: ObservableObject {
|
||||
|
||||
// Load templates and selected template
|
||||
loadTemplates()
|
||||
refreshRecoveryAudioFolder()
|
||||
// Observe template selection: save to meeting and regenerate notes on changes (skip initial)
|
||||
$selectedTemplateId
|
||||
.dropFirst()
|
||||
@@ -217,6 +214,8 @@ class MeetingViewModel: ObservableObject {
|
||||
Task {
|
||||
let chunks = await recordingSessionManager.stopRecording()
|
||||
meeting.transcriptChunks = chunks
|
||||
meeting.recoveryAudioFolderName = recordingSessionManager.lastRecoveryAudioFolderName
|
||||
refreshRecoveryAudioFolder()
|
||||
saveMeeting()
|
||||
if !meeting.formattedTranscript.isEmpty {
|
||||
await generateNotes()
|
||||
@@ -224,15 +223,57 @@ class MeetingViewModel: ObservableObject {
|
||||
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)
|
||||
}
|
||||
|
||||
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 showAudioInFinder() {
|
||||
guard let recoveryAudioFolderURL else { return }
|
||||
LocalStorageManager.shared.showAudioFolderInFinder(recoveryAudioFolderURL)
|
||||
}
|
||||
|
||||
func loadTemplates() {
|
||||
templates = LocalStorageManager.shared.loadTemplates()
|
||||
|
||||
// Load per-meeting template or default to Standard Meeting
|
||||
if let meetingTemplateId = meeting.templateId {
|
||||
// Keep an existing meeting's template, otherwise use the configured default.
|
||||
if let meetingTemplateId = meeting.templateId,
|
||||
templates.contains(where: { $0.id == meetingTemplateId }) {
|
||||
selectedTemplateId = meetingTemplateId
|
||||
} else if let defaultTemplate = templates.first(where: { $0.title == "Standard Meeting" }) {
|
||||
selectedTemplateId = defaultTemplate.id
|
||||
} else {
|
||||
selectedTemplateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
|
||||
meeting.templateId = selectedTemplateId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,20 +311,40 @@ class MeetingViewModel: ObservableObject {
|
||||
|
||||
// Only save if there was no error
|
||||
if !hasError {
|
||||
await generateTitleIfNeeded()
|
||||
saveMeeting()
|
||||
}
|
||||
|
||||
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() {
|
||||
if isDeleted { return }
|
||||
@discardableResult
|
||||
func saveMeeting() -> Bool {
|
||||
if isDeleted { return false }
|
||||
print("💾 Saving meeting: \(meeting.id)")
|
||||
let success = LocalStorageManager.shared.saveMeeting(meeting)
|
||||
print("💾 Save result: \(success ? "SUCCESS" : "FAILED")")
|
||||
if success {
|
||||
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
func copyCurrentTabContent() {
|
||||
@@ -331,12 +392,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() {
|
||||
templates = LocalStorageManager.shared.loadTemplates()
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
settings.selectedTemplateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
|
||||
}
|
||||
|
||||
func saveSettings(showMessage: Bool = true) {
|
||||
@@ -88,6 +71,7 @@ class SettingsViewModel: ObservableObject {
|
||||
// via computed properties when they're modified
|
||||
let coderSaved = KeychainHelper.shared.saveCoderAPIKey(settings.coderAPIKey)
|
||||
LocalAPIServer.shared.applyConfiguration()
|
||||
LocalStorageManager.shared.purgeExpiredAudioFolders()
|
||||
|
||||
if showMessage {
|
||||
if coderSaved {
|
||||
|
||||
@@ -4,6 +4,7 @@ import SwiftUI
|
||||
@MainActor
|
||||
class TemplatesViewModel: ObservableObject {
|
||||
@Published var templates: [NoteTemplate] = []
|
||||
@Published private(set) var defaultTemplateID: UUID? = nil
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
@@ -14,8 +15,14 @@ class TemplatesViewModel: ObservableObject {
|
||||
func loadTemplates() {
|
||||
isLoading = true
|
||||
templates = LocalStorageManager.shared.loadTemplates()
|
||||
defaultTemplateID = LocalStorageManager.shared.preferredTemplateID(in: templates)
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func setDefaultTemplate(_ template: NoteTemplate) {
|
||||
UserDefaultsManager.shared.selectedTemplateId = template.id
|
||||
defaultTemplateID = template.id
|
||||
}
|
||||
|
||||
func saveTemplate(_ template: NoteTemplate) {
|
||||
if LocalStorageManager.shared.saveTemplate(template) {
|
||||
@@ -42,4 +49,4 @@ class TemplatesViewModel: ObservableObject {
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +259,23 @@ struct MeetingDetailContentView: View {
|
||||
|
||||
// Ellipsis menu
|
||||
Menu {
|
||||
if viewModel.recoveryAudioFolderURL != nil {
|
||||
Button {
|
||||
viewModel.retryTranscription()
|
||||
} label: {
|
||||
Label("Retry Transcription", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(!viewModel.canRetryTranscription)
|
||||
|
||||
Button {
|
||||
viewModel.showAudioInFinder()
|
||||
} label: {
|
||||
Label("Show Audio in Finder", systemImage: "folder")
|
||||
}
|
||||
|
||||
Divider()
|
||||
}
|
||||
|
||||
Button("Delete Meeting", role: .destructive) {
|
||||
showDeleteAlert = true
|
||||
}
|
||||
@@ -327,7 +344,7 @@ struct MeetingDetailContentView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.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
|
||||
Button(action: {
|
||||
@@ -374,7 +391,7 @@ struct MeetingDetailContentView: View {
|
||||
|
||||
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 {
|
||||
Button(action: {
|
||||
isEditing.toggle()
|
||||
@@ -445,8 +462,9 @@ struct MeetingDetailContentView: View {
|
||||
Text("Are you sure you want to delete this meeting? This action cannot be undone.")
|
||||
}
|
||||
.onDisappear {
|
||||
// Auto-delete empty meetings when leaving, otherwise save
|
||||
viewModel.deleteIfEmpty()
|
||||
// A failed recording may still be empty. Keep it until the user
|
||||
// explicitly deletes it so app updates cannot erase history.
|
||||
viewModel.saveMeeting()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct SettingsView: View {
|
||||
@ObservedObject var viewModel: SettingsViewModel
|
||||
@StateObject private var localAPIServer = LocalAPIServer.shared
|
||||
@State private var showingTemplateManager = false
|
||||
@State private var confirmingTokenRegeneration = false
|
||||
@State private var showingMeetingImporter = false
|
||||
@State private var meetingImportMessage = ""
|
||||
@State private var showingMeetingImportResult = false
|
||||
@Binding var navigationPath: NavigationPath
|
||||
|
||||
init(viewModel: SettingsViewModel, navigationPath: Binding<NavigationPath> = .constant(NavigationPath())) {
|
||||
@@ -119,6 +123,34 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Meeting Storage")
|
||||
.font(.headline)
|
||||
|
||||
LabeledContent("Audio retention") {
|
||||
Stepper(
|
||||
value: $viewModel.settings.audioRetentionDays,
|
||||
in: 1...365
|
||||
) {
|
||||
Text("\(viewModel.settings.audioRetentionDays) \(viewModel.settings.audioRetentionDays == 1 ? "day" : "days")")
|
||||
.monospacedDigit()
|
||||
.frame(minWidth: 70, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
LocalStorageManager.shared.showAudioFolderInFinder()
|
||||
} label: {
|
||||
Label("Show Audio Folder", systemImage: "folder")
|
||||
}
|
||||
|
||||
Button {
|
||||
showingMeetingImporter = true
|
||||
} label: {
|
||||
Label("Import Meetings...", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
}
|
||||
|
||||
// Note Templates Section: only the Manage Templates button
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -129,6 +161,12 @@ struct SettingsView: View {
|
||||
Text("Create and manage note templates")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Picker("Default template", selection: $viewModel.settings.selectedTemplateId) {
|
||||
ForEach(viewModel.templates) { template in
|
||||
Text(template.title).tag(Optional(template.id))
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
navigationPath.append("templates")
|
||||
@@ -276,6 +314,29 @@ struct SettingsView: View {
|
||||
} message: {
|
||||
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) {
|
||||
Button("Regenerate", role: .destructive) {
|
||||
viewModel.regenerateMuteDeckAPIToken()
|
||||
|
||||
@@ -36,13 +36,13 @@ struct TemplateEditView: View {
|
||||
TextEditor(text: $template.context)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.05))
|
||||
.cornerRadius(8)
|
||||
.frame(minHeight: 100)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.frame(height: 140)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
// Sections
|
||||
@@ -55,14 +55,12 @@ struct TemplateEditView: View {
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
withAnimation {
|
||||
template.sections.append(
|
||||
TemplateSection(
|
||||
title: "New Section",
|
||||
description: "Description of this section"
|
||||
)
|
||||
template.sections.append(
|
||||
TemplateSection(
|
||||
title: "New Section",
|
||||
description: "Description of this section"
|
||||
)
|
||||
}
|
||||
)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "plus")
|
||||
@@ -77,29 +75,27 @@ struct TemplateEditView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
ForEach(template.sections.indices, id: \.self) { index in
|
||||
ForEach($template.sections) { $section in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextField("Section Title", text: $template.sections[index].title)
|
||||
TextField("Section Title", text: $section.title)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
TextEditor(text: $template.sections[index].description)
|
||||
TextEditor(text: $section.description)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.05))
|
||||
.cornerRadius(8)
|
||||
.frame(minHeight: 60)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.frame(height: 90)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
Button {
|
||||
withAnimation {
|
||||
let _ = template.sections.remove(at: index)
|
||||
}
|
||||
template.sections.removeAll { $0.id == section.id }
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.foregroundColor(.red)
|
||||
@@ -131,12 +127,19 @@ struct TemplateEditView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top)
|
||||
.disabled(template.title.isEmpty || template.sections.isEmpty)
|
||||
.disabled(!canSave)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.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 {
|
||||
@@ -152,4 +155,4 @@ struct TemplateEditView: View {
|
||||
)
|
||||
) { _ in }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,21 +2,21 @@ import SwiftUI
|
||||
|
||||
struct TemplateListView: View {
|
||||
@StateObject private var viewModel = TemplatesViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var editingTemplate: NoteTemplate?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(viewModel.templates) { template in
|
||||
HStack {
|
||||
NavigationLink(destination: TemplateEditView(template: template) { updatedTemplate in
|
||||
viewModel.saveTemplate(updatedTemplate)
|
||||
}) {
|
||||
Button {
|
||||
presentEditor(for: template)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(template.title)
|
||||
.font(.headline)
|
||||
if template.isDefault {
|
||||
Text("Default")
|
||||
Text("Built-in")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
@@ -36,8 +36,18 @@ struct TemplateListView: View {
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
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 {
|
||||
Button(role: .destructive) {
|
||||
@@ -50,6 +60,14 @@ struct TemplateListView: View {
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
if viewModel.defaultTemplateID != template.id {
|
||||
Button {
|
||||
viewModel.setDefaultTemplate(template)
|
||||
} label: {
|
||||
Label("Use as Default", systemImage: "star")
|
||||
}
|
||||
}
|
||||
|
||||
if !template.isDefault {
|
||||
Button(role: .destructive) {
|
||||
viewModel.deleteTemplate(template)
|
||||
@@ -70,9 +88,9 @@ struct TemplateListView: View {
|
||||
.navigationTitle("Note Templates")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
NavigationLink(destination: TemplateEditView(template: viewModel.createNewTemplate()) { updatedTemplate in
|
||||
viewModel.saveTemplate(updatedTemplate)
|
||||
}) {
|
||||
Button {
|
||||
presentEditor(for: viewModel.createNewTemplate())
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
@@ -89,9 +107,22 @@ struct TemplateListView: View {
|
||||
} message: {
|
||||
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 {
|
||||
TemplateListView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.owen.meetingnotes</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.screen-capture</key>
|
||||
@@ -20,8 +16,8 @@
|
||||
<true/>
|
||||
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
||||
<array>
|
||||
<string>owen.meetingnotes-spks</string>
|
||||
<string>owen.meetingnotes-spki</string>
|
||||
<string>net.jamesbone.meetingnotes-spks</string>
|
||||
<string>net.jamesbone.meetingnotes-spki</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -7,7 +7,7 @@ set -e # Exit on any error
|
||||
|
||||
# Configuration
|
||||
APP_NAME="Meetingnotes"
|
||||
BUNDLE_ID="owen.meetingnotes"
|
||||
BUNDLE_ID="net.jamesbone.meetingnotes"
|
||||
VERSION=$(grep -m1 "MARKETING_VERSION" Meetingnotes.xcodeproj/project.pbxproj | sed 's/.*= \(.*\);/\1/')
|
||||
|
||||
# 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 " 3. Upload the DMG to the GitHub release"
|
||||
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!"
|
||||
|
||||
Executable
+34
@@ -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."
|
||||
Executable
+139
@@ -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"
|
||||
Executable
+104
@@ -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"
|
||||
Reference in New Issue
Block a user