Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea44104458 | ||
|
|
336fb95b07 | ||
|
|
c7e29828b4 | ||
|
|
97eb58d1dc | ||
|
|
bcc16cd53f | ||
|
|
25ae1ae710 | ||
|
|
47ca61a811 | ||
|
|
e3397e5e85 | ||
|
|
d69968101a | ||
|
|
11dfd97801 | ||
|
|
e7d5455ec4 | ||
|
|
70a077492e | ||
|
|
5f128fd0cb | ||
|
|
6f60933930 | ||
|
|
f77b9f4c98 | ||
|
|
37e21bf398 | ||
|
|
d8f5c33141 | ||
|
|
34947e2119 |
@@ -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 = 13;
|
CURRENT_PROJECT_VERSION = 19;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = ML6HYR5LUR;
|
DEVELOPMENT_TEAM = G9LVHZAJNX;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -290,10 +290,10 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||||
MARKETING_VERSION = 1.1.1;
|
MARKETING_VERSION = 1.1.7;
|
||||||
ONLY_ACTIVE_ARCH = NO;
|
ONLY_ACTIVE_ARCH = NO;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
|
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -312,9 +312,9 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 13;
|
CURRENT_PROJECT_VERSION = 19;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = ML6HYR5LUR;
|
DEVELOPMENT_TEAM = G9LVHZAJNX;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -326,10 +326,10 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||||
MARKETING_VERSION = 1.1.1;
|
MARKETING_VERSION = 1.1.7;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
|
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -366,7 +366,7 @@
|
|||||||
repositoryURL = "https://github.com/sparkle-project/Sparkle.git";
|
repositoryURL = "https://github.com/sparkle-project/Sparkle.git";
|
||||||
requirement = {
|
requirement = {
|
||||||
kind = upToNextMajorVersion;
|
kind = upToNextMajorVersion;
|
||||||
minimumVersion = 2.7.1;
|
minimumVersion = 2.9.4;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
CCC33F7F2E236B6F00EDE382 /* XCRemoteSwiftPackageReference "posthog-ios" */ = {
|
CCC33F7F2E236B6F00EDE382 /* XCRemoteSwiftPackageReference "posthog-ios" */ = {
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/sparkle-project/Sparkle.git",
|
"location" : "https://github.com/sparkle-project/Sparkle.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "df074165274afaa39539c05d57b0832620775b11",
|
"revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
|
||||||
"version" : "2.7.1"
|
"version" : "2.9.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
|
|||||||
+1
-73
@@ -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>
|
|
||||||
<title>1.1.1</title>
|
|
||||||
<pubDate>Tue, 05 Aug 2025 12:01:01 -0400</pubDate>
|
|
||||||
<sparkle:version>13</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>1.1.1</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes-1.1.1.zip" length="9742223" type="application/octet-stream" sparkle:edSignature="IdBhnJDPglM1H+LxjIDMYvXieGyXK8FLV3o1LSBKgR3SAkbwjieN/dW/kWMw4jOL7ousjZMssvL7AVs+4OoeAw==" />
|
|
||||||
<sparkle:deltas>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-12.delta" sparkle:deltaFrom="12" length="1495358" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="JHUS+WLJjdvNP+GGj25pUTXbVL5uzc9IO1eLtkFhSvl1+yb7l7btuRTnCPDVmvX/c8rF+MQraNRamrKVcB2eAw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-11.delta" sparkle:deltaFrom="11" length="2386250" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="sSZug40z/SSNzUROZFM36FyyaJ6LvTqVGZtpYnvBITkd2/fqrbGaKUXCgBKY/vzvdq+jNduKlArSsQUIkRemAA==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-10.delta" sparkle:deltaFrom="10" length="2381262" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="ODG+JexsG6BGjbOyPMTnt9Gfpd+iHHqSPSQBsCJTIMIJ0/RcKa39iZfZ7rkL996sQllFbwdAAph3bDQCYa0NCg==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-9.delta" sparkle:deltaFrom="9" length="2384498" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="66GFKhxIVI5jzrV40NKywvn3Lx8cdxsQEHYqiWWw1IeqSqH4lF00qE1giPJgKdZkEbL6neMjIqofcvqkIbPRAw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-8.delta" sparkle:deltaFrom="8" length="2970002" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="C+KVv/usN2Vmg8tkJmYRBWrT7bBrvQY2GbTgXuVljvz/HbgnA1fyrsrhIrkXTbWJ8pdH76pn/Lfzw2oYtvkGDQ==" />
|
|
||||||
</sparkle:deltas>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>1.1.0</title>
|
|
||||||
<pubDate>Fri, 25 Jul 2025 17:39:07 -0400</pubDate>
|
|
||||||
<sparkle:version>12</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>1.1.0</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes-1.1.0.zip" length="9715895" type="application/octet-stream" sparkle:edSignature="Htx9FYb58yGYIJc+xJd/W2PHgbjsVXY+TL2WsGa8gx4J4f0Uyk+1Uizl8c2lDC4+hIra+b9lIrjG6l69NjU4Cg==" />
|
|
||||||
<sparkle:deltas>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-11.delta" sparkle:deltaFrom="11" length="2319634" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="L2IQPoN19GQfVV8leQCCh4U7/1/Oe3SyclBWbT+58VFKI+FH3KGj2vZM1x+v3X3pmyPdSy/S0N9sg+B7jFSUBg==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-10.delta" sparkle:deltaFrom="10" length="2301554" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="+GKgyT7MBDTqot6SFnvQax1il9p3ZEDG5SWg0gCOwMkKPrp6v48GrJY/VAF8tow2RbeVY2Q2HP6DNVPTm1jeDQ==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-9.delta" sparkle:deltaFrom="9" length="2342546" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="lPAYUzZGPYYgb+UWGCv237QvKTH8l3jZVXxyXHsWOtYcDzuGRYiZ39eMYTAF9OMEKbvfm5vQiXXxu42CKnj3DA==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-8.delta" sparkle:deltaFrom="8" length="2940458" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="iL16jVnWBp5Ws/F1B8goazEcGCrrMK9XwH2imslbJiAWlwG5sXAaN2N8XHcHA7xuysQkway/R1vCONjmm/DaDg==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-7.delta" sparkle:deltaFrom="7" length="2923010" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="LQHXBknrE6nFFdpDW4ZWP9f/BBhU3PIUZ7DihMLh+Iu46/8cCv8vDaH7/doEJGoUfa1oEstYsWX0bCdHkKp9Cw==" />
|
|
||||||
</sparkle:deltas>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>1.0.6</title>
|
|
||||||
<pubDate>Mon, 21 Jul 2025 16:21:51 -0400</pubDate>
|
|
||||||
<sparkle:version>11</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>1.0.6</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes-1.0.6.zip" length="9530065" type="application/octet-stream" sparkle:edSignature="IxQ4qvkMsUjHaIn4g4/wH1MR1HtpPXJ/N1XaBtCpyeEK45J1UjoIn1zZae8SrHj8zNDglzK+XvV9vZM3iw1nAA==" />
|
|
||||||
<sparkle:deltas>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-9.delta" sparkle:deltaFrom="9" length="1081770" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="uvLxEKq4bGpYvCqPPcp3mDal9z7DI3FPwpBMsdqZFUjmnBWXvtha7Pk6Rv+Ua6OuxjsNqOGg5qiwkp9z3J41Aw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-10.delta" sparkle:deltaFrom="10" length="1192342" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="+u8c+Qeen3ILEKiPtOpW3KINmsBBk6nqBsCr5aZ4i/WqHHVbG/kTe6oWPCWhiq5XFlzNaRpXguDl3PdEhvafCQ==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-8.delta" sparkle:deltaFrom="8" length="2083034" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="uQinSWneeR1R9ad5GaE3Vr2pZI0vO9KGVVf/y5xw31iKvwe6S1fpZTdZpujlE1NZzl2R07ZOPt9mDYu6uMiXAA==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-7.delta" sparkle:deltaFrom="7" length="2075294" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="9u3ldVNYRuIIVGN0zG4T76yQ7JviM39Rl2cnnfUCpDwJ/+KjXgsjwpbelZa/5AWFeKffDth04D6xVRJB5tWfAw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-6.delta" sparkle:deltaFrom="6" length="2075930" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="AgHLB01YstVzOTxXfief9M2rfxCNcya/w7N6QevalSI3Uxdw8CH5MVF2UGFWy1kdIiuQC7YBsftGmL20L9KzBA==" />
|
|
||||||
</sparkle:deltas>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>1.0.5</title>
|
|
||||||
<pubDate>Thu, 17 Jul 2025 08:36:59 -0400</pubDate>
|
|
||||||
<sparkle:version>10</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>1.0.5</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes-1.0.5.zip" length="9519029" type="application/octet-stream" sparkle:edSignature="XBVJM2eh02aEQ6aNotS/3DNYXaOXA7956k/6GWEcfpcDVpJmUMDKcTIX0mtDWJl5YXin6YTA9qpIZDUJmkitBg==" />
|
|
||||||
<sparkle:deltas>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-9.delta" sparkle:deltaFrom="9" length="358186" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="WCLkjV3zZwBgy8QEpQVcvI8mLeZVVwHkYMCis3GROY+TlX3o9P12tJfB/Mb0bRI5MHaXgl+6PelbdBC2SLL9Dg==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-8.delta" sparkle:deltaFrom="8" length="2026862" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="wjQTre8hcYt6MS3gIXnXUpv+GnQ7tZALOAajn1jEgY7nNxk5P30v9qESknlDzLNCcOpb9iy/4aFTtv+aDqb1DQ==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-7.delta" sparkle:deltaFrom="7" length="2033094" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="hUqgwwoswcKVUOA4RUs8DVriGh4BXFXZ15gV2TSaCw8C4TX83qgrDVFa6ZjmpFPO23ck5+lY4chyfAzTFHq8Bw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-6.delta" sparkle:deltaFrom="6" length="2026918" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="L8Y1rn8/8Kh7mVf9WNbYWIrXJo8jNtQ0MK14qWQ+PKzImSUc8nqVZ1uWgI7cClBRaPUhpqcL8CnWGWfI4BgQBg==" />
|
|
||||||
</sparkle:deltas>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>1.0.4</title>
|
|
||||||
<pubDate>Wed, 16 Jul 2025 09:02:52 -0400</pubDate>
|
|
||||||
<sparkle:version>9</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>1.0.4</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes-1.0.4.zip" length="9518446" type="application/octet-stream" sparkle:edSignature="aWBAf2cnvhAf1WBEa9oUHzL+N5RBph8Xbsh/XXBhR7wo9Vkeh51CqBh+3pjWJqxaDGxb3aimgydjIKrCVVpyAw==" />
|
|
||||||
<sparkle:deltas>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes9-8.delta" sparkle:deltaFrom="8" length="2038454" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="AUNQbGbKk3a7Phzxfm1yEQQ6KuaqvX0NASF7u657DtdkcTbxvBqPBon/JKaZIEVtlnq+qDyjfcUpTY1S1Cj0Cw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes9-7.delta" sparkle:deltaFrom="7" length="2027690" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="+libxIpQFVA0lsctoJzmH8dNGCcHZLE8agqRKMzbCN1+fRA8Zq0hrvIAxvrpyCim1StmOtvh/zWKz+483SyJBw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes9-6.delta" sparkle:deltaFrom="6" length="2022146" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="LLFyZQK3TLSNvD8RCpQKNmKfefg8mp9Z7J1uw09zaYhahjSa+hV1jbzkSMIEG6P6ncBScBfmOrhhuimAG6OWBw==" />
|
|
||||||
</sparkle:deltas>
|
|
||||||
</item>
|
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -12,9 +12,13 @@
|
|||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Meetingnotes needs access to your microphone for transcription.</string>
|
<string>Meetingnotes needs access to your microphone for transcription.</string>
|
||||||
<key>SUFeedURL</key>
|
<key>SUFeedURL</key>
|
||||||
<string>https://raw.githubusercontent.com/superdooper86/meetingnotes/main/appcast.xml</string>
|
<string>https://github.com/superdooper86/meetingnotes/releases/latest/download/appcast.xml</string>
|
||||||
<key>SUPublicEDKey</key>
|
<key>SUPublicEDKey</key>
|
||||||
<string>BVXHOV8ZxPxKZ1swhFndymzew9nyd3si7849JA9cqsg=</string>
|
<string>9ZuN9G9ERB3Qoyyd/4FsF+6LMUv5jzAGP26OXAHBiW0=</string>
|
||||||
|
<key>SUEnableAutomaticChecks</key>
|
||||||
|
<true/>
|
||||||
|
<key>SUAutomaticallyUpdate</key>
|
||||||
|
<true/>
|
||||||
<key>SUEnableInstallerLauncherService</key>
|
<key>SUEnableInstallerLauncherService</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -21,9 +21,12 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
private let audioProcessController = AudioProcessController()
|
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 var isTapActive = false
|
private var isTapActive = false
|
||||||
private var isRestartingSystemTap = false
|
private var isRestartingSystemTap = false
|
||||||
|
private var isAcceptingAudio = false
|
||||||
private var micRetryCount = 0
|
private var micRetryCount = 0
|
||||||
|
private var pendingMicRestart: DispatchWorkItem?
|
||||||
private let maxMicRetries = 3
|
private let maxMicRetries = 3
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
private var systemAudioFile: AVAudioFile?
|
private var systemAudioFile: AVAudioFile?
|
||||||
private var micAudioURL: URL?
|
private var micAudioURL: URL?
|
||||||
private var systemAudioURL: URL?
|
private var systemAudioURL: URL?
|
||||||
|
private var recordingStartedAt = Date()
|
||||||
|
|
||||||
private override init() {
|
private override init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -50,9 +54,10 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startRecording() {
|
func startRecording() {
|
||||||
sessionID = UUID()
|
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
cancelCapture(removeFiles: true)
|
cancelCapture(removeFiles: true)
|
||||||
|
sessionID = UUID()
|
||||||
|
recordingStartedAt = Date()
|
||||||
do {
|
do {
|
||||||
try prepareAudioFiles()
|
try prepareAudioFiles()
|
||||||
startMicrophoneTap()
|
startMicrophoneTap()
|
||||||
@@ -64,11 +69,12 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stopRecordingAndTranscribe() async -> [TranscriptChunk] {
|
func stopRecordingAndTranscribe() async -> [TranscriptChunk] {
|
||||||
|
let completedSessionID = sessionID
|
||||||
|
let captureStartedAt = recordingStartedAt
|
||||||
let files = stopCaptureAndCloseFiles()
|
let files = stopCaptureAndCloseFiles()
|
||||||
isProcessing = true
|
isProcessing = true
|
||||||
defer {
|
defer {
|
||||||
isProcessing = false
|
isProcessing = false
|
||||||
removeAudioFiles(files.compactMap { $0 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let model = UserDefaultsManager.shared.transcriptionModel
|
let model = UserDefaultsManager.shared.transcriptionModel
|
||||||
@@ -82,18 +88,42 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
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 }
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let text):
|
case .success(let transcription):
|
||||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
if transcription.segments.isEmpty {
|
||||||
if !trimmed.isEmpty {
|
let text = transcription.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
updated.append(TranscriptChunk(source: source, text: trimmed, isFinal: true))
|
if !text.isEmpty {
|
||||||
|
updated.append(TranscriptChunk(timestamp: captureStartedAt, source: source, text: text, isFinal: true))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for segment in transcription.segments {
|
||||||
|
let text = segment.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { continue }
|
||||||
|
updated.append(TranscriptChunk(
|
||||||
|
timestamp: captureStartedAt.addingTimeInterval(max(0, segment.start)),
|
||||||
|
source: source,
|
||||||
|
text: text,
|
||||||
|
isFinal: true
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
failures.append("\(source.displayName): \(error.localizedDescription)")
|
failures.append("\(source.displayName): \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
updated.sort {
|
||||||
|
if $0.timestamp != $1.timestamp { return $0.timestamp < $1.timestamp }
|
||||||
|
return $0.source.rawValue < $1.source.rawValue
|
||||||
|
}
|
||||||
transcriptChunks = updated
|
transcriptChunks = updated
|
||||||
if !failures.isEmpty {
|
let completedFiles = files.compactMap { $0 }
|
||||||
errorMessage = "Transcription failed for " + failures.joined(separator: "; ")
|
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
|
return updated
|
||||||
}
|
}
|
||||||
@@ -102,7 +132,7 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
cancelCapture(removeFiles: true)
|
cancelCapture(removeFiles: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func transcribe(_ fileURL: URL?, model: String) async -> Result<String, Error>? {
|
private func transcribe(_ fileURL: URL?, model: String) async -> Result<CoderAPIClient.Transcription, Error>? {
|
||||||
guard let fileURL else { return nil }
|
guard let fileURL else { return nil }
|
||||||
do {
|
do {
|
||||||
return .success(try await CoderAPIClient.shared.transcribe(fileURL: fileURL, model: model))
|
return .success(try await CoderAPIClient.shared.transcribe(fileURL: fileURL, model: model))
|
||||||
@@ -122,20 +152,26 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
let id = sessionID.uuidString
|
let id = sessionID.uuidString
|
||||||
let micURL = base.appendingPathComponent("meetingnotes-\(id)-mic.m4a")
|
let micURL = base.appendingPathComponent("meetingnotes-\(id)-mic.m4a")
|
||||||
let systemURL = base.appendingPathComponent("meetingnotes-\(id)-system.m4a")
|
let systemURL = base.appendingPathComponent("meetingnotes-\(id)-system.m4a")
|
||||||
micAudioFile = try AVAudioFile(
|
let newMicAudioFile = try AVAudioFile(
|
||||||
forWriting: micURL,
|
forWriting: micURL,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
commonFormat: .pcmFormatFloat32,
|
commonFormat: .pcmFormatFloat32,
|
||||||
interleaved: false
|
interleaved: false
|
||||||
)
|
)
|
||||||
systemAudioFile = try AVAudioFile(
|
let newSystemAudioFile = try AVAudioFile(
|
||||||
forWriting: systemURL,
|
forWriting: systemURL,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
commonFormat: .pcmFormatFloat32,
|
commonFormat: .pcmFormatFloat32,
|
||||||
interleaved: false
|
interleaved: false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
audioFileLock.lock()
|
||||||
|
micAudioFile = newMicAudioFile
|
||||||
|
systemAudioFile = newSystemAudioFile
|
||||||
micAudioURL = micURL
|
micAudioURL = micURL
|
||||||
systemAudioURL = systemURL
|
systemAudioURL = systemURL
|
||||||
|
isAcceptingAudio = true
|
||||||
|
audioFileLock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startMicrophoneTap() {
|
private func startMicrophoneTap() {
|
||||||
@@ -147,9 +183,13 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported microphone format"])
|
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported microphone format"])
|
||||||
}
|
}
|
||||||
inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in
|
inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in
|
||||||
guard let self, buffer.frameLength > 0 else { return }
|
guard let self else { return }
|
||||||
self.updateAudioLevel(buffer, source: .mic)
|
self.processAudioBuffer(
|
||||||
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .mic)
|
{ buffer },
|
||||||
|
converter: converter,
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
source: .mic
|
||||||
|
)
|
||||||
}
|
}
|
||||||
audioEngine.prepare()
|
audioEngine.prepare()
|
||||||
try audioEngine.start()
|
try audioEngine.start()
|
||||||
@@ -161,12 +201,17 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func restartMicrophone() {
|
private func restartMicrophone() {
|
||||||
guard (isRecording || micAudioFile != nil), micRetryCount < maxMicRetries else { return }
|
guard hasActiveAudioFiles(), micRetryCount < maxMicRetries else { return }
|
||||||
micRetryCount += 1
|
micRetryCount += 1
|
||||||
|
pendingMicRestart?.cancel()
|
||||||
cleanupAudioEngine()
|
cleanupAudioEngine()
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
|
|
||||||
self?.startMicrophoneTap()
|
let restart = DispatchWorkItem { [weak self] in
|
||||||
|
guard let self, self.hasActiveAudioFiles() else { return }
|
||||||
|
self.startMicrophoneTap()
|
||||||
}
|
}
|
||||||
|
pendingMicRestart = restart
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: restart)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupAudioEngine() {
|
private func cleanupAudioEngine() {
|
||||||
@@ -262,11 +307,13 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported system audio format"])
|
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported system audio format"])
|
||||||
}
|
}
|
||||||
try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in
|
try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in
|
||||||
guard let self,
|
guard let self else { return }
|
||||||
let buffer = AVAudioPCMBuffer(pcmFormat: inputFormat, bufferListNoCopy: inputData, deallocator: nil),
|
self.processAudioBuffer(
|
||||||
buffer.frameLength > 0 else { return }
|
{ AVAudioPCMBuffer(pcmFormat: inputFormat, bufferListNoCopy: inputData, deallocator: nil) },
|
||||||
self.updateAudioLevel(buffer, source: .system)
|
converter: converter,
|
||||||
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .system)
|
targetFormat: targetFormat,
|
||||||
|
source: .system
|
||||||
|
)
|
||||||
} invalidationHandler: { [weak self] _ in
|
} invalidationHandler: { [weak self] _ in
|
||||||
guard let self, !self.isRestartingSystemTap, self.isRecording else { return }
|
guard let self, !self.isRestartingSystemTap, self.isRecording else { return }
|
||||||
Task { await self.restartSystemAudioTap() }
|
Task { await self.restartSystemAudioTap() }
|
||||||
@@ -274,11 +321,20 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func processAudioBuffer(
|
private func processAudioBuffer(
|
||||||
_ inputBuffer: AVAudioPCMBuffer,
|
_ inputBufferProvider: () -> AVAudioPCMBuffer?,
|
||||||
converter: AVAudioConverter,
|
converter: AVAudioConverter,
|
||||||
targetFormat: AVAudioFormat,
|
targetFormat: AVAudioFormat,
|
||||||
source: AudioSource
|
source: AudioSource
|
||||||
) {
|
) {
|
||||||
|
// Keep callback-owned buffers alive until conversion finishes. Teardown
|
||||||
|
// takes this same lock before invalidating the Core Audio process tap.
|
||||||
|
audioFileLock.lock()
|
||||||
|
defer { audioFileLock.unlock() }
|
||||||
|
guard isAcceptingAudio,
|
||||||
|
let inputBuffer = inputBufferProvider(),
|
||||||
|
inputBuffer.frameLength > 0 else { return }
|
||||||
|
|
||||||
|
updateAudioLevel(inputBuffer, source: source)
|
||||||
let ratio = targetFormat.sampleRate / inputBuffer.format.sampleRate
|
let ratio = targetFormat.sampleRate / inputBuffer.format.sampleRate
|
||||||
let capacity = max(1, AVAudioFrameCount(ceil(Double(inputBuffer.frameLength) * ratio)))
|
let capacity = max(1, AVAudioFrameCount(ceil(Double(inputBuffer.frameLength) * ratio)))
|
||||||
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { return }
|
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { return }
|
||||||
@@ -294,6 +350,7 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
return inputBuffer
|
return inputBuffer
|
||||||
}
|
}
|
||||||
guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { return }
|
guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
switch source {
|
switch source {
|
||||||
case .mic:
|
case .mic:
|
||||||
@@ -327,7 +384,16 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
|
|
||||||
private func stopCaptureAndCloseFiles() -> [URL?] {
|
private func stopCaptureAndCloseFiles() -> [URL?] {
|
||||||
isRecording = false
|
isRecording = false
|
||||||
|
pendingMicRestart?.cancel()
|
||||||
|
pendingMicRestart = nil
|
||||||
AudioLevelManager.shared.updateRecordingState(false)
|
AudioLevelManager.shared.updateRecordingState(false)
|
||||||
|
|
||||||
|
// Stop new callbacks and wait for any active conversion/write before
|
||||||
|
// invalidating callback-owned buffers or finalizing AVAudioFile.
|
||||||
|
audioFileLock.lock()
|
||||||
|
isAcceptingAudio = false
|
||||||
|
audioFileLock.unlock()
|
||||||
|
|
||||||
if isTapActive {
|
if isTapActive {
|
||||||
processTap?.invalidate()
|
processTap?.invalidate()
|
||||||
processTap = nil
|
processTap = nil
|
||||||
@@ -337,11 +403,13 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
micRetryCount = 0
|
micRetryCount = 0
|
||||||
resetAudioLevels()
|
resetAudioLevels()
|
||||||
|
|
||||||
|
audioFileLock.lock()
|
||||||
let micHasAudio = (micAudioFile?.length ?? 0) > 0
|
let micHasAudio = (micAudioFile?.length ?? 0) > 0
|
||||||
let systemHasAudio = (systemAudioFile?.length ?? 0) > 0
|
let systemHasAudio = (systemAudioFile?.length ?? 0) > 0
|
||||||
micAudioFile = nil
|
micAudioFile = nil
|
||||||
systemAudioFile = nil
|
systemAudioFile = nil
|
||||||
let files: [URL?] = [micHasAudio ? micAudioURL : nil, systemHasAudio ? systemAudioURL : nil]
|
let files: [URL?] = [micHasAudio ? micAudioURL : nil, systemHasAudio ? systemAudioURL : nil]
|
||||||
|
audioFileLock.unlock()
|
||||||
if !micHasAudio, let micAudioURL { try? FileManager.default.removeItem(at: micAudioURL) }
|
if !micHasAudio, let micAudioURL { try? FileManager.default.removeItem(at: micAudioURL) }
|
||||||
if !systemHasAudio, let systemAudioURL { try? FileManager.default.removeItem(at: systemAudioURL) }
|
if !systemHasAudio, let systemAudioURL { try? FileManager.default.removeItem(at: systemAudioURL) }
|
||||||
micAudioURL = nil
|
micAudioURL = nil
|
||||||
@@ -359,6 +427,31 @@ 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? {
|
||||||
|
guard !urls.isEmpty,
|
||||||
|
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let folder = documents
|
||||||
|
.appendingPathComponent("Meetingnotes-Recovery", isDirectory: true)
|
||||||
|
.appendingPathComponent(sessionID.uuidString, isDirectory: true)
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var preservedCount = 0
|
||||||
|
for url in urls {
|
||||||
|
do {
|
||||||
|
try FileManager.default.moveItem(at: url, to: folder.appendingPathComponent(url.lastPathComponent))
|
||||||
|
preservedCount += 1
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return preservedCount > 0 ? folder : nil
|
||||||
|
}
|
||||||
|
|
||||||
private func resetAudioLevels() {
|
private func resetAudioLevels() {
|
||||||
micAudioLevel = 0
|
micAudioLevel = 0
|
||||||
systemAudioLevel = 0
|
systemAudioLevel = 0
|
||||||
@@ -366,6 +459,12 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
AudioLevelManager.shared.updateSystemLevel(0)
|
AudioLevelManager.shared.updateSystemLevel(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func hasActiveAudioFiles() -> Bool {
|
||||||
|
audioFileLock.lock()
|
||||||
|
defer { audioFileLock.unlock() }
|
||||||
|
return isAcceptingAudio
|
||||||
|
}
|
||||||
|
|
||||||
private func handleAudioEngineConfigurationChange() {
|
private func handleAudioEngineConfigurationChange() {
|
||||||
restartMicrophone()
|
restartMicrophone()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import Foundation
|
|||||||
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
|
||||||
@@ -47,7 +52,17 @@ class LocalStorageManager {
|
|||||||
// Write atomically using a temp file then replace
|
// Write atomically using a temp file then replace
|
||||||
let tmpURL = fileURL.appendingPathExtension("tmp")
|
let tmpURL = fileURL.appendingPathExtension("tmp")
|
||||||
try data.write(to: tmpURL, options: .atomic)
|
try data.write(to: tmpURL, options: .atomic)
|
||||||
try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL, backupItemName: nil, options: [], resultingItemURL: nil)
|
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||||
|
try FileManager.default.replaceItem(
|
||||||
|
at: fileURL,
|
||||||
|
withItemAt: tmpURL,
|
||||||
|
backupItemName: nil,
|
||||||
|
options: [],
|
||||||
|
resultingItemURL: nil
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
try FileManager.default.moveItem(at: tmpURL, to: fileURL)
|
||||||
|
}
|
||||||
|
|
||||||
print("✅ Saved meeting: \(meeting.id)")
|
print("✅ Saved meeting: \(meeting.id)")
|
||||||
return true
|
return true
|
||||||
@@ -129,6 +144,51 @@ class LocalStorageManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
|
||||||
/// Saves a note template to local storage
|
/// Saves a note template to local storage
|
||||||
@@ -146,7 +206,17 @@ class LocalStorageManager {
|
|||||||
// Write atomically using a temp file then replace
|
// Write atomically using a temp file then replace
|
||||||
let tmpURL = fileURL.appendingPathExtension("tmp")
|
let tmpURL = fileURL.appendingPathExtension("tmp")
|
||||||
try data.write(to: tmpURL, options: .atomic)
|
try data.write(to: tmpURL, options: .atomic)
|
||||||
try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL, backupItemName: nil, options: [], resultingItemURL: nil)
|
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||||
|
try FileManager.default.replaceItem(
|
||||||
|
at: fileURL,
|
||||||
|
withItemAt: tmpURL,
|
||||||
|
backupItemName: nil,
|
||||||
|
options: [],
|
||||||
|
resultingItemURL: nil
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
try FileManager.default.moveItem(at: tmpURL, to: fileURL)
|
||||||
|
}
|
||||||
|
|
||||||
print("✅ Saved template: \(template.id)")
|
print("✅ Saved template: \(template.id)")
|
||||||
return true
|
return true
|
||||||
@@ -200,6 +270,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
|
||||||
/// - Returns: True if successful, false otherwise
|
/// - Returns: True if successful, false otherwise
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ struct MeetingnotesApp: App {
|
|||||||
ContentView()
|
ContentView()
|
||||||
.frame(minWidth: 700, minHeight: 400)
|
.frame(minWidth: 700, minHeight: 400)
|
||||||
}
|
}
|
||||||
.windowResizability(.contentSize)
|
.windowResizability(.automatic)
|
||||||
.defaultSize(width: 1000, height: 600)
|
.defaultSize(width: 1000, height: 600)
|
||||||
.commands {
|
.commands {
|
||||||
CommandGroup(after: .appInfo) {
|
CommandGroup(after: .appInfo) {
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
enum TranscriptTimestampFormatter {
|
||||||
|
static let formatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "HH:mm:ss"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func string(from date: Date) -> String {
|
||||||
|
formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum AudioSource: String, Codable, CaseIterable {
|
enum AudioSource: String, Codable, CaseIterable {
|
||||||
case mic = "MIC"
|
case mic = "MIC"
|
||||||
case system = "SYS"
|
case system = "SYS"
|
||||||
@@ -108,80 +120,21 @@ struct Meeting: Codable, Identifiable, Hashable {
|
|||||||
var formattedTranscript: String {
|
var formattedTranscript: String {
|
||||||
let finalChunks = transcriptChunks.filter { $0.isFinal }
|
let finalChunks = transcriptChunks.filter { $0.isFinal }
|
||||||
|
|
||||||
guard !finalChunks.isEmpty else { return "" }
|
return finalChunks.map { chunk in
|
||||||
|
"[\(TranscriptTimestampFormatter.string(from: chunk.timestamp))] \(chunk.source.copyPrefix): \(chunk.text)"
|
||||||
var result: [String] = []
|
}.joined(separator: "\n")
|
||||||
var currentSource: AudioSource?
|
|
||||||
var currentTexts: [String] = []
|
|
||||||
|
|
||||||
for chunk in finalChunks {
|
|
||||||
if chunk.source != currentSource {
|
|
||||||
// Finish previous section if exists
|
|
||||||
if let source = currentSource, !currentTexts.isEmpty {
|
|
||||||
let combinedText = currentTexts.joined(separator: " ")
|
|
||||||
result.append("\(source.copyPrefix): \(combinedText)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start new section
|
|
||||||
currentSource = chunk.source
|
|
||||||
currentTexts = [chunk.text]
|
|
||||||
} else {
|
|
||||||
// Same source, add to current section
|
|
||||||
currentTexts.append(chunk.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finish last section
|
|
||||||
if let source = currentSource, !currentTexts.isEmpty {
|
|
||||||
let combinedText = currentTexts.joined(separator: " ")
|
|
||||||
result.append("\(source.copyPrefix): \(combinedText)")
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.joined(separator: " \n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collapsed chunks for UI display
|
// Collapsed chunks for UI display
|
||||||
var collapsedTranscriptChunks: [CollapsedTranscriptChunk] {
|
var collapsedTranscriptChunks: [CollapsedTranscriptChunk] {
|
||||||
guard !transcriptChunks.isEmpty else { return [] }
|
transcriptChunks.filter(\.isFinal).map { chunk in
|
||||||
|
CollapsedTranscriptChunk(
|
||||||
var result: [CollapsedTranscriptChunk] = []
|
id: chunk.id,
|
||||||
var currentSource: AudioSource?
|
timestamp: chunk.timestamp,
|
||||||
var currentTexts: [String] = []
|
source: chunk.source,
|
||||||
var currentTimestamp: Date?
|
combinedText: chunk.text
|
||||||
|
)
|
||||||
for chunk in transcriptChunks {
|
|
||||||
if chunk.source != currentSource {
|
|
||||||
// Finish previous section if exists
|
|
||||||
if let source = currentSource, !currentTexts.isEmpty, let timestamp = currentTimestamp {
|
|
||||||
let combinedText = currentTexts.joined(separator: " ")
|
|
||||||
result.append(CollapsedTranscriptChunk(
|
|
||||||
timestamp: timestamp,
|
|
||||||
source: source,
|
|
||||||
combinedText: combinedText
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start new section
|
|
||||||
currentSource = chunk.source
|
|
||||||
currentTexts = [chunk.text]
|
|
||||||
currentTimestamp = chunk.timestamp
|
|
||||||
} else {
|
|
||||||
// Same source, add to current section
|
|
||||||
currentTexts.append(chunk.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finish last section
|
|
||||||
if let source = currentSource, !currentTexts.isEmpty, let timestamp = currentTimestamp {
|
|
||||||
let combinedText = currentTexts.joined(separator: " ")
|
|
||||||
result.append(CollapsedTranscriptChunk(
|
|
||||||
timestamp: timestamp,
|
|
||||||
source: source,
|
|
||||||
combinedText: combinedText
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separate computed properties for mic and system transcripts
|
// Separate computed properties for mic and system transcripts
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ final class ProcessTap {
|
|||||||
init(target: TapTarget, muteWhenRunning: Bool = false) {
|
init(target: TapTarget, muteWhenRunning: Bool = false) {
|
||||||
self.target = target
|
self.target = target
|
||||||
self.muteWhenRunning = muteWhenRunning
|
self.muteWhenRunning = muteWhenRunning
|
||||||
self.logger = Logger(subsystem: "owen.meetingnotes", category: "\(String(describing: ProcessTap.self))(\(target.loggingProcessName))")
|
self.logger = Logger(subsystem: "net.jamesbone.meetingnotes", category: "\(String(describing: ProcessTap.self))(\(target.loggingProcessName))")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
@@ -141,7 +141,7 @@ final class ProcessTap {
|
|||||||
if processObjectIDs.isEmpty {
|
if processObjectIDs.isEmpty {
|
||||||
logger.warning("System audio tap configured with an empty list of processObjectIDs. This might not capture any audio or behave unexpectedly.")
|
logger.warning("System audio tap configured with an empty list of processObjectIDs. This might not capture any audio or behave unexpectedly.")
|
||||||
}
|
}
|
||||||
tapDescription = CATapDescription(stereoMixdownOfProcesses: processObjectIDs)
|
tapDescription = CATapDescription(monoMixdownOfProcesses: processObjectIDs)
|
||||||
logger.debug("Configuring tap for system audio output using \(processObjectIDs.count) explicit processes.")
|
logger.debug("Configuring tap for system audio output using \(processObjectIDs.count) explicit processes.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +327,7 @@ final class ProcessTapRecorder {
|
|||||||
self.tapDisplayName = tap.displayName
|
self.tapDisplayName = tap.displayName
|
||||||
self.fileURL = fileURL
|
self.fileURL = fileURL
|
||||||
self._tap = tap
|
self._tap = tap
|
||||||
self.logger = Logger(subsystem: "owen.meetingnotes", category: "\(String(describing: ProcessTapRecorder.self))(\(fileURL.lastPathComponent))")
|
self.logger = Logger(subsystem: "net.jamesbone.meetingnotes", category: "\(String(describing: ProcessTapRecorder.self))(\(fileURL.lastPathComponent))")
|
||||||
|
|
||||||
self.icon = tap.target.iconImage
|
self.icon = tap.target.iconImage
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,17 @@ enum CoderAPIError: LocalizedError {
|
|||||||
final class CoderAPIClient {
|
final class CoderAPIClient {
|
||||||
static let shared = CoderAPIClient()
|
static let shared = CoderAPIClient()
|
||||||
|
|
||||||
|
struct Transcription {
|
||||||
|
struct Segment: Decodable {
|
||||||
|
let start: TimeInterval
|
||||||
|
let end: TimeInterval
|
||||||
|
let text: String
|
||||||
|
}
|
||||||
|
|
||||||
|
let text: String
|
||||||
|
let segments: [Segment]
|
||||||
|
}
|
||||||
|
|
||||||
private struct ModelsResponse: Decodable {
|
private struct ModelsResponse: Decodable {
|
||||||
let data: [CoderModel]
|
let data: [CoderModel]
|
||||||
}
|
}
|
||||||
@@ -59,9 +70,17 @@ final class CoderAPIClient {
|
|||||||
|
|
||||||
private struct TranscriptionResponse: Decodable {
|
private struct TranscriptionResponse: Decodable {
|
||||||
let text: String
|
let text: String
|
||||||
|
let segments: [Transcription.Segment]?
|
||||||
}
|
}
|
||||||
|
|
||||||
private init() {}
|
private let transcriptionSession: URLSession
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let configuration = URLSessionConfiguration.default
|
||||||
|
configuration.timeoutIntervalForRequest = 2 * 60 * 60
|
||||||
|
configuration.timeoutIntervalForResource = 2 * 60 * 60
|
||||||
|
transcriptionSession = URLSession(configuration: configuration)
|
||||||
|
}
|
||||||
|
|
||||||
func models(baseURL: String, apiKey: String) async throws -> [CoderModel] {
|
func models(baseURL: String, apiKey: String) async throws -> [CoderModel] {
|
||||||
var request = URLRequest(url: try endpoint(baseURL: baseURL, path: "models"))
|
var request = URLRequest(url: try endpoint(baseURL: baseURL, path: "models"))
|
||||||
@@ -128,7 +147,7 @@ final class CoderAPIClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func transcribe(fileURL: URL, model: String, language: String = "en") async throws -> String {
|
func transcribe(fileURL: URL, model: String, language: String = "en") async throws -> Transcription {
|
||||||
let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
let selectedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") }
|
guard !selectedModel.isEmpty else { throw CoderAPIError.missingModel("transcription") }
|
||||||
let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "")
|
let apiKey = try requiredAPIKey(KeychainHelper.shared.getCoderAPIKey() ?? "")
|
||||||
@@ -149,9 +168,10 @@ final class CoderAPIClient {
|
|||||||
let size = attributes[.size] as? NSNumber {
|
let size = attributes[.size] as? NSNumber {
|
||||||
request.setValue(size.stringValue, forHTTPHeaderField: "Content-Length")
|
request.setValue(size.stringValue, forHTTPHeaderField: "Content-Length")
|
||||||
}
|
}
|
||||||
let (data, response) = try await URLSession.shared.upload(for: request, fromFile: bodyURL)
|
let (data, response) = try await transcriptionSession.upload(for: request, fromFile: bodyURL)
|
||||||
try validate(response: response, data: data)
|
try validate(response: response, data: data)
|
||||||
return try JSONDecoder().decode(TranscriptionResponse.self, from: data).text
|
let decoded = try JSONDecoder().decode(TranscriptionResponse.self, from: data)
|
||||||
|
return Transcription(text: decoded.text, segments: decoded.segments ?? [])
|
||||||
}
|
}
|
||||||
|
|
||||||
private func endpoint(baseURL: String, path: String) throws -> URL {
|
private func endpoint(baseURL: String, path: String) throws -> URL {
|
||||||
@@ -194,6 +214,7 @@ final class CoderAPIClient {
|
|||||||
}
|
}
|
||||||
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\n\(model)\r\n")
|
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\n\(model)\r\n")
|
||||||
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"language\"\r\n\r\n\(language)\r\n")
|
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"language\"\r\n\r\n\(language)\r\n")
|
||||||
|
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"response_format\"\r\n\r\nverbose_json\r\n")
|
||||||
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(audioURL.lastPathComponent)\"\r\nContent-Type: audio/mp4\r\n\r\n")
|
try write("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(audioURL.lastPathComponent)\"\r\nContent-Type: audio/mp4\r\n\r\n")
|
||||||
let input = try FileHandle(forReadingFrom: audioURL)
|
let input = try FileHandle(forReadingFrom: audioURL)
|
||||||
defer { try? input.close() }
|
defer { try? input.close() }
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ private final class LocalRecordingController {
|
|||||||
return statusPayload()
|
return statusPayload()
|
||||||
}
|
}
|
||||||
|
|
||||||
let meeting = Meeting()
|
let meeting = Meeting(templateId: LocalStorageManager.shared.preferredTemplateID())
|
||||||
guard LocalStorageManager.shared.saveMeeting(meeting) else {
|
guard LocalStorageManager.shared.saveMeeting(meeting) else {
|
||||||
throw LocalRecordingError.saveFailed
|
throw LocalRecordingError.saveFailed
|
||||||
}
|
}
|
||||||
@@ -288,9 +288,7 @@ private final class LocalRecordingController {
|
|||||||
meeting.transcriptChunks = chunks
|
meeting.transcriptChunks = chunks
|
||||||
let templates = LocalStorageManager.shared.loadTemplates()
|
let templates = LocalStorageManager.shared.loadTemplates()
|
||||||
if meeting.templateId == nil {
|
if meeting.templateId == nil {
|
||||||
meeting.templateId = UserDefaultsManager.shared.selectedTemplateId
|
meeting.templateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
|
||||||
?? templates.first(where: { $0.title == "Standard Meeting" })?.id
|
|
||||||
?? templates.first?.id
|
|
||||||
}
|
}
|
||||||
_ = LocalStorageManager.shared.saveMeeting(meeting)
|
_ = LocalStorageManager.shared.saveMeeting(meeting)
|
||||||
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
|
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ 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
|
||||||
|
|||||||
@@ -228,11 +228,13 @@ class MeetingViewModel: ObservableObject {
|
|||||||
func loadTemplates() {
|
func loadTemplates() {
|
||||||
templates = LocalStorageManager.shared.loadTemplates()
|
templates = LocalStorageManager.shared.loadTemplates()
|
||||||
|
|
||||||
// Load per-meeting template or default to Standard Meeting
|
// Keep an existing meeting's template, otherwise use the configured default.
|
||||||
if let meetingTemplateId = meeting.templateId {
|
if let meetingTemplateId = meeting.templateId,
|
||||||
|
templates.contains(where: { $0.id == meetingTemplateId }) {
|
||||||
selectedTemplateId = meetingTemplateId
|
selectedTemplateId = meetingTemplateId
|
||||||
} else if let defaultTemplate = templates.first(where: { $0.title == "Standard Meeting" }) {
|
} else {
|
||||||
selectedTemplateId = defaultTemplate.id
|
selectedTemplateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
|
||||||
|
meeting.templateId = selectedTemplateId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,24 +52,7 @@ class SettingsViewModel: ObservableObject {
|
|||||||
|
|
||||||
func loadTemplates() {
|
func loadTemplates() {
|
||||||
templates = LocalStorageManager.shared.loadTemplates()
|
templates = LocalStorageManager.shared.loadTemplates()
|
||||||
|
settings.selectedTemplateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
|
||||||
// Validate that the selected template still exists
|
|
||||||
if let selectedId = settings.selectedTemplateId {
|
|
||||||
if !templates.contains(where: { $0.id == selectedId }) {
|
|
||||||
// Selected template was deleted, clear the selection
|
|
||||||
settings.selectedTemplateId = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no template is selected, select the first default template
|
|
||||||
if settings.selectedTemplateId == nil {
|
|
||||||
if let defaultTemplate = templates.first(where: { $0.title == "Standard Meeting" }) {
|
|
||||||
settings.selectedTemplateId = defaultTemplate.id
|
|
||||||
} else if let firstTemplate = templates.first {
|
|
||||||
// Fallback to first available template
|
|
||||||
settings.selectedTemplateId = firstTemplate.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveSettings(showMessage: Bool = true) {
|
func saveSettings(showMessage: Bool = true) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import SwiftUI
|
|||||||
@MainActor
|
@MainActor
|
||||||
class TemplatesViewModel: ObservableObject {
|
class TemplatesViewModel: ObservableObject {
|
||||||
@Published var templates: [NoteTemplate] = []
|
@Published var templates: [NoteTemplate] = []
|
||||||
|
@Published private(set) var defaultTemplateID: UUID? = nil
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
@@ -14,9 +15,15 @@ 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) {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
|
|||||||
@@ -197,6 +197,11 @@ struct CollapsedTranscriptChunkView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
Text(TranscriptTimestampFormatter.string(from: chunk.timestamp))
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 58, alignment: .leading)
|
||||||
|
|
||||||
// Source indicator
|
// Source indicator
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: chunk.source.icon)
|
Image(systemName: chunk.source.icon)
|
||||||
|
|||||||
@@ -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())) {
|
||||||
@@ -120,6 +124,17 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Meeting Storage")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showingMeetingImporter = true
|
||||||
|
} label: {
|
||||||
|
Label("Import Meetings...", systemImage: "square.and.arrow.down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Note Templates Section: only the Manage Templates button
|
// Note Templates Section: only the Manage Templates button
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Note Templates")
|
Text("Note Templates")
|
||||||
@@ -130,6 +145,12 @@ struct SettingsView: View {
|
|||||||
.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")
|
||||||
} label: {
|
} label: {
|
||||||
@@ -276,6 +297,29 @@ struct SettingsView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(viewModel.saveMessage)
|
Text(viewModel.saveMessage)
|
||||||
}
|
}
|
||||||
|
.fileImporter(
|
||||||
|
isPresented: $showingMeetingImporter,
|
||||||
|
allowedContentTypes: [.folder],
|
||||||
|
allowsMultipleSelection: false
|
||||||
|
) { result in
|
||||||
|
do {
|
||||||
|
guard let directory = try result.get().first else { return }
|
||||||
|
let importResult = try LocalStorageManager.shared.importMeetings(from: directory)
|
||||||
|
meetingImportMessage = "Imported \(importResult.importedCount) meeting\(importResult.importedCount == 1 ? "" : "s")."
|
||||||
|
if importResult.skippedCount > 0 {
|
||||||
|
meetingImportMessage += " Skipped \(importResult.skippedCount) existing or invalid file\(importResult.skippedCount == 1 ? "" : "s")."
|
||||||
|
}
|
||||||
|
NotificationCenter.default.post(name: .meetingSaved, object: nil)
|
||||||
|
} catch {
|
||||||
|
meetingImportMessage = "Meeting import failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
showingMeetingImportResult = true
|
||||||
|
}
|
||||||
|
.alert("Meeting Import", isPresented: $showingMeetingImportResult) {
|
||||||
|
Button("OK") { }
|
||||||
|
} message: {
|
||||||
|
Text(meetingImportMessage)
|
||||||
|
}
|
||||||
.confirmationDialog("Regenerate API token?", isPresented: $confirmingTokenRegeneration, titleVisibility: .visible) {
|
.confirmationDialog("Regenerate API token?", isPresented: $confirmingTokenRegeneration, titleVisibility: .visible) {
|
||||||
Button("Regenerate", role: .destructive) {
|
Button("Regenerate", role: .destructive) {
|
||||||
viewModel.regenerateMuteDeckAPIToken()
|
viewModel.regenerateMuteDeckAPIToken()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,9 +36,19 @@ 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) {
|
||||||
viewModel.deleteTemplate(template)
|
viewModel.deleteTemplate(template)
|
||||||
@@ -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,6 +107,19 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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