Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
579a91d357 | ||
|
|
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'
|
| grep -q 'com.apple.security.network.server'
|
||||||
- name: Smoke test local API
|
- name: Smoke test local API
|
||||||
run: |
|
run: |
|
||||||
defaults write owen.meetingnotes muteDeckAPIEnabled -bool true
|
defaults write net.jamesbone.meetingnotes muteDeckAPIEnabled -bool true
|
||||||
defaults write owen.meetingnotes muteDeckAPIPort -int 19880
|
defaults write net.jamesbone.meetingnotes muteDeckAPIPort -int 19880
|
||||||
"$RUNNER_TEMP/DerivedData/Build/Products/Release/Meetingnotes.app/Contents/MacOS/Meetingnotes" >"$RUNNER_TEMP/meetingnotes.log" 2>&1 &
|
"$RUNNER_TEMP/DerivedData/Build/Products/Release/Meetingnotes.app/Contents/MacOS/Meetingnotes" >"$RUNNER_TEMP/meetingnotes.log" 2>&1 &
|
||||||
app_pid=$!
|
app_pid=$!
|
||||||
trap 'kill "$app_pid" 2>/dev/null || true' EXIT
|
trap 'kill "$app_pid" 2>/dev/null || true' EXIT
|
||||||
|
|||||||
@@ -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_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 14;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = ML6HYR5LUR;
|
DEVELOPMENT_TEAM = G9LVHZAJNX;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -290,10 +290,10 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||||
MARKETING_VERSION = 1.1.2;
|
MARKETING_VERSION = 1.1.18;
|
||||||
ONLY_ACTIVE_ARCH = NO;
|
ONLY_ACTIVE_ARCH = NO;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
|
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -312,9 +312,9 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 14;
|
CURRENT_PROJECT_VERSION = 30;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = ML6HYR5LUR;
|
DEVELOPMENT_TEAM = G9LVHZAJNX;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -326,10 +326,10 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||||
MARKETING_VERSION = 1.1.2;
|
MARKETING_VERSION = 1.1.18;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
|
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -366,7 +366,7 @@
|
|||||||
repositoryURL = "https://github.com/sparkle-project/Sparkle.git";
|
repositoryURL = "https://github.com/sparkle-project/Sparkle.git";
|
||||||
requirement = {
|
requirement = {
|
||||||
kind = upToNextMajorVersion;
|
kind = upToNextMajorVersion;
|
||||||
minimumVersion = 2.7.1;
|
minimumVersion = 2.9.4;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
CCC33F7F2E236B6F00EDE382 /* XCRemoteSwiftPackageReference "posthog-ios" */ = {
|
CCC33F7F2E236B6F00EDE382 /* XCRemoteSwiftPackageReference "posthog-ios" */ = {
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/sparkle-project/Sparkle.git",
|
"location" : "https://github.com/sparkle-project/Sparkle.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "df074165274afaa39539c05d57b0832620775b11",
|
"revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
|
||||||
"version" : "2.7.1"
|
"version" : "2.9.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,95 +6,9 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
The Free, Open-Source AI Notetaker for Busy Engineers
|
The Free, Open-Source AI Notetaker for Busy Engineers
|
||||||
<br />
|
|
||||||
<a href="https://github.com/owengretzinger/meetingnotes/releases/latest/download/Meetingnotes.dmg">Download for MacOS 14+</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Recall.ai - Meeting Transcription API
|
|
||||||
|
|
||||||
Meetingnotes runs locally, capturing two streams: system & mic.
|
|
||||||
|
|
||||||
If 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
|
## Features
|
||||||
|
|
||||||
Implemented:
|
Implemented:
|
||||||
@@ -128,18 +42,10 @@ Later:
|
|||||||
- AI chat for asking questions about a meeting
|
- AI chat for asking questions about a meeting
|
||||||
- Integrations for email, Slack, Notion, etc.
|
- Integrations for email, Slack, Notion, etc.
|
||||||
|
|
||||||
## Local Development
|
|
||||||
|
|
||||||
Open the project in Xcode. Command+R to build it and run it.
|
|
||||||
|
|
||||||
## Releasing a New Version
|
## Releasing a New Version
|
||||||
|
|
||||||
Follow these steps to create a new release with auto-updates:
|
Production releases are Developer ID signed, notarized by Apple, published to
|
||||||
|
GitHub Releases, and signed for Sparkle auto-updates.
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Homebrew packages: `brew install create-dmg sparkle`
|
|
||||||
- Make scripts executable: `chmod +x scripts/update_version.sh scripts/build_release.sh`
|
|
||||||
|
|
||||||
### Release Process
|
### Release Process
|
||||||
|
|
||||||
@@ -159,31 +65,19 @@ Follow these steps to create a new release with auto-updates:
|
|||||||
./scripts/update_version.sh custom 1.2.0
|
./scripts/update_version.sh custom 1.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Build the release:**
|
2. Commit and push the version change to `main`.
|
||||||
|
|
||||||
```bash
|
3. Run the `Release` workflow from GitHub Actions and enter the version without
|
||||||
./scripts/build_release.sh
|
the `v` prefix. The workflow signs and notarizes the app, generates the
|
||||||
```
|
signed appcast, creates the version tag, and publishes both release assets.
|
||||||
|
|
||||||
This will:
|
The app checks
|
||||||
|
`https://github.com/superdooper86/meetingnotes/releases/latest/download/appcast.xml`
|
||||||
|
and installs later releases automatically through Sparkle.
|
||||||
|
|
||||||
- Clean build the app in Release mode
|
### Recovering Meetings
|
||||||
- Create a signed DMG file
|
|
||||||
- Generate the appcast.xml for auto-updates
|
|
||||||
|
|
||||||
3. **Create GitHub Release:**
|
The first Developer ID signed build may not automatically inherit data from an
|
||||||
|
older ad-hoc signed build. In Settings, use **Import Meetings...** and select the
|
||||||
- Go to [GitHub Releases](https://github.com/owengretzinger/meetingnotes/releases)
|
old `Meetings` folder. After this one-time transition, the stable signing
|
||||||
- Click "Create a new release"
|
identity keeps the same sandbox container across updates.
|
||||||
- Tag: `v1.0.1` (match the version number)
|
|
||||||
- Title: `Meetingnotes v1.0.1`
|
|
||||||
- Upload the DMG and zip files from `releases/` folder
|
|
||||||
- Generate release notes
|
|
||||||
|
|
||||||
4. **Update appcast:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add appcast.xml
|
|
||||||
git commit -m "Update appcast for v1.0.1"
|
|
||||||
git push
|
|
||||||
```
|
|
||||||
|
|||||||
+5
-77
@@ -1,78 +1,6 @@
|
|||||||
<?xml version="1.0" standalone="yes"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>Meetingnotes</title>
|
<title>Meetingnotes</title>
|
||||||
<item>
|
</channel>
|
||||||
<title>1.1.1</title>
|
</rss>
|
||||||
<pubDate>Tue, 05 Aug 2025 12:01:01 -0400</pubDate>
|
|
||||||
<sparkle:version>13</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>1.1.1</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes-1.1.1.zip" length="9742223" type="application/octet-stream" sparkle:edSignature="IdBhnJDPglM1H+LxjIDMYvXieGyXK8FLV3o1LSBKgR3SAkbwjieN/dW/kWMw4jOL7ousjZMssvL7AVs+4OoeAw==" />
|
|
||||||
<sparkle:deltas>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-12.delta" sparkle:deltaFrom="12" length="1495358" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="JHUS+WLJjdvNP+GGj25pUTXbVL5uzc9IO1eLtkFhSvl1+yb7l7btuRTnCPDVmvX/c8rF+MQraNRamrKVcB2eAw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-11.delta" sparkle:deltaFrom="11" length="2386250" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="sSZug40z/SSNzUROZFM36FyyaJ6LvTqVGZtpYnvBITkd2/fqrbGaKUXCgBKY/vzvdq+jNduKlArSsQUIkRemAA==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-10.delta" sparkle:deltaFrom="10" length="2381262" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="ODG+JexsG6BGjbOyPMTnt9Gfpd+iHHqSPSQBsCJTIMIJ0/RcKa39iZfZ7rkL996sQllFbwdAAph3bDQCYa0NCg==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-9.delta" sparkle:deltaFrom="9" length="2384498" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="66GFKhxIVI5jzrV40NKywvn3Lx8cdxsQEHYqiWWw1IeqSqH4lF00qE1giPJgKdZkEbL6neMjIqofcvqkIbPRAw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-8.delta" sparkle:deltaFrom="8" length="2970002" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="C+KVv/usN2Vmg8tkJmYRBWrT7bBrvQY2GbTgXuVljvz/HbgnA1fyrsrhIrkXTbWJ8pdH76pn/Lfzw2oYtvkGDQ==" />
|
|
||||||
</sparkle:deltas>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>1.1.0</title>
|
|
||||||
<pubDate>Fri, 25 Jul 2025 17:39:07 -0400</pubDate>
|
|
||||||
<sparkle:version>12</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>1.1.0</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes-1.1.0.zip" length="9715895" type="application/octet-stream" sparkle:edSignature="Htx9FYb58yGYIJc+xJd/W2PHgbjsVXY+TL2WsGa8gx4J4f0Uyk+1Uizl8c2lDC4+hIra+b9lIrjG6l69NjU4Cg==" />
|
|
||||||
<sparkle:deltas>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-11.delta" sparkle:deltaFrom="11" length="2319634" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="L2IQPoN19GQfVV8leQCCh4U7/1/Oe3SyclBWbT+58VFKI+FH3KGj2vZM1x+v3X3pmyPdSy/S0N9sg+B7jFSUBg==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-10.delta" sparkle:deltaFrom="10" length="2301554" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="+GKgyT7MBDTqot6SFnvQax1il9p3ZEDG5SWg0gCOwMkKPrp6v48GrJY/VAF8tow2RbeVY2Q2HP6DNVPTm1jeDQ==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-9.delta" sparkle:deltaFrom="9" length="2342546" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="lPAYUzZGPYYgb+UWGCv237QvKTH8l3jZVXxyXHsWOtYcDzuGRYiZ39eMYTAF9OMEKbvfm5vQiXXxu42CKnj3DA==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-8.delta" sparkle:deltaFrom="8" length="2940458" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="iL16jVnWBp5Ws/F1B8goazEcGCrrMK9XwH2imslbJiAWlwG5sXAaN2N8XHcHA7xuysQkway/R1vCONjmm/DaDg==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-7.delta" sparkle:deltaFrom="7" length="2923010" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="LQHXBknrE6nFFdpDW4ZWP9f/BBhU3PIUZ7DihMLh+Iu46/8cCv8vDaH7/doEJGoUfa1oEstYsWX0bCdHkKp9Cw==" />
|
|
||||||
</sparkle:deltas>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>1.0.6</title>
|
|
||||||
<pubDate>Mon, 21 Jul 2025 16:21:51 -0400</pubDate>
|
|
||||||
<sparkle:version>11</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>1.0.6</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes-1.0.6.zip" length="9530065" type="application/octet-stream" sparkle:edSignature="IxQ4qvkMsUjHaIn4g4/wH1MR1HtpPXJ/N1XaBtCpyeEK45J1UjoIn1zZae8SrHj8zNDglzK+XvV9vZM3iw1nAA==" />
|
|
||||||
<sparkle:deltas>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-9.delta" sparkle:deltaFrom="9" length="1081770" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="uvLxEKq4bGpYvCqPPcp3mDal9z7DI3FPwpBMsdqZFUjmnBWXvtha7Pk6Rv+Ua6OuxjsNqOGg5qiwkp9z3J41Aw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-10.delta" sparkle:deltaFrom="10" length="1192342" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="+u8c+Qeen3ILEKiPtOpW3KINmsBBk6nqBsCr5aZ4i/WqHHVbG/kTe6oWPCWhiq5XFlzNaRpXguDl3PdEhvafCQ==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-8.delta" sparkle:deltaFrom="8" length="2083034" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="uQinSWneeR1R9ad5GaE3Vr2pZI0vO9KGVVf/y5xw31iKvwe6S1fpZTdZpujlE1NZzl2R07ZOPt9mDYu6uMiXAA==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-7.delta" sparkle:deltaFrom="7" length="2075294" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="9u3ldVNYRuIIVGN0zG4T76yQ7JviM39Rl2cnnfUCpDwJ/+KjXgsjwpbelZa/5AWFeKffDth04D6xVRJB5tWfAw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-6.delta" sparkle:deltaFrom="6" length="2075930" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="AgHLB01YstVzOTxXfief9M2rfxCNcya/w7N6QevalSI3Uxdw8CH5MVF2UGFWy1kdIiuQC7YBsftGmL20L9KzBA==" />
|
|
||||||
</sparkle:deltas>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>1.0.5</title>
|
|
||||||
<pubDate>Thu, 17 Jul 2025 08:36:59 -0400</pubDate>
|
|
||||||
<sparkle:version>10</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>1.0.5</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes-1.0.5.zip" length="9519029" type="application/octet-stream" sparkle:edSignature="XBVJM2eh02aEQ6aNotS/3DNYXaOXA7956k/6GWEcfpcDVpJmUMDKcTIX0mtDWJl5YXin6YTA9qpIZDUJmkitBg==" />
|
|
||||||
<sparkle:deltas>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-9.delta" sparkle:deltaFrom="9" length="358186" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="WCLkjV3zZwBgy8QEpQVcvI8mLeZVVwHkYMCis3GROY+TlX3o9P12tJfB/Mb0bRI5MHaXgl+6PelbdBC2SLL9Dg==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-8.delta" sparkle:deltaFrom="8" length="2026862" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="wjQTre8hcYt6MS3gIXnXUpv+GnQ7tZALOAajn1jEgY7nNxk5P30v9qESknlDzLNCcOpb9iy/4aFTtv+aDqb1DQ==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-7.delta" sparkle:deltaFrom="7" length="2033094" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="hUqgwwoswcKVUOA4RUs8DVriGh4BXFXZ15gV2TSaCw8C4TX83qgrDVFa6ZjmpFPO23ck5+lY4chyfAzTFHq8Bw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-6.delta" sparkle:deltaFrom="6" length="2026918" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="L8Y1rn8/8Kh7mVf9WNbYWIrXJo8jNtQ0MK14qWQ+PKzImSUc8nqVZ1uWgI7cClBRaPUhpqcL8CnWGWfI4BgQBg==" />
|
|
||||||
</sparkle:deltas>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>1.0.4</title>
|
|
||||||
<pubDate>Wed, 16 Jul 2025 09:02:52 -0400</pubDate>
|
|
||||||
<sparkle:version>9</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>1.0.4</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes-1.0.4.zip" length="9518446" type="application/octet-stream" sparkle:edSignature="aWBAf2cnvhAf1WBEa9oUHzL+N5RBph8Xbsh/XXBhR7wo9Vkeh51CqBh+3pjWJqxaDGxb3aimgydjIKrCVVpyAw==" />
|
|
||||||
<sparkle:deltas>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes9-8.delta" sparkle:deltaFrom="8" length="2038454" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="AUNQbGbKk3a7Phzxfm1yEQQ6KuaqvX0NASF7u657DtdkcTbxvBqPBon/JKaZIEVtlnq+qDyjfcUpTY1S1Cj0Cw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes9-7.delta" sparkle:deltaFrom="7" length="2027690" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="+libxIpQFVA0lsctoJzmH8dNGCcHZLE8agqRKMzbCN1+fRA8Zq0hrvIAxvrpyCim1StmOtvh/zWKz+483SyJBw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes9-6.delta" sparkle:deltaFrom="6" length="2022146" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="LLFyZQK3TLSNvD8RCpQKNmKfefg8mp9Z7J1uw09zaYhahjSa+hV1jbzkSMIEG6P6ncBScBfmOrhhuimAG6OWBw==" />
|
|
||||||
</sparkle:deltas>
|
|
||||||
</item>
|
|
||||||
</channel>
|
|
||||||
</rss>
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsLocalNetworking</key>
|
<key>NSAllowsLocalNetworking</key>
|
||||||
@@ -12,9 +14,13 @@
|
|||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Meetingnotes needs access to your microphone for transcription.</string>
|
<string>Meetingnotes needs access to your microphone for transcription.</string>
|
||||||
<key>SUFeedURL</key>
|
<key>SUFeedURL</key>
|
||||||
<string>https://raw.githubusercontent.com/superdooper86/meetingnotes/main/appcast.xml</string>
|
<string>https://github.com/superdooper86/meetingnotes/releases/latest/download/appcast.xml</string>
|
||||||
<key>SUPublicEDKey</key>
|
<key>SUPublicEDKey</key>
|
||||||
<string>BVXHOV8ZxPxKZ1swhFndymzew9nyd3si7849JA9cqsg=</string>
|
<string>9ZuN9G9ERB3Qoyyd/4FsF+6LMUv5jzAGP26OXAHBiW0=</string>
|
||||||
|
<key>SUEnableAutomaticChecks</key>
|
||||||
|
<true/>
|
||||||
|
<key>SUAutomaticallyUpdate</key>
|
||||||
|
<true/>
|
||||||
<key>SUEnableInstallerLauncherService</key>
|
<key>SUEnableInstallerLauncherService</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -3,6 +3,23 @@ import Combine
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
private enum RecoveryTranscriptionError: LocalizedError {
|
||||||
|
case noAudioFiles
|
||||||
|
case noSpeech
|
||||||
|
case requestFailed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .noAudioFiles:
|
||||||
|
return "The saved recovery audio could not be found."
|
||||||
|
case .noSpeech:
|
||||||
|
return "No speech was detected in the saved recovery audio."
|
||||||
|
case .requestFailed(let details):
|
||||||
|
return "Retry transcription failed for \(details)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Captures microphone and system audio locally, then sends completed files to Coder.
|
/// Captures microphone and system audio locally, then sends completed files to Coder.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class AudioManager: NSObject, ObservableObject {
|
final class AudioManager: NSObject, ObservableObject {
|
||||||
@@ -14,22 +31,20 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
@Published var micAudioLevel: Float = 0
|
@Published var micAudioLevel: Float = 0
|
||||||
@Published var systemAudioLevel: Float = 0
|
@Published var systemAudioLevel: Float = 0
|
||||||
|
private(set) var lastRecoveryAudioFolderName: String?
|
||||||
|
|
||||||
private var audioEngine = AVAudioEngine()
|
private var audioEngine = AVAudioEngine()
|
||||||
private var sessionID = UUID()
|
private var sessionID = UUID()
|
||||||
|
private var meetingID = UUID()
|
||||||
private var processTap: ProcessTap?
|
private var processTap: ProcessTap?
|
||||||
private let audioProcessController = AudioProcessController()
|
|
||||||
private let permission = AudioRecordingPermission()
|
private let permission = AudioRecordingPermission()
|
||||||
private let tapQueue = DispatchQueue(label: "io.meetingnotes.audiotap", qos: .userInitiated)
|
private let tapQueue = DispatchQueue(label: "io.meetingnotes.audiotap", qos: .userInitiated)
|
||||||
private let audioFileLock = NSLock()
|
private let audioFileLock = NSLock()
|
||||||
private var isTapActive = false
|
private var isTapActive = false
|
||||||
private var isRestartingSystemTap = false
|
|
||||||
private var isAcceptingAudio = false
|
private var isAcceptingAudio = false
|
||||||
private var micRetryCount = 0
|
private var micRetryCount = 0
|
||||||
private var pendingMicRestart: DispatchWorkItem?
|
private var pendingMicRestart: DispatchWorkItem?
|
||||||
private let maxMicRetries = 3
|
private let maxMicRetries = 3
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
private var micAudioFile: AVAudioFile?
|
private var micAudioFile: AVAudioFile?
|
||||||
private var systemAudioFile: AVAudioFile?
|
private var systemAudioFile: AVAudioFile?
|
||||||
private var micAudioURL: URL?
|
private var micAudioURL: URL?
|
||||||
@@ -39,24 +54,18 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
private override init() {
|
private override init() {
|
||||||
super.init()
|
super.init()
|
||||||
observeAudioEngine()
|
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 {
|
deinit {
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func startRecording() {
|
func startRecording(for meetingID: UUID) {
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
lastRecoveryAudioFolderName = nil
|
||||||
cancelCapture(removeFiles: true)
|
cancelCapture(removeFiles: true)
|
||||||
sessionID = UUID()
|
sessionID = UUID()
|
||||||
|
self.meetingID = meetingID
|
||||||
recordingStartedAt = Date()
|
recordingStartedAt = Date()
|
||||||
do {
|
do {
|
||||||
try prepareAudioFiles()
|
try prepareAudioFiles()
|
||||||
@@ -69,7 +78,7 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stopRecordingAndTranscribe() async -> [TranscriptChunk] {
|
func stopRecordingAndTranscribe() async -> [TranscriptChunk] {
|
||||||
let completedSessionID = sessionID
|
let completedMeetingID = meetingID
|
||||||
let captureStartedAt = recordingStartedAt
|
let captureStartedAt = recordingStartedAt
|
||||||
let files = stopCaptureAndCloseFiles()
|
let files = stopCaptureAndCloseFiles()
|
||||||
isProcessing = true
|
isProcessing = true
|
||||||
@@ -83,7 +92,78 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
let (micTranscription, systemTranscription) = await (micResult, systemResult)
|
let (micTranscription, systemTranscription) = await (micResult, systemResult)
|
||||||
let results = [micTranscription, systemTranscription]
|
let results = [micTranscription, systemTranscription]
|
||||||
|
|
||||||
var updated = transcriptChunks.filter(\.isFinal)
|
let (updated, failures) = buildTranscriptChunks(
|
||||||
|
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] = []
|
var failures: [String] = []
|
||||||
for (source, result) in zip([AudioSource.mic, .system], results) {
|
for (source, result) in zip([AudioSource.mic, .system], results) {
|
||||||
guard let result else { continue }
|
guard let result else { continue }
|
||||||
@@ -114,31 +194,7 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
if $0.timestamp != $1.timestamp { return $0.timestamp < $1.timestamp }
|
if $0.timestamp != $1.timestamp { return $0.timestamp < $1.timestamp }
|
||||||
return $0.source.rawValue < $1.source.rawValue
|
return $0.source.rawValue < $1.source.rawValue
|
||||||
}
|
}
|
||||||
transcriptChunks = updated
|
return (updated, failures)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func prepareAudioFiles() throws {
|
private func prepareAudioFiles() throws {
|
||||||
@@ -183,9 +239,13 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported microphone format"])
|
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported microphone format"])
|
||||||
}
|
}
|
||||||
inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in
|
inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in
|
||||||
guard let self, buffer.frameLength > 0 else { return }
|
guard let self else { return }
|
||||||
self.updateAudioLevel(buffer, source: .mic)
|
self.processAudioBuffer(
|
||||||
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .mic)
|
{ buffer },
|
||||||
|
converter: converter,
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
source: .mic
|
||||||
|
)
|
||||||
}
|
}
|
||||||
audioEngine.prepare()
|
audioEngine.prepare()
|
||||||
try audioEngine.start()
|
try audioEngine.start()
|
||||||
@@ -235,8 +295,7 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let processIDs = audioProcessController.processes.map(\.objectID)
|
let newTap = ProcessTap(target: .systemAudio)
|
||||||
let newTap = ProcessTap(target: .systemAudio(processObjectIDs: processIDs))
|
|
||||||
newTap.activate()
|
newTap.activate()
|
||||||
if let tapError = newTap.errorMessage {
|
if let tapError = newTap.errorMessage {
|
||||||
errorMessage = "Failed to activate system audio capture: \(tapError)"
|
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 {
|
private func restartSystemAudioTap() async {
|
||||||
guard isRecording else { return }
|
guard isRecording else { return }
|
||||||
isRestartingSystemTap = true
|
|
||||||
defer { isRestartingSystemTap = false }
|
|
||||||
if isTapActive {
|
if isTapActive {
|
||||||
processTap?.invalidate()
|
processTap?.invalidate()
|
||||||
processTap = nil
|
processTap = nil
|
||||||
@@ -303,23 +349,72 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported system audio format"])
|
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported system audio format"])
|
||||||
}
|
}
|
||||||
try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in
|
try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in
|
||||||
guard let self,
|
guard let self else { return }
|
||||||
let buffer = AVAudioPCMBuffer(pcmFormat: inputFormat, bufferListNoCopy: inputData, deallocator: nil),
|
// The tap queue is serial. Reusing the converter preserves its
|
||||||
buffer.frameLength > 0 else { return }
|
// resampler state instead of discarding audio at every callback.
|
||||||
self.updateAudioLevel(buffer, source: .system)
|
self.processAudioBuffer(
|
||||||
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .system)
|
{ self.copyAudioBuffer(from: inputData, format: inputFormat) },
|
||||||
|
converter: converter,
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
source: .system
|
||||||
|
)
|
||||||
} invalidationHandler: { [weak self] _ in
|
} invalidationHandler: { [weak self] _ in
|
||||||
guard let self, !self.isRestartingSystemTap, self.isRecording else { return }
|
guard let self, self.isRecording else { return }
|
||||||
Task { await self.restartSystemAudioTap() }
|
Task { await self.restartSystemAudioTap() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func copyAudioBuffer(
|
||||||
|
from inputData: UnsafePointer<AudioBufferList>,
|
||||||
|
format: AVAudioFormat
|
||||||
|
) -> AVAudioPCMBuffer? {
|
||||||
|
guard let borrowedBuffer = AVAudioPCMBuffer(
|
||||||
|
pcmFormat: format,
|
||||||
|
bufferListNoCopy: inputData,
|
||||||
|
deallocator: nil
|
||||||
|
), borrowedBuffer.frameLength > 0,
|
||||||
|
let ownedBuffer = AVAudioPCMBuffer(
|
||||||
|
pcmFormat: format,
|
||||||
|
frameCapacity: borrowedBuffer.frameLength
|
||||||
|
) else { return nil }
|
||||||
|
|
||||||
|
ownedBuffer.frameLength = borrowedBuffer.frameLength
|
||||||
|
let sourceBuffers = UnsafeMutableAudioBufferListPointer(
|
||||||
|
UnsafeMutablePointer(mutating: inputData)
|
||||||
|
)
|
||||||
|
let destinationBuffers = UnsafeMutableAudioBufferListPointer(
|
||||||
|
ownedBuffer.mutableAudioBufferList
|
||||||
|
)
|
||||||
|
guard sourceBuffers.count == destinationBuffers.count else { return nil }
|
||||||
|
|
||||||
|
for index in 0..<sourceBuffers.count {
|
||||||
|
let source = sourceBuffers[index]
|
||||||
|
let destination = destinationBuffers[index]
|
||||||
|
let byteCount = Int(source.mDataByteSize)
|
||||||
|
guard byteCount <= Int(destination.mDataByteSize),
|
||||||
|
let sourceData = source.mData,
|
||||||
|
let destinationData = destination.mData else { return nil }
|
||||||
|
memcpy(destinationData, sourceData, byteCount)
|
||||||
|
destinationBuffers[index].mDataByteSize = source.mDataByteSize
|
||||||
|
}
|
||||||
|
return ownedBuffer
|
||||||
|
}
|
||||||
|
|
||||||
private func processAudioBuffer(
|
private func processAudioBuffer(
|
||||||
_ inputBuffer: AVAudioPCMBuffer,
|
_ inputBufferProvider: () -> AVAudioPCMBuffer?,
|
||||||
converter: AVAudioConverter,
|
converter: AVAudioConverter,
|
||||||
targetFormat: AVAudioFormat,
|
targetFormat: AVAudioFormat,
|
||||||
source: AudioSource
|
source: AudioSource
|
||||||
) {
|
) {
|
||||||
|
// The system callback copies its borrowed Core Audio memory while this
|
||||||
|
// lock prevents teardown, then conversion operates on the owned copy.
|
||||||
|
audioFileLock.lock()
|
||||||
|
defer { audioFileLock.unlock() }
|
||||||
|
guard isAcceptingAudio,
|
||||||
|
let inputBuffer = inputBufferProvider(),
|
||||||
|
inputBuffer.frameLength > 0 else { return }
|
||||||
|
|
||||||
|
updateAudioLevel(inputBuffer, source: source)
|
||||||
let ratio = targetFormat.sampleRate / inputBuffer.format.sampleRate
|
let ratio = targetFormat.sampleRate / inputBuffer.format.sampleRate
|
||||||
let capacity = max(1, AVAudioFrameCount(ceil(Double(inputBuffer.frameLength) * ratio)))
|
let capacity = max(1, AVAudioFrameCount(ceil(Double(inputBuffer.frameLength) * ratio)))
|
||||||
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { return }
|
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { return }
|
||||||
@@ -336,11 +431,6 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { return }
|
guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { return }
|
||||||
|
|
||||||
audioFileLock.lock()
|
|
||||||
defer { audioFileLock.unlock() }
|
|
||||||
guard isAcceptingAudio else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
do {
|
||||||
switch source {
|
switch source {
|
||||||
case .mic:
|
case .mic:
|
||||||
@@ -378,8 +468,8 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
pendingMicRestart = nil
|
pendingMicRestart = nil
|
||||||
AudioLevelManager.shared.updateRecordingState(false)
|
AudioLevelManager.shared.updateRecordingState(false)
|
||||||
|
|
||||||
// Stop new writes and wait for any callback already writing before
|
// Stop new callbacks and wait for any active conversion/write before
|
||||||
// AVAudioFile is finalized and released.
|
// invalidating callback-owned buffers or finalizing AVAudioFile.
|
||||||
audioFileLock.lock()
|
audioFileLock.lock()
|
||||||
isAcceptingAudio = false
|
isAcceptingAudio = false
|
||||||
audioFileLock.unlock()
|
audioFileLock.unlock()
|
||||||
@@ -417,29 +507,8 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
for url in urls { try? FileManager.default.removeItem(at: url) }
|
for url in urls { try? FileManager.default.removeItem(at: url) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func preserveAudioFiles(_ urls: [URL], sessionID: UUID) -> URL? {
|
private func preserveAudioFiles(_ urls: [URL], meetingID: UUID) -> URL? {
|
||||||
guard !urls.isEmpty,
|
LocalStorageManager.shared.preserveAudioFiles(urls, for: meetingID)
|
||||||
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let folder = documents
|
|
||||||
.appendingPathComponent("Meetingnotes-Recovery", isDirectory: true)
|
|
||||||
.appendingPathComponent(sessionID.uuidString, isDirectory: true)
|
|
||||||
do {
|
|
||||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
||||||
} catch {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var preservedCount = 0
|
|
||||||
for url in urls {
|
|
||||||
do {
|
|
||||||
try FileManager.default.moveItem(at: url, to: folder.appendingPathComponent(url.lastPathComponent))
|
|
||||||
preservedCount += 1
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return preservedCount > 0 ? folder : nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resetAudioLevels() {
|
private func resetAudioLevels() {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Security
|
|||||||
class KeychainHelper {
|
class KeychainHelper {
|
||||||
static let shared = KeychainHelper()
|
static let shared = KeychainHelper()
|
||||||
|
|
||||||
private let serviceName = "owen.meetingnotes"
|
private let serviceName = "net.jamesbone.meetingnotes"
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,21 @@
|
|||||||
// Handles local storage of meetings and app data
|
// Handles local storage of meetings and app data
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
|
||||||
/// Manages local file storage for meetings and app data
|
/// Manages local file storage for meetings and app data
|
||||||
class LocalStorageManager {
|
class LocalStorageManager {
|
||||||
static let shared = LocalStorageManager()
|
static let shared = LocalStorageManager()
|
||||||
|
|
||||||
|
struct MeetingImportResult {
|
||||||
|
let importedCount: Int
|
||||||
|
let skippedCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
private let documentsDirectory: URL
|
private let documentsDirectory: URL
|
||||||
private let meetingsDirectory: URL
|
private let meetingsDirectory: URL
|
||||||
private let templatesDirectory: URL
|
private let templatesDirectory: URL
|
||||||
|
private let recoveryDirectory: URL
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
// Get the app's documents directory
|
// Get the app's documents directory
|
||||||
@@ -21,12 +28,18 @@ class LocalStorageManager {
|
|||||||
|
|
||||||
// Create templates subdirectory
|
// Create templates subdirectory
|
||||||
templatesDirectory = documentsDirectory.appendingPathComponent("Templates")
|
templatesDirectory = documentsDirectory.appendingPathComponent("Templates")
|
||||||
|
|
||||||
|
recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes Audio")
|
||||||
|
|
||||||
// Ensure directories exist
|
// Ensure directories exist
|
||||||
try? FileManager.default.createDirectory(at: meetingsDirectory,
|
try? FileManager.default.createDirectory(at: meetingsDirectory,
|
||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
try? FileManager.default.createDirectory(at: templatesDirectory,
|
try? FileManager.default.createDirectory(at: templatesDirectory,
|
||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
|
try? FileManager.default.createDirectory(at: recoveryDirectory,
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
migrateLegacyRecoveryAudio()
|
||||||
|
purgeExpiredAudioFolders()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Meeting Management
|
// MARK: - Meeting Management
|
||||||
@@ -47,7 +60,17 @@ class LocalStorageManager {
|
|||||||
// Write atomically using a temp file then replace
|
// Write atomically using a temp file then replace
|
||||||
let tmpURL = fileURL.appendingPathExtension("tmp")
|
let tmpURL = fileURL.appendingPathExtension("tmp")
|
||||||
try data.write(to: tmpURL, options: .atomic)
|
try data.write(to: tmpURL, options: .atomic)
|
||||||
try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL, backupItemName: nil, options: [], resultingItemURL: nil)
|
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||||
|
try FileManager.default.replaceItem(
|
||||||
|
at: fileURL,
|
||||||
|
withItemAt: tmpURL,
|
||||||
|
backupItemName: nil,
|
||||||
|
options: [],
|
||||||
|
resultingItemURL: nil
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
try FileManager.default.moveItem(at: tmpURL, to: fileURL)
|
||||||
|
}
|
||||||
|
|
||||||
print("✅ Saved meeting: \(meeting.id)")
|
print("✅ Saved meeting: \(meeting.id)")
|
||||||
return true
|
return true
|
||||||
@@ -128,6 +151,224 @@ class LocalStorageManager {
|
|||||||
return false
|
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
|
// MARK: - Template Management
|
||||||
|
|
||||||
@@ -146,7 +387,17 @@ class LocalStorageManager {
|
|||||||
// Write atomically using a temp file then replace
|
// Write atomically using a temp file then replace
|
||||||
let tmpURL = fileURL.appendingPathExtension("tmp")
|
let tmpURL = fileURL.appendingPathExtension("tmp")
|
||||||
try data.write(to: tmpURL, options: .atomic)
|
try data.write(to: tmpURL, options: .atomic)
|
||||||
try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL, backupItemName: nil, options: [], resultingItemURL: nil)
|
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||||
|
try FileManager.default.replaceItem(
|
||||||
|
at: fileURL,
|
||||||
|
withItemAt: tmpURL,
|
||||||
|
backupItemName: nil,
|
||||||
|
options: [],
|
||||||
|
resultingItemURL: nil
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
try FileManager.default.moveItem(at: tmpURL, to: fileURL)
|
||||||
|
}
|
||||||
|
|
||||||
print("✅ Saved template: \(template.id)")
|
print("✅ Saved template: \(template.id)")
|
||||||
return true
|
return true
|
||||||
@@ -199,6 +450,19 @@ class LocalStorageManager {
|
|||||||
|
|
||||||
return templates.sorted { $0.title < $1.title }
|
return templates.sorted { $0.title < $1.title }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func preferredTemplateID(in templates: [NoteTemplate]? = nil) -> UUID? {
|
||||||
|
let availableTemplates = templates ?? loadTemplates()
|
||||||
|
if let selectedTemplateID = UserDefaultsManager.shared.selectedTemplateId,
|
||||||
|
availableTemplates.contains(where: { $0.id == selectedTemplateID }) {
|
||||||
|
return selectedTemplateID
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallbackTemplateID = availableTemplates.first(where: { $0.title == "Standard Meeting" })?.id
|
||||||
|
?? availableTemplates.first?.id
|
||||||
|
UserDefaultsManager.shared.selectedTemplateId = fallbackTemplateID
|
||||||
|
return fallbackTemplateID
|
||||||
|
}
|
||||||
|
|
||||||
/// Deletes a template from local storage
|
/// Deletes a template from local storage
|
||||||
/// - Parameter template: The template to delete
|
/// - Parameter template: The template to delete
|
||||||
@@ -241,4 +505,4 @@ class LocalStorageManager {
|
|||||||
var meetingsDirectoryURL: URL {
|
var meetingsDirectoryURL: URL {
|
||||||
meetingsDirectory
|
meetingsDirectory
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class RecordingSessionManager: ObservableObject {
|
|||||||
|
|
||||||
activeMeetingId = meetingId
|
activeMeetingId = meetingId
|
||||||
recordingStartedAt = Date()
|
recordingStartedAt = Date()
|
||||||
audioManager.startRecording()
|
audioManager.startRecording(for: meetingId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopRecording() async -> [TranscriptChunk] {
|
func stopRecording() async -> [TranscriptChunk] {
|
||||||
@@ -112,6 +112,10 @@ class RecordingSessionManager: ObservableObject {
|
|||||||
func isRecordingMeeting(_ meetingId: UUID) -> Bool {
|
func isRecordingMeeting(_ meetingId: UUID) -> Bool {
|
||||||
return isRecording && activeMeetingId == meetingId
|
return isRecording && activeMeetingId == meetingId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var lastRecoveryAudioFolderName: String? {
|
||||||
|
audioManager.lastRecoveryAudioFolderName
|
||||||
|
}
|
||||||
|
|
||||||
private func updateActiveMeetingTranscript(meetingId: UUID, chunks: [TranscriptChunk]) {
|
private func updateActiveMeetingTranscript(meetingId: UUID, chunks: [TranscriptChunk]) {
|
||||||
// Load all meetings
|
// Load all meetings
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class UserDefaultsManager {
|
|||||||
static let transcriptionModel = "transcriptionModel"
|
static let transcriptionModel = "transcriptionModel"
|
||||||
static let muteDeckAPIEnabled = "muteDeckAPIEnabled"
|
static let muteDeckAPIEnabled = "muteDeckAPIEnabled"
|
||||||
static let muteDeckAPIPort = "muteDeckAPIPort"
|
static let muteDeckAPIPort = "muteDeckAPIPort"
|
||||||
|
static let audioRetentionDays = "audioRetentionDays"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - User Blurb
|
// MARK: - User Blurb
|
||||||
@@ -94,4 +95,12 @@ class UserDefaultsManager {
|
|||||||
}
|
}
|
||||||
set { userDefaults.set(newValue, forKey: Keys.muteDeckAPIPort) }
|
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 SwiftUI
|
||||||
|
import AppKit
|
||||||
import Sparkle
|
import Sparkle
|
||||||
import PostHog
|
import PostHog
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct MeetingnotesApp: App {
|
struct MeetingnotesApp: App {
|
||||||
private let updaterController: SPUStandardUpdaterController
|
private let updaterController: SPUStandardUpdaterController
|
||||||
|
@StateObject private var recordingSessionManager = RecordingSessionManager.shared
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
updaterController = SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil)
|
updaterController = SPUStandardUpdaterController(updaterDelegate: nil, userDriverDelegate: nil)
|
||||||
@@ -34,17 +36,145 @@ struct MeetingnotesApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup("Meetingnotes", id: "main") {
|
||||||
ContentView()
|
ContentView()
|
||||||
.frame(minWidth: 700, minHeight: 400)
|
.frame(minWidth: 700, minHeight: 400)
|
||||||
|
.background(MainWindowConfigurator())
|
||||||
}
|
}
|
||||||
.windowResizability(.contentSize)
|
.windowResizability(.automatic)
|
||||||
.defaultSize(width: 1000, height: 600)
|
.defaultSize(width: 1000, height: 600)
|
||||||
.commands {
|
.commands {
|
||||||
CommandGroup(after: .appInfo) {
|
CommandGroup(after: .appInfo) {
|
||||||
CheckForUpdatesView(updater: updaterController.updater)
|
CheckForUpdatesView(updater: updaterController.updater)
|
||||||
|
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 userNotes: String
|
||||||
var generatedNotes: String
|
var generatedNotes: String
|
||||||
var templateId: UUID? // Add property to track per-meeting template
|
var templateId: UUID? // Add property to track per-meeting template
|
||||||
|
var recoveryAudioFolderName: String?
|
||||||
// MARK: - Data versioning
|
// MARK: - Data versioning
|
||||||
/// Version of this Meeting record on disk. Useful for migration.
|
/// Version of this Meeting record on disk. Useful for migration.
|
||||||
var dataVersion: Int
|
var dataVersion: Int
|
||||||
@@ -95,6 +96,7 @@ struct Meeting: Codable, Identifiable, Hashable {
|
|||||||
userNotes: String = "",
|
userNotes: String = "",
|
||||||
generatedNotes: String = "",
|
generatedNotes: String = "",
|
||||||
templateId: UUID? = nil,
|
templateId: UUID? = nil,
|
||||||
|
recoveryAudioFolderName: String? = nil,
|
||||||
dataVersion: Int = Meeting.currentDataVersion) {
|
dataVersion: Int = Meeting.currentDataVersion) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.date = date
|
self.date = date
|
||||||
@@ -103,6 +105,7 @@ struct Meeting: Codable, Identifiable, Hashable {
|
|||||||
self.userNotes = userNotes
|
self.userNotes = userNotes
|
||||||
self.generatedNotes = generatedNotes
|
self.generatedNotes = generatedNotes
|
||||||
self.templateId = templateId
|
self.templateId = templateId
|
||||||
|
self.recoveryAudioFolderName = recoveryAudioFolderName
|
||||||
self.dataVersion = dataVersion
|
self.dataVersion = dataVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ struct Settings: Codable {
|
|||||||
set { UserDefaultsManager.shared.muteDeckAPIPort = newValue }
|
set { UserDefaultsManager.shared.muteDeckAPIPort = newValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var audioRetentionDays: Int {
|
||||||
|
get { UserDefaultsManager.shared.audioRetentionDays }
|
||||||
|
set { UserDefaultsManager.shared.audioRetentionDays = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
// System prompt default loading
|
// System prompt default loading
|
||||||
static func defaultSystemPrompt() -> String {
|
static func defaultSystemPrompt() -> String {
|
||||||
guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"),
|
guard let path = Bundle.main.path(forResource: "DefaultSystemPrompt", ofType: "txt"),
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ extension String: @retroactive LocalizedError {
|
|||||||
@Observable
|
@Observable
|
||||||
final class AudioProcessController {
|
final class AudioProcessController {
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "owen.meetingnotes", category: String(describing: AudioProcessController.self))
|
private let logger = Logger(subsystem: "net.jamesbone.meetingnotes", category: String(describing: AudioProcessController.self))
|
||||||
|
|
||||||
private(set) var processes = [AudioProcess]() {
|
private(set) var processes = [AudioProcess]() {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -236,4 +236,4 @@ private extension URL {
|
|||||||
var isApp: Bool {
|
var isApp: Bool {
|
||||||
(try? resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .application) == true
|
(try? resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .application) == true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import OSLog
|
|||||||
/// Uses TCC SPI in order to check/request system audio recording permission.
|
/// Uses TCC SPI in order to check/request system audio recording permission.
|
||||||
@Observable
|
@Observable
|
||||||
final class AudioRecordingPermission {
|
final class AudioRecordingPermission {
|
||||||
private let logger = Logger(subsystem: "owen.meetingnotes", category: String(describing: AudioRecordingPermission.self))
|
private let logger = Logger(subsystem: "net.jamesbone.meetingnotes", category: String(describing: AudioRecordingPermission.self))
|
||||||
|
|
||||||
enum Status: String {
|
enum Status: String {
|
||||||
case unknown
|
case unknown
|
||||||
@@ -122,4 +122,4 @@ final class AudioRecordingPermission {
|
|||||||
return fn
|
return fn
|
||||||
}()
|
}()
|
||||||
#endif // ENABLE_TCC_SPI
|
#endif // ENABLE_TCC_SPI
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import AVFoundation
|
|||||||
|
|
||||||
enum TapTarget {
|
enum TapTarget {
|
||||||
case singleProcess(AudioProcess)
|
case singleProcess(AudioProcess)
|
||||||
case systemAudio(processObjectIDs: [AudioObjectID])
|
case systemAudio
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -51,7 +51,7 @@ final class ProcessTap {
|
|||||||
init(target: TapTarget, muteWhenRunning: Bool = false) {
|
init(target: TapTarget, muteWhenRunning: Bool = false) {
|
||||||
self.target = target
|
self.target = target
|
||||||
self.muteWhenRunning = muteWhenRunning
|
self.muteWhenRunning = muteWhenRunning
|
||||||
self.logger = Logger(subsystem: "owen.meetingnotes", category: "\(String(describing: ProcessTap.self))(\(target.loggingProcessName))")
|
self.logger = Logger(subsystem: "net.jamesbone.meetingnotes", category: "\(String(describing: ProcessTap.self))(\(target.loggingProcessName))")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
@@ -137,12 +137,12 @@ final class ProcessTap {
|
|||||||
case .singleProcess(let process):
|
case .singleProcess(let process):
|
||||||
tapDescription = CATapDescription(stereoMixdownOfProcesses: [process.objectID])
|
tapDescription = CATapDescription(stereoMixdownOfProcesses: [process.objectID])
|
||||||
logger.debug("Configuring tap for single process objectID: \(process.objectID)")
|
logger.debug("Configuring tap for single process objectID: \(process.objectID)")
|
||||||
case .systemAudio(let processObjectIDs):
|
case .systemAudio:
|
||||||
if processObjectIDs.isEmpty {
|
// Keep the HAL tap's buffer layout consistent with the default
|
||||||
logger.warning("System audio tap configured with an empty list of processObjectIDs. This might not capture any audio or behave unexpectedly.")
|
// output stream. AudioManager performs the stereo-to-mono mix when
|
||||||
}
|
// it converts the captured audio to the 16 kHz transcription file.
|
||||||
tapDescription = CATapDescription(monoMixdownOfProcesses: processObjectIDs)
|
tapDescription = CATapDescription(stereoGlobalTapButExcludeProcesses: [])
|
||||||
logger.debug("Configuring tap for system audio output using \(processObjectIDs.count) explicit processes.")
|
logger.debug("Configuring a stereo global system audio tap.")
|
||||||
}
|
}
|
||||||
|
|
||||||
tapDescription.uuid = UUID()
|
tapDescription.uuid = UUID()
|
||||||
@@ -327,7 +327,7 @@ final class ProcessTapRecorder {
|
|||||||
self.tapDisplayName = tap.displayName
|
self.tapDisplayName = tap.displayName
|
||||||
self.fileURL = fileURL
|
self.fileURL = fileURL
|
||||||
self._tap = tap
|
self._tap = tap
|
||||||
self.logger = Logger(subsystem: "owen.meetingnotes", category: "\(String(describing: ProcessTapRecorder.self))(\(fileURL.lastPathComponent))")
|
self.logger = Logger(subsystem: "net.jamesbone.meetingnotes", category: "\(String(describing: ProcessTapRecorder.self))(\(fileURL.lastPathComponent))")
|
||||||
|
|
||||||
self.icon = tap.target.iconImage
|
self.icon = tap.target.iconImage
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct CoderModel: Codable, Identifiable, Hashable {
|
struct CoderModel: Codable, Identifiable, Hashable {
|
||||||
@@ -73,6 +74,13 @@ final class CoderAPIClient {
|
|||||||
let segments: [Transcription.Segment]?
|
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 let transcriptionSession: URLSession
|
||||||
|
|
||||||
private init() {
|
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
|
AsyncThrowingStream { continuation in
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@@ -113,7 +125,7 @@ final class CoderAPIClient {
|
|||||||
"model": selectedModel,
|
"model": selectedModel,
|
||||||
"messages": [
|
"messages": [
|
||||||
["role": "system", "content": systemPrompt],
|
["role": "system", "content": systemPrompt],
|
||||||
["role": "user", "content": "Create the meeting notes now."]
|
["role": "user", "content": userPrompt]
|
||||||
],
|
],
|
||||||
"stream": true
|
"stream": true
|
||||||
])
|
])
|
||||||
@@ -151,10 +163,67 @@ final class CoderAPIClient {
|
|||||||
let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") }
|
guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") }
|
||||||
let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "")
|
let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "")
|
||||||
|
let chunks = try makeAudioChunks(from: fileURL)
|
||||||
|
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 boundary = "Meetingnotes-\(UUID().uuidString)"
|
||||||
let bodyURL = try makeMultipartBody(
|
let bodyURL = try makeMultipartBody(
|
||||||
audioURL: fileURL,
|
audioURL: fileURL,
|
||||||
model: selectedModel,
|
model: model,
|
||||||
language: language,
|
language: language,
|
||||||
boundary: boundary
|
boundary: boundary
|
||||||
)
|
)
|
||||||
@@ -174,6 +243,80 @@ final class CoderAPIClient {
|
|||||||
return Transcription(text: decoded.text, segments: decoded.segments ?? [])
|
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 {
|
private func endpoint(baseURL: String, path: String) throws -> URL {
|
||||||
guard var components = URLComponents(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)),
|
guard var components = URLComponents(string: baseURL.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||||
let scheme = components.scheme?.lowercased(),
|
let scheme = components.scheme?.lowercased(),
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ private final class LocalRecordingController {
|
|||||||
return statusPayload()
|
return statusPayload()
|
||||||
}
|
}
|
||||||
|
|
||||||
let meeting = Meeting()
|
let meeting = Meeting(templateId: LocalStorageManager.shared.preferredTemplateID())
|
||||||
guard LocalStorageManager.shared.saveMeeting(meeting) else {
|
guard LocalStorageManager.shared.saveMeeting(meeting) else {
|
||||||
throw LocalRecordingError.saveFailed
|
throw LocalRecordingError.saveFailed
|
||||||
}
|
}
|
||||||
@@ -286,11 +286,10 @@ private final class LocalRecordingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
meeting.transcriptChunks = chunks
|
meeting.transcriptChunks = chunks
|
||||||
|
meeting.recoveryAudioFolderName = recordingManager.lastRecoveryAudioFolderName
|
||||||
let templates = LocalStorageManager.shared.loadTemplates()
|
let templates = LocalStorageManager.shared.loadTemplates()
|
||||||
if meeting.templateId == nil {
|
if meeting.templateId == nil {
|
||||||
meeting.templateId = UserDefaultsManager.shared.selectedTemplateId
|
meeting.templateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
|
||||||
?? templates.first(where: { $0.title == "Standard Meeting" })?.id
|
|
||||||
?? templates.first?.id
|
|
||||||
}
|
}
|
||||||
_ = LocalStorageManager.shared.saveMeeting(meeting)
|
_ = LocalStorageManager.shared.saveMeeting(meeting)
|
||||||
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
|
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
|
||||||
@@ -311,8 +310,27 @@ private final class LocalRecordingController {
|
|||||||
generatedNotes = ""
|
generatedNotes = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var meetingChanged = false
|
||||||
if !generatedNotes.isEmpty {
|
if !generatedNotes.isEmpty {
|
||||||
meeting.generatedNotes = generatedNotes
|
meeting.generatedNotes = generatedNotes
|
||||||
|
meetingChanged = true
|
||||||
|
}
|
||||||
|
if meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
do {
|
||||||
|
if let title = try await NotesGenerator.shared.generateMeetingTitle(
|
||||||
|
meeting: meeting,
|
||||||
|
generatedNotes: generatedNotes,
|
||||||
|
templateId: meeting.templateId
|
||||||
|
) {
|
||||||
|
meeting.title = title
|
||||||
|
meetingChanged = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The recording and generated notes remain valid without a generated title.
|
||||||
|
print("Meeting title generation failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if meetingChanged {
|
||||||
_ = LocalStorageManager.shared.saveMeeting(meeting)
|
_ = LocalStorageManager.shared.saveMeeting(meeting)
|
||||||
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
|
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,88 @@ class NotesGenerator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generates a short title after meeting notes have been created.
|
||||||
|
func generateMeetingTitle(
|
||||||
|
meeting: Meeting,
|
||||||
|
generatedNotes: String,
|
||||||
|
templateId: UUID?
|
||||||
|
) async throws -> String? {
|
||||||
|
let templates = LocalStorageManager.shared.loadTemplates()
|
||||||
|
let template = templateId.flatMap { id in
|
||||||
|
templates.first(where: { $0.id == id })
|
||||||
|
}
|
||||||
|
let meetingContext: String
|
||||||
|
if let template {
|
||||||
|
meetingContext = "\(template.title): \(template.context)"
|
||||||
|
} else {
|
||||||
|
meetingContext = "General meeting"
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmedNotes = generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let meetingContent = trimmedNotes.isEmpty ? meeting.formattedTranscript : trimmedNotes
|
||||||
|
guard !meetingContent.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let systemPrompt = """
|
||||||
|
Create a concise, descriptive title for a recorded meeting from its context and content.
|
||||||
|
Return only the title in 3 to 8 words. Do not use quotation marks, Markdown, or a trailing period.
|
||||||
|
Avoid generic titles such as Meeting, Discussion, Meeting Notes, or Untitled Meeting.
|
||||||
|
"""
|
||||||
|
let userPrompt = """
|
||||||
|
Meeting context:
|
||||||
|
\(meetingContext)
|
||||||
|
|
||||||
|
Meeting content:
|
||||||
|
\(meetingContent)
|
||||||
|
"""
|
||||||
|
|
||||||
|
var generatedTitle = ""
|
||||||
|
let stream = CoderAPIClient.shared.streamChat(
|
||||||
|
systemPrompt: systemPrompt,
|
||||||
|
userPrompt: userPrompt,
|
||||||
|
model: UserDefaultsManager.shared.notesModel
|
||||||
|
)
|
||||||
|
for try await content in stream {
|
||||||
|
generatedTitle += content
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedTitle(generatedTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizedTitle(_ value: String) -> String? {
|
||||||
|
guard var title = value
|
||||||
|
.split(whereSeparator: \Character.isNewline)
|
||||||
|
.map(String.init)
|
||||||
|
.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
while title.hasPrefix("#") {
|
||||||
|
title.removeFirst()
|
||||||
|
title = title.trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
if title.lowercased().hasPrefix("title:") {
|
||||||
|
title = String(title.dropFirst("title:".count))
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
title = title.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
|
||||||
|
if title.hasSuffix(".") {
|
||||||
|
title.removeLast()
|
||||||
|
}
|
||||||
|
|
||||||
|
if title.count > 80 {
|
||||||
|
let prefix = String(title.prefix(80))
|
||||||
|
if let lastSpace = prefix.lastIndex(of: " ") {
|
||||||
|
title = String(prefix[..<lastSpace])
|
||||||
|
} else {
|
||||||
|
title = prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
title = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return title.isEmpty ? nil : title
|
||||||
|
}
|
||||||
|
|
||||||
/// Validates if the Coder service token is configured
|
/// Validates if the Coder service token is configured
|
||||||
/// - Returns: True if API key exists, false otherwise
|
/// - Returns: True if API key exists, false otherwise
|
||||||
@@ -115,4 +197,4 @@ class NotesGenerator {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,11 +68,11 @@ class MeetingListViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createNewMeeting() -> Meeting {
|
func createNewMeeting() -> Meeting {
|
||||||
let newMeeting = Meeting()
|
let newMeeting = Meeting(templateId: LocalStorageManager.shared.preferredTemplateID())
|
||||||
meetings.insert(newMeeting, at: 0)
|
meetings.insert(newMeeting, at: 0)
|
||||||
_ = LocalStorageManager.shared.saveMeeting(newMeeting)
|
_ = LocalStorageManager.shared.saveMeeting(newMeeting)
|
||||||
// Track meeting creation event
|
// Track meeting creation event
|
||||||
PostHogSDK.shared.capture("meeting_created")
|
PostHogSDK.shared.capture("meeting_created")
|
||||||
return newMeeting
|
return newMeeting
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ extension Notification.Name {
|
|||||||
enum MeetingViewTab: String, CaseIterable {
|
enum MeetingViewTab: String, CaseIterable {
|
||||||
case myNotes = "My Notes"
|
case myNotes = "My Notes"
|
||||||
case transcript = "Transcript"
|
case transcript = "Transcript"
|
||||||
case enhancedNotes = "Enhanced Notes"
|
case enhancedNotes = "Meeting Notes"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -25,6 +25,8 @@ class MeetingViewModel: ObservableObject {
|
|||||||
@Published private var recordingStateChanged = false // Trigger SwiftUI updates
|
@Published private var recordingStateChanged = false // Trigger SwiftUI updates
|
||||||
@Published var isValidatingKey = false // Indicates API key validation in progress
|
@Published var isValidatingKey = false // Indicates API key validation in progress
|
||||||
@Published var isStartingRecording = false // Indicates recording start in progress
|
@Published var isStartingRecording = false // Indicates recording start in progress
|
||||||
|
@Published var isRetryingTranscription = false
|
||||||
|
@Published private(set) var recoveryAudioFolderURL: URL?
|
||||||
|
|
||||||
// Computed property to determine if Generate button should animate
|
// Computed property to determine if Generate button should animate
|
||||||
var shouldAnimateGenerateButton: Bool {
|
var shouldAnimateGenerateButton: Bool {
|
||||||
@@ -44,7 +46,13 @@ class MeetingViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var isProcessing: Bool {
|
var isProcessing: Bool {
|
||||||
return recordingSessionManager.isProcessing && recordingSessionManager.activeMeetingId == meeting.id
|
return isRetryingTranscription ||
|
||||||
|
(recordingSessionManager.isProcessing && recordingSessionManager.activeMeetingId == meeting.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var canRetryTranscription: Bool {
|
||||||
|
recoveryAudioFolderURL != nil &&
|
||||||
|
!isRecording && !isProcessing && !isStartingRecording && !isValidatingKey
|
||||||
}
|
}
|
||||||
@Published var selectedTab: MeetingViewTab = .transcript // Default to transcript tab
|
@Published var selectedTab: MeetingViewTab = .transcript // Default to transcript tab
|
||||||
|
|
||||||
@@ -54,15 +62,6 @@ class MeetingViewModel: ObservableObject {
|
|||||||
|
|
||||||
private let recordingSessionManager = RecordingSessionManager.shared
|
private let recordingSessionManager = RecordingSessionManager.shared
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var isNewMeeting = false
|
|
||||||
|
|
||||||
// Computed property to check if meeting is empty
|
|
||||||
var isEmpty: Bool {
|
|
||||||
return meeting.transcriptChunks.isEmpty &&
|
|
||||||
meeting.userNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
|
||||||
meeting.generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
|
||||||
meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
init(meeting: Meeting = Meeting()) {
|
init(meeting: Meeting = Meeting()) {
|
||||||
// Load the latest version of the meeting from storage if it exists
|
// Load the latest version of the meeting from storage if it exists
|
||||||
@@ -76,9 +75,6 @@ class MeetingViewModel: ObservableObject {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Detect if this is a new meeting based on content, not storage existence
|
|
||||||
isNewMeeting = isEmpty
|
|
||||||
|
|
||||||
// Set initial tab based on notes existence
|
// Set initial tab based on notes existence
|
||||||
if !self.meeting.generatedNotes.isEmpty {
|
if !self.meeting.generatedNotes.isEmpty {
|
||||||
selectedTab = .enhancedNotes
|
selectedTab = .enhancedNotes
|
||||||
@@ -88,6 +84,7 @@ class MeetingViewModel: ObservableObject {
|
|||||||
|
|
||||||
// Load templates and selected template
|
// Load templates and selected template
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
|
refreshRecoveryAudioFolder()
|
||||||
// Observe template selection: save to meeting and regenerate notes on changes (skip initial)
|
// Observe template selection: save to meeting and regenerate notes on changes (skip initial)
|
||||||
$selectedTemplateId
|
$selectedTemplateId
|
||||||
.dropFirst()
|
.dropFirst()
|
||||||
@@ -217,6 +214,8 @@ class MeetingViewModel: ObservableObject {
|
|||||||
Task {
|
Task {
|
||||||
let chunks = await recordingSessionManager.stopRecording()
|
let chunks = await recordingSessionManager.stopRecording()
|
||||||
meeting.transcriptChunks = chunks
|
meeting.transcriptChunks = chunks
|
||||||
|
meeting.recoveryAudioFolderName = recordingSessionManager.lastRecoveryAudioFolderName
|
||||||
|
refreshRecoveryAudioFolder()
|
||||||
saveMeeting()
|
saveMeeting()
|
||||||
if !meeting.formattedTranscript.isEmpty {
|
if !meeting.formattedTranscript.isEmpty {
|
||||||
await generateNotes()
|
await generateNotes()
|
||||||
@@ -224,15 +223,57 @@ class MeetingViewModel: ObservableObject {
|
|||||||
isStartingRecording = false
|
isStartingRecording = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func retryTranscription() {
|
||||||
|
guard let recoveryAudioFolderURL, canRetryTranscription else { return }
|
||||||
|
|
||||||
|
isRetryingTranscription = true
|
||||||
|
errorMessage = nil
|
||||||
|
Task {
|
||||||
|
defer { isRetryingTranscription = false }
|
||||||
|
do {
|
||||||
|
let chunks = try await AudioManager.shared.transcribeRecoveryAudio(
|
||||||
|
in: recoveryAudioFolderURL,
|
||||||
|
captureStartedAt: meeting.date
|
||||||
|
)
|
||||||
|
meeting.transcriptChunks = chunks
|
||||||
|
meeting.recoveryAudioFolderName = recoveryAudioFolderURL.lastPathComponent
|
||||||
|
selectedTab = .transcript
|
||||||
|
|
||||||
|
guard saveMeeting() else {
|
||||||
|
throw CocoaError(.fileWriteUnknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
func loadTemplates() {
|
||||||
templates = LocalStorageManager.shared.loadTemplates()
|
templates = LocalStorageManager.shared.loadTemplates()
|
||||||
|
|
||||||
// Load per-meeting template or default to Standard Meeting
|
// Keep an existing meeting's template, otherwise use the configured default.
|
||||||
if let meetingTemplateId = meeting.templateId {
|
if let meetingTemplateId = meeting.templateId,
|
||||||
|
templates.contains(where: { $0.id == meetingTemplateId }) {
|
||||||
selectedTemplateId = meetingTemplateId
|
selectedTemplateId = meetingTemplateId
|
||||||
} else if let defaultTemplate = templates.first(where: { $0.title == "Standard Meeting" }) {
|
} else {
|
||||||
selectedTemplateId = defaultTemplate.id
|
selectedTemplateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
|
||||||
|
meeting.templateId = selectedTemplateId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,20 +311,40 @@ class MeetingViewModel: ObservableObject {
|
|||||||
|
|
||||||
// Only save if there was no error
|
// Only save if there was no error
|
||||||
if !hasError {
|
if !hasError {
|
||||||
|
await generateTitleIfNeeded()
|
||||||
saveMeeting()
|
saveMeeting()
|
||||||
}
|
}
|
||||||
|
|
||||||
isGeneratingNotes = false
|
isGeneratingNotes = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func generateTitleIfNeeded() async {
|
||||||
|
guard meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
if let title = try await NotesGenerator.shared.generateMeetingTitle(
|
||||||
|
meeting: meeting,
|
||||||
|
generatedNotes: meeting.generatedNotes,
|
||||||
|
templateId: selectedTemplateId
|
||||||
|
) {
|
||||||
|
meeting.title = title
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A title is helpful but should never prevent completed notes from being saved.
|
||||||
|
print("Meeting title generation failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func saveMeeting() {
|
@discardableResult
|
||||||
if isDeleted { return }
|
func saveMeeting() -> Bool {
|
||||||
|
if isDeleted { return false }
|
||||||
print("💾 Saving meeting: \(meeting.id)")
|
print("💾 Saving meeting: \(meeting.id)")
|
||||||
let success = LocalStorageManager.shared.saveMeeting(meeting)
|
let success = LocalStorageManager.shared.saveMeeting(meeting)
|
||||||
print("💾 Save result: \(success ? "SUCCESS" : "FAILED")")
|
print("💾 Save result: \(success ? "SUCCESS" : "FAILED")")
|
||||||
if success {
|
if success {
|
||||||
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
|
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
|
||||||
}
|
}
|
||||||
|
return success
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyCurrentTabContent() {
|
func copyCurrentTabContent() {
|
||||||
@@ -331,12 +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() {
|
func loadTemplates() {
|
||||||
templates = LocalStorageManager.shared.loadTemplates()
|
templates = LocalStorageManager.shared.loadTemplates()
|
||||||
|
settings.selectedTemplateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
|
||||||
// Validate that the selected template still exists
|
|
||||||
if let selectedId = settings.selectedTemplateId {
|
|
||||||
if !templates.contains(where: { $0.id == selectedId }) {
|
|
||||||
// Selected template was deleted, clear the selection
|
|
||||||
settings.selectedTemplateId = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no template is selected, select the first default template
|
|
||||||
if settings.selectedTemplateId == nil {
|
|
||||||
if let defaultTemplate = templates.first(where: { $0.title == "Standard Meeting" }) {
|
|
||||||
settings.selectedTemplateId = defaultTemplate.id
|
|
||||||
} else if let firstTemplate = templates.first {
|
|
||||||
// Fallback to first available template
|
|
||||||
settings.selectedTemplateId = firstTemplate.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveSettings(showMessage: Bool = true) {
|
func saveSettings(showMessage: Bool = true) {
|
||||||
@@ -88,6 +71,7 @@ class SettingsViewModel: ObservableObject {
|
|||||||
// via computed properties when they're modified
|
// via computed properties when they're modified
|
||||||
let coderSaved = KeychainHelper.shared.saveCoderAPIKey(settings.coderAPIKey)
|
let coderSaved = KeychainHelper.shared.saveCoderAPIKey(settings.coderAPIKey)
|
||||||
LocalAPIServer.shared.applyConfiguration()
|
LocalAPIServer.shared.applyConfiguration()
|
||||||
|
LocalStorageManager.shared.purgeExpiredAudioFolders()
|
||||||
|
|
||||||
if showMessage {
|
if showMessage {
|
||||||
if coderSaved {
|
if coderSaved {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import SwiftUI
|
|||||||
@MainActor
|
@MainActor
|
||||||
class TemplatesViewModel: ObservableObject {
|
class TemplatesViewModel: ObservableObject {
|
||||||
@Published var templates: [NoteTemplate] = []
|
@Published var templates: [NoteTemplate] = []
|
||||||
|
@Published private(set) var defaultTemplateID: UUID? = nil
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
@@ -14,8 +15,14 @@ class TemplatesViewModel: ObservableObject {
|
|||||||
func loadTemplates() {
|
func loadTemplates() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
templates = LocalStorageManager.shared.loadTemplates()
|
templates = LocalStorageManager.shared.loadTemplates()
|
||||||
|
defaultTemplateID = LocalStorageManager.shared.preferredTemplateID(in: templates)
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setDefaultTemplate(_ template: NoteTemplate) {
|
||||||
|
UserDefaultsManager.shared.selectedTemplateId = template.id
|
||||||
|
defaultTemplateID = template.id
|
||||||
|
}
|
||||||
|
|
||||||
func saveTemplate(_ template: NoteTemplate) {
|
func saveTemplate(_ template: NoteTemplate) {
|
||||||
if LocalStorageManager.shared.saveTemplate(template) {
|
if LocalStorageManager.shared.saveTemplate(template) {
|
||||||
@@ -42,4 +49,4 @@ class TemplatesViewModel: ObservableObject {
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,6 +259,23 @@ struct MeetingDetailContentView: View {
|
|||||||
|
|
||||||
// Ellipsis menu
|
// Ellipsis menu
|
||||||
Menu {
|
Menu {
|
||||||
|
if viewModel.recoveryAudioFolderURL != nil {
|
||||||
|
Button {
|
||||||
|
viewModel.retryTranscription()
|
||||||
|
} label: {
|
||||||
|
Label("Retry Transcription", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.disabled(!viewModel.canRetryTranscription)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.showAudioInFinder()
|
||||||
|
} label: {
|
||||||
|
Label("Show Audio in Finder", systemImage: "folder")
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
|
||||||
Button("Delete Meeting", role: .destructive) {
|
Button("Delete Meeting", role: .destructive) {
|
||||||
showDeleteAlert = true
|
showDeleteAlert = true
|
||||||
}
|
}
|
||||||
@@ -327,7 +344,7 @@ struct MeetingDetailContentView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isProcessing || viewModel.isStartingRecording)
|
.disabled(viewModel.meeting.transcript.isEmpty || viewModel.isGeneratingNotes || viewModel.isRecording || viewModel.isProcessing || viewModel.isStartingRecording)
|
||||||
.help("Generate enhanced notes using a template")
|
.help("Generate meeting notes using a template")
|
||||||
|
|
||||||
// Recording Button
|
// Recording Button
|
||||||
Button(action: {
|
Button(action: {
|
||||||
@@ -374,7 +391,7 @@ struct MeetingDetailContentView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Edit/Preview button (for My Notes and Enhanced Notes)
|
// Edit/Preview button (for My Notes and Meeting Notes)
|
||||||
if viewModel.selectedTab == .myNotes || viewModel.selectedTab == .enhancedNotes {
|
if viewModel.selectedTab == .myNotes || viewModel.selectedTab == .enhancedNotes {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
isEditing.toggle()
|
isEditing.toggle()
|
||||||
@@ -445,8 +462,9 @@ struct MeetingDetailContentView: View {
|
|||||||
Text("Are you sure you want to delete this meeting? This action cannot be undone.")
|
Text("Are you sure you want to delete this meeting? This action cannot be undone.")
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
// Auto-delete empty meetings when leaving, otherwise save
|
// A failed recording may still be empty. Keep it until the user
|
||||||
viewModel.deleteIfEmpty()
|
// explicitly deletes it so app updates cannot erase history.
|
||||||
|
viewModel.saveMeeting()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@ObservedObject var viewModel: SettingsViewModel
|
@ObservedObject var viewModel: SettingsViewModel
|
||||||
@StateObject private var localAPIServer = LocalAPIServer.shared
|
@StateObject private var localAPIServer = LocalAPIServer.shared
|
||||||
@State private var showingTemplateManager = false
|
@State private var showingTemplateManager = false
|
||||||
@State private var confirmingTokenRegeneration = false
|
@State private var confirmingTokenRegeneration = false
|
||||||
|
@State private var showingMeetingImporter = false
|
||||||
|
@State private var meetingImportMessage = ""
|
||||||
|
@State private var showingMeetingImportResult = false
|
||||||
@Binding var navigationPath: NavigationPath
|
@Binding var navigationPath: NavigationPath
|
||||||
|
|
||||||
init(viewModel: SettingsViewModel, navigationPath: Binding<NavigationPath> = .constant(NavigationPath())) {
|
init(viewModel: SettingsViewModel, navigationPath: Binding<NavigationPath> = .constant(NavigationPath())) {
|
||||||
@@ -119,6 +123,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
|
// Note Templates Section: only the Manage Templates button
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
@@ -129,6 +161,12 @@ struct SettingsView: View {
|
|||||||
Text("Create and manage note templates")
|
Text("Create and manage note templates")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Picker("Default template", selection: $viewModel.settings.selectedTemplateId) {
|
||||||
|
ForEach(viewModel.templates) { template in
|
||||||
|
Text(template.title).tag(Optional(template.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
navigationPath.append("templates")
|
navigationPath.append("templates")
|
||||||
@@ -276,6 +314,29 @@ struct SettingsView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(viewModel.saveMessage)
|
Text(viewModel.saveMessage)
|
||||||
}
|
}
|
||||||
|
.fileImporter(
|
||||||
|
isPresented: $showingMeetingImporter,
|
||||||
|
allowedContentTypes: [.folder],
|
||||||
|
allowsMultipleSelection: false
|
||||||
|
) { result in
|
||||||
|
do {
|
||||||
|
guard let directory = try result.get().first else { return }
|
||||||
|
let importResult = try LocalStorageManager.shared.importMeetings(from: directory)
|
||||||
|
meetingImportMessage = "Imported \(importResult.importedCount) meeting\(importResult.importedCount == 1 ? "" : "s")."
|
||||||
|
if importResult.skippedCount > 0 {
|
||||||
|
meetingImportMessage += " Skipped \(importResult.skippedCount) existing or invalid file\(importResult.skippedCount == 1 ? "" : "s")."
|
||||||
|
}
|
||||||
|
NotificationCenter.default.post(name: .meetingSaved, object: nil)
|
||||||
|
} catch {
|
||||||
|
meetingImportMessage = "Meeting import failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
showingMeetingImportResult = true
|
||||||
|
}
|
||||||
|
.alert("Meeting Import", isPresented: $showingMeetingImportResult) {
|
||||||
|
Button("OK") { }
|
||||||
|
} message: {
|
||||||
|
Text(meetingImportMessage)
|
||||||
|
}
|
||||||
.confirmationDialog("Regenerate API token?", isPresented: $confirmingTokenRegeneration, titleVisibility: .visible) {
|
.confirmationDialog("Regenerate API token?", isPresented: $confirmingTokenRegeneration, titleVisibility: .visible) {
|
||||||
Button("Regenerate", role: .destructive) {
|
Button("Regenerate", role: .destructive) {
|
||||||
viewModel.regenerateMuteDeckAPIToken()
|
viewModel.regenerateMuteDeckAPIToken()
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ struct TemplateEditView: View {
|
|||||||
TextEditor(text: $template.context)
|
TextEditor(text: $template.context)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.background(Color.gray.opacity(0.05))
|
.frame(height: 140)
|
||||||
.cornerRadius(8)
|
.background(Color.secondary.opacity(0.06))
|
||||||
.frame(minHeight: 100)
|
.overlay {
|
||||||
.overlay(
|
RoundedRectangle(cornerRadius: 6)
|
||||||
RoundedRectangle(cornerRadius: 8)
|
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
|
||||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
}
|
||||||
)
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sections
|
// Sections
|
||||||
@@ -55,14 +55,12 @@ struct TemplateEditView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
withAnimation {
|
template.sections.append(
|
||||||
template.sections.append(
|
TemplateSection(
|
||||||
TemplateSection(
|
title: "New Section",
|
||||||
title: "New Section",
|
description: "Description of this section"
|
||||||
description: "Description of this section"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
@@ -77,29 +75,27 @@ struct TemplateEditView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
ForEach(template.sections.indices, id: \.self) { index in
|
ForEach($template.sections) { $section in
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
TextField("Section Title", text: $template.sections[index].title)
|
TextField("Section Title", text: $section.title)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
|
|
||||||
TextEditor(text: $template.sections[index].description)
|
TextEditor(text: $section.description)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.background(Color.gray.opacity(0.05))
|
.frame(height: 90)
|
||||||
.cornerRadius(8)
|
.background(Color.secondary.opacity(0.06))
|
||||||
.frame(minHeight: 60)
|
.overlay {
|
||||||
.overlay(
|
RoundedRectangle(cornerRadius: 6)
|
||||||
RoundedRectangle(cornerRadius: 8)
|
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
|
||||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
}
|
||||||
)
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
withAnimation {
|
template.sections.removeAll { $0.id == section.id }
|
||||||
let _ = template.sections.remove(at: index)
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "trash")
|
Image(systemName: "trash")
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
@@ -131,12 +127,19 @@ struct TemplateEditView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
.disabled(template.title.isEmpty || template.sections.isEmpty)
|
.disabled(!canSave)
|
||||||
}
|
}
|
||||||
.padding(24)
|
.padding(24)
|
||||||
}
|
}
|
||||||
.navigationTitle("Edit Template")
|
.navigationTitle("Edit Template")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var canSave: Bool {
|
||||||
|
let hasTitle = !template.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
let hasInstructions = !template.context.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
|| !template.sections.isEmpty
|
||||||
|
return hasTitle && hasInstructions
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
@@ -152,4 +155,4 @@ struct TemplateEditView: View {
|
|||||||
)
|
)
|
||||||
) { _ in }
|
) { _ in }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,21 @@ import SwiftUI
|
|||||||
|
|
||||||
struct TemplateListView: View {
|
struct TemplateListView: View {
|
||||||
@StateObject private var viewModel = TemplatesViewModel()
|
@StateObject private var viewModel = TemplatesViewModel()
|
||||||
@Environment(\.dismiss) private var dismiss
|
@State private var editingTemplate: NoteTemplate?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
ForEach(viewModel.templates) { template in
|
ForEach(viewModel.templates) { template in
|
||||||
HStack {
|
HStack {
|
||||||
NavigationLink(destination: TemplateEditView(template: template) { updatedTemplate in
|
Button {
|
||||||
viewModel.saveTemplate(updatedTemplate)
|
presentEditor(for: template)
|
||||||
}) {
|
} label: {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(template.title)
|
Text(template.title)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
if template.isDefault {
|
if template.isDefault {
|
||||||
Text("Default")
|
Text("Built-in")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
@@ -36,8 +36,18 @@ struct TemplateListView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.setDefaultTemplate(template)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewModel.defaultTemplateID == template.id ? "star.fill" : "star")
|
||||||
|
.foregroundColor(viewModel.defaultTemplateID == template.id ? .accentColor : .secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(viewModel.defaultTemplateID == template.id ? "Default template" : "Use as default template")
|
||||||
|
|
||||||
if !template.isDefault {
|
if !template.isDefault {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
@@ -50,6 +60,14 @@ struct TemplateListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
|
if viewModel.defaultTemplateID != template.id {
|
||||||
|
Button {
|
||||||
|
viewModel.setDefaultTemplate(template)
|
||||||
|
} label: {
|
||||||
|
Label("Use as Default", systemImage: "star")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !template.isDefault {
|
if !template.isDefault {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
viewModel.deleteTemplate(template)
|
viewModel.deleteTemplate(template)
|
||||||
@@ -70,9 +88,9 @@ struct TemplateListView: View {
|
|||||||
.navigationTitle("Note Templates")
|
.navigationTitle("Note Templates")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
NavigationLink(destination: TemplateEditView(template: viewModel.createNewTemplate()) { updatedTemplate in
|
Button {
|
||||||
viewModel.saveTemplate(updatedTemplate)
|
presentEditor(for: viewModel.createNewTemplate())
|
||||||
}) {
|
} label: {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,9 +107,22 @@ struct TemplateListView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(viewModel.errorMessage ?? "")
|
Text(viewModel.errorMessage ?? "")
|
||||||
}
|
}
|
||||||
|
.sheet(item: $editingTemplate) { template in
|
||||||
|
NavigationStack {
|
||||||
|
TemplateEditView(template: template) { updatedTemplate in
|
||||||
|
viewModel.saveTemplate(updatedTemplate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 680, minHeight: 620)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentEditor(for template: NoteTemplate) {
|
||||||
|
guard editingTemplate == nil else { return }
|
||||||
|
editingTemplate = template
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
TemplateListView()
|
TemplateListView()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,6 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
|
||||||
<array>
|
|
||||||
<string>group.owen.meetingnotes</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.security.device.audio-input</key>
|
<key>com.apple.security.device.audio-input</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.device.screen-capture</key>
|
<key>com.apple.security.device.screen-capture</key>
|
||||||
@@ -20,8 +16,8 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
||||||
<array>
|
<array>
|
||||||
<string>owen.meetingnotes-spks</string>
|
<string>net.jamesbone.meetingnotes-spks</string>
|
||||||
<string>owen.meetingnotes-spki</string>
|
<string>net.jamesbone.meetingnotes-spki</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ set -e # Exit on any error
|
|||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
APP_NAME="Meetingnotes"
|
APP_NAME="Meetingnotes"
|
||||||
BUNDLE_ID="owen.meetingnotes"
|
BUNDLE_ID="net.jamesbone.meetingnotes"
|
||||||
VERSION=$(grep -m1 "MARKETING_VERSION" Meetingnotes.xcodeproj/project.pbxproj | sed 's/.*= \(.*\);/\1/')
|
VERSION=$(grep -m1 "MARKETING_VERSION" Meetingnotes.xcodeproj/project.pbxproj | sed 's/.*= \(.*\);/\1/')
|
||||||
|
|
||||||
# Source environment variables if .env file exists
|
# Source environment variables if .env file exists
|
||||||
@@ -379,4 +379,4 @@ echo " 1. Test the DMG on another Mac"
|
|||||||
echo " 2. Create a GitHub release with tag v${VERSION}"
|
echo " 2. Create a GitHub release with tag v${VERSION}"
|
||||||
echo " 3. Upload the DMG to the GitHub release"
|
echo " 3. Upload the DMG to the GitHub release"
|
||||||
echo " 4. Commit and push the appcast.xml file"
|
echo " 4. Commit and push the appcast.xml file"
|
||||||
echo " 5. Your users will get auto-update notifications!"
|
echo " 5. Your users will get auto-update notifications!"
|
||||||
|
|||||||
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