Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bcdd51201 | ||
|
|
ea44104458 | ||
|
|
336fb95b07 | ||
|
|
c7e29828b4 | ||
|
|
97eb58d1dc | ||
|
|
bcc16cd53f | ||
|
|
25ae1ae710 | ||
|
|
47ca61a811 | ||
|
|
e3397e5e85 | ||
|
|
d69968101a | ||
|
|
11dfd97801 | ||
|
|
e7d5455ec4 | ||
|
|
70a077492e | ||
|
|
5f128fd0cb | ||
|
|
6f60933930 | ||
|
|
f77b9f4c98 | ||
|
|
37e21bf398 |
@@ -35,8 +35,8 @@ jobs:
|
|||||||
| grep -q 'com.apple.security.network.server'
|
| grep -q 'com.apple.security.network.server'
|
||||||
- name: Smoke test local API
|
- name: Smoke test local API
|
||||||
run: |
|
run: |
|
||||||
defaults write owen.meetingnotes muteDeckAPIEnabled -bool true
|
defaults write net.jamesbone.meetingnotes muteDeckAPIEnabled -bool true
|
||||||
defaults write owen.meetingnotes muteDeckAPIPort -int 19880
|
defaults write net.jamesbone.meetingnotes muteDeckAPIPort -int 19880
|
||||||
"$RUNNER_TEMP/DerivedData/Build/Products/Release/Meetingnotes.app/Contents/MacOS/Meetingnotes" >"$RUNNER_TEMP/meetingnotes.log" 2>&1 &
|
"$RUNNER_TEMP/DerivedData/Build/Products/Release/Meetingnotes.app/Contents/MacOS/Meetingnotes" >"$RUNNER_TEMP/meetingnotes.log" 2>&1 &
|
||||||
app_pid=$!
|
app_pid=$!
|
||||||
trap 'kill "$app_pid" 2>/dev/null || true' EXIT
|
trap 'kill "$app_pid" 2>/dev/null || true' EXIT
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
name: Finalize Notarization (manual)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release_run_id:
|
||||||
|
description: Release workflow run ID; leave blank to use the latest pending run
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: meetingnotes-finalize-release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
finalize:
|
||||||
|
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
|
||||||
|
runs-on: macos-15
|
||||||
|
env:
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
|
||||||
|
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Validate release secrets
|
||||||
|
run: |
|
||||||
|
for variable in APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD SPARKLE_PRIVATE_KEY; do
|
||||||
|
if [[ -z "${!variable:-}" ]]; then
|
||||||
|
echo "Missing GitHub Actions secret: $variable" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Download pending signed build
|
||||||
|
id: submission
|
||||||
|
env:
|
||||||
|
MANUAL_RUN_ID: ${{ inputs.release_run_id }}
|
||||||
|
COMPLETED_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||||
|
run: |
|
||||||
|
PENDING_DIR="$RUNNER_TEMP/meetingnotes-pending"
|
||||||
|
candidate_ids=()
|
||||||
|
if [[ -n "${MANUAL_RUN_ID:-}" ]]; then
|
||||||
|
candidate_ids+=("$MANUAL_RUN_ID")
|
||||||
|
elif [[ -n "${COMPLETED_RUN_ID:-}" ]]; then
|
||||||
|
candidate_ids+=("$COMPLETED_RUN_ID")
|
||||||
|
else
|
||||||
|
while IFS= read -r run_id; do
|
||||||
|
candidate_ids+=("$run_id")
|
||||||
|
done < <(gh run list --repo "$GITHUB_REPOSITORY" --workflow Release --status success --limit 20 --json databaseId --jq '.[].databaseId')
|
||||||
|
fi
|
||||||
|
|
||||||
|
for run_id in "${candidate_ids[@]}"; do
|
||||||
|
if [[ ! "$run_id" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "Invalid release run ID: $run_id" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
artifact_name="meetingnotes-notarization-$run_id"
|
||||||
|
artifact_count=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/artifacts" \
|
||||||
|
--jq "[.artifacts[] | select(.name == \"$artifact_name\" and .expired == false)] | length")
|
||||||
|
if [[ "$artifact_count" == 0 ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$PENDING_DIR"
|
||||||
|
mkdir -p "$PENDING_DIR"
|
||||||
|
gh run download "$run_id" --repo "$GITHUB_REPOSITORY" --name "$artifact_name" --dir "$PENDING_DIR"
|
||||||
|
|
||||||
|
version=$(<"$PENDING_DIR/version")
|
||||||
|
if gh release view "v$version" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "run_id=$run_id" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Using release submission from workflow run $run_id"
|
||||||
|
exit 0
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No pending release submission was found"
|
||||||
|
|
||||||
|
- name: Check notarization and publish when accepted
|
||||||
|
if: steps.submission.outputs.found == 'true'
|
||||||
|
run: scripts/finalize_release.sh
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: Version from MARKETING_VERSION, without the v prefix
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: meetingnotes-release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: macos-15
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Validate release secrets
|
||||||
|
env:
|
||||||
|
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
for variable in APPLE_CERTIFICATE_P12 APPLE_CERTIFICATE_PASSWORD SPARKLE_PRIVATE_KEY; do
|
||||||
|
if [[ -z "${!variable:-}" ]]; then
|
||||||
|
echo "Missing GitHub Actions secret: $variable" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Import Developer ID certificate
|
||||||
|
uses: apple-actions/import-codesign-certs@v7
|
||||||
|
with:
|
||||||
|
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||||
|
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Locate Developer ID identity
|
||||||
|
run: |
|
||||||
|
signing_identity=$(security find-identity -v -p codesigning | awk -F '"' '/Developer ID Application/{print $2; exit}')
|
||||||
|
if [[ -z "$signing_identity" ]]; then
|
||||||
|
echo "The .p12 does not contain a Developer ID Application identity" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "SIGNING_IDENTITY=$signing_identity" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build and sign release
|
||||||
|
timeout-minutes: 30
|
||||||
|
run: scripts/package_release.sh
|
||||||
|
|
||||||
|
- name: Preserve signed release artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: meetingnotes-signed-release-${{ github.run_id }}
|
||||||
|
path: ${{ runner.temp }}/meetingnotes-release/release
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Publish signed GitHub release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
tag="v$VERSION"
|
||||||
|
if git rev-parse "$tag" >/dev/null 2>&1; then
|
||||||
|
echo "Tag already exists: $tag" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release create "$tag" \
|
||||||
|
"$RUNNER_TEMP/meetingnotes-release/release/Meetingnotes-$VERSION.zip" \
|
||||||
|
"$RUNNER_TEMP/meetingnotes-release/release/appcast.xml" \
|
||||||
|
--target "$GITHUB_SHA" \
|
||||||
|
--title "Meetingnotes $VERSION" \
|
||||||
|
--generate-notes
|
||||||
@@ -1,39 +1 @@
|
|||||||
# Contributing to Meetingnotes
|
|
||||||
|
|
||||||
I anticipate the main users of this app to be technical, given the need to bring your own API key.
|
|
||||||
|
|
||||||
As you use the app, you will come across bugs and have desires for new features. It is my hope that you will then be eager to contribute to improve the app.
|
|
||||||
|
|
||||||
Since you're here, maybe that's you! Please take a moment to read this guide before making a pull request.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Be respectful
|
|
||||||
- Open an issue first for major changes
|
|
||||||
- Use conventional commits
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How to Contribute
|
|
||||||
|
|
||||||
1. **Fork** the repo
|
|
||||||
2. **Create a branch**:
|
|
||||||
`git checkout -b my-feature`
|
|
||||||
3. **Commit your changes**:
|
|
||||||
`git commit -m "feat: cool feature"`
|
|
||||||
4. **Push to your fork**:
|
|
||||||
`git push origin my-feature`
|
|
||||||
5. **Open a pull request**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contributor License Notice
|
|
||||||
|
|
||||||
By submitting a pull request or otherwise contributing to this project, you agree to the following:
|
|
||||||
|
|
||||||
- You license your contribution under the same license as this project (LGPL-3.0).
|
|
||||||
- You grant the me the right to relicense your contribution, including under proprietary or commercial licenses in the future.
|
|
||||||
|
|
||||||
This helps keep the project open-source while also allowing for commercial options down the road, such as a hosted version where users could pay through the app instead of using their own API key.
|
|
||||||
|
|
||||||
If you’re not comfortable with this, please feel free to contact me or open an issue to discuss alternatives.
|
|
||||||
|
|||||||
@@ -276,9 +276,9 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 14;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = ML6HYR5LUR;
|
DEVELOPMENT_TEAM = G9LVHZAJNX;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -290,10 +290,10 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||||
MARKETING_VERSION = 1.1.2;
|
MARKETING_VERSION = 1.1.8;
|
||||||
ONLY_ACTIVE_ARCH = NO;
|
ONLY_ACTIVE_ARCH = NO;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = owen.meetingnotes;
|
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -312,9 +312,9 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 14;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = ML6HYR5LUR;
|
DEVELOPMENT_TEAM = G9LVHZAJNX;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -326,10 +326,10 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||||
MARKETING_VERSION = 1.1.2;
|
MARKETING_VERSION = 1.1.8;
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|||||||
+4
-76
@@ -1,78 +1,6 @@
|
|||||||
<?xml version="1.0" standalone="yes"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>Meetingnotes</title>
|
<title>Meetingnotes</title>
|
||||||
<item>
|
</channel>
|
||||||
<title>1.1.1</title>
|
|
||||||
<pubDate>Tue, 05 Aug 2025 12:01:01 -0400</pubDate>
|
|
||||||
<sparkle:version>13</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>1.1.1</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes-1.1.1.zip" length="9742223" type="application/octet-stream" sparkle:edSignature="IdBhnJDPglM1H+LxjIDMYvXieGyXK8FLV3o1LSBKgR3SAkbwjieN/dW/kWMw4jOL7ousjZMssvL7AVs+4OoeAw==" />
|
|
||||||
<sparkle:deltas>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-12.delta" sparkle:deltaFrom="12" length="1495358" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="JHUS+WLJjdvNP+GGj25pUTXbVL5uzc9IO1eLtkFhSvl1+yb7l7btuRTnCPDVmvX/c8rF+MQraNRamrKVcB2eAw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-11.delta" sparkle:deltaFrom="11" length="2386250" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="sSZug40z/SSNzUROZFM36FyyaJ6LvTqVGZtpYnvBITkd2/fqrbGaKUXCgBKY/vzvdq+jNduKlArSsQUIkRemAA==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-10.delta" sparkle:deltaFrom="10" length="2381262" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="ODG+JexsG6BGjbOyPMTnt9Gfpd+iHHqSPSQBsCJTIMIJ0/RcKa39iZfZ7rkL996sQllFbwdAAph3bDQCYa0NCg==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-9.delta" sparkle:deltaFrom="9" length="2384498" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="66GFKhxIVI5jzrV40NKywvn3Lx8cdxsQEHYqiWWw1IeqSqH4lF00qE1giPJgKdZkEbL6neMjIqofcvqkIbPRAw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.1/Meetingnotes13-8.delta" sparkle:deltaFrom="8" length="2970002" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="C+KVv/usN2Vmg8tkJmYRBWrT7bBrvQY2GbTgXuVljvz/HbgnA1fyrsrhIrkXTbWJ8pdH76pn/Lfzw2oYtvkGDQ==" />
|
|
||||||
</sparkle:deltas>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>1.1.0</title>
|
|
||||||
<pubDate>Fri, 25 Jul 2025 17:39:07 -0400</pubDate>
|
|
||||||
<sparkle:version>12</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>1.1.0</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes-1.1.0.zip" length="9715895" type="application/octet-stream" sparkle:edSignature="Htx9FYb58yGYIJc+xJd/W2PHgbjsVXY+TL2WsGa8gx4J4f0Uyk+1Uizl8c2lDC4+hIra+b9lIrjG6l69NjU4Cg==" />
|
|
||||||
<sparkle:deltas>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-11.delta" sparkle:deltaFrom="11" length="2319634" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="L2IQPoN19GQfVV8leQCCh4U7/1/Oe3SyclBWbT+58VFKI+FH3KGj2vZM1x+v3X3pmyPdSy/S0N9sg+B7jFSUBg==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-10.delta" sparkle:deltaFrom="10" length="2301554" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="+GKgyT7MBDTqot6SFnvQax1il9p3ZEDG5SWg0gCOwMkKPrp6v48GrJY/VAF8tow2RbeVY2Q2HP6DNVPTm1jeDQ==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-9.delta" sparkle:deltaFrom="9" length="2342546" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="lPAYUzZGPYYgb+UWGCv237QvKTH8l3jZVXxyXHsWOtYcDzuGRYiZ39eMYTAF9OMEKbvfm5vQiXXxu42CKnj3DA==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-8.delta" sparkle:deltaFrom="8" length="2940458" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="iL16jVnWBp5Ws/F1B8goazEcGCrrMK9XwH2imslbJiAWlwG5sXAaN2N8XHcHA7xuysQkway/R1vCONjmm/DaDg==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.1.0/Meetingnotes12-7.delta" sparkle:deltaFrom="7" length="2923010" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="LQHXBknrE6nFFdpDW4ZWP9f/BBhU3PIUZ7DihMLh+Iu46/8cCv8vDaH7/doEJGoUfa1oEstYsWX0bCdHkKp9Cw==" />
|
|
||||||
</sparkle:deltas>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>1.0.6</title>
|
|
||||||
<pubDate>Mon, 21 Jul 2025 16:21:51 -0400</pubDate>
|
|
||||||
<sparkle:version>11</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>1.0.6</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes-1.0.6.zip" length="9530065" type="application/octet-stream" sparkle:edSignature="IxQ4qvkMsUjHaIn4g4/wH1MR1HtpPXJ/N1XaBtCpyeEK45J1UjoIn1zZae8SrHj8zNDglzK+XvV9vZM3iw1nAA==" />
|
|
||||||
<sparkle:deltas>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-9.delta" sparkle:deltaFrom="9" length="1081770" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="uvLxEKq4bGpYvCqPPcp3mDal9z7DI3FPwpBMsdqZFUjmnBWXvtha7Pk6Rv+Ua6OuxjsNqOGg5qiwkp9z3J41Aw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-10.delta" sparkle:deltaFrom="10" length="1192342" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="+u8c+Qeen3ILEKiPtOpW3KINmsBBk6nqBsCr5aZ4i/WqHHVbG/kTe6oWPCWhiq5XFlzNaRpXguDl3PdEhvafCQ==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-8.delta" sparkle:deltaFrom="8" length="2083034" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="uQinSWneeR1R9ad5GaE3Vr2pZI0vO9KGVVf/y5xw31iKvwe6S1fpZTdZpujlE1NZzl2R07ZOPt9mDYu6uMiXAA==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-7.delta" sparkle:deltaFrom="7" length="2075294" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="9u3ldVNYRuIIVGN0zG4T76yQ7JviM39Rl2cnnfUCpDwJ/+KjXgsjwpbelZa/5AWFeKffDth04D6xVRJB5tWfAw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.6/Meetingnotes11-6.delta" sparkle:deltaFrom="6" length="2075930" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="AgHLB01YstVzOTxXfief9M2rfxCNcya/w7N6QevalSI3Uxdw8CH5MVF2UGFWy1kdIiuQC7YBsftGmL20L9KzBA==" />
|
|
||||||
</sparkle:deltas>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>1.0.5</title>
|
|
||||||
<pubDate>Thu, 17 Jul 2025 08:36:59 -0400</pubDate>
|
|
||||||
<sparkle:version>10</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>1.0.5</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes-1.0.5.zip" length="9519029" type="application/octet-stream" sparkle:edSignature="XBVJM2eh02aEQ6aNotS/3DNYXaOXA7956k/6GWEcfpcDVpJmUMDKcTIX0mtDWJl5YXin6YTA9qpIZDUJmkitBg==" />
|
|
||||||
<sparkle:deltas>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-9.delta" sparkle:deltaFrom="9" length="358186" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="WCLkjV3zZwBgy8QEpQVcvI8mLeZVVwHkYMCis3GROY+TlX3o9P12tJfB/Mb0bRI5MHaXgl+6PelbdBC2SLL9Dg==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-8.delta" sparkle:deltaFrom="8" length="2026862" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="wjQTre8hcYt6MS3gIXnXUpv+GnQ7tZALOAajn1jEgY7nNxk5P30v9qESknlDzLNCcOpb9iy/4aFTtv+aDqb1DQ==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-7.delta" sparkle:deltaFrom="7" length="2033094" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="hUqgwwoswcKVUOA4RUs8DVriGh4BXFXZ15gV2TSaCw8C4TX83qgrDVFa6ZjmpFPO23ck5+lY4chyfAzTFHq8Bw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.5/Meetingnotes10-6.delta" sparkle:deltaFrom="6" length="2026918" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="L8Y1rn8/8Kh7mVf9WNbYWIrXJo8jNtQ0MK14qWQ+PKzImSUc8nqVZ1uWgI7cClBRaPUhpqcL8CnWGWfI4BgQBg==" />
|
|
||||||
</sparkle:deltas>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>1.0.4</title>
|
|
||||||
<pubDate>Wed, 16 Jul 2025 09:02:52 -0400</pubDate>
|
|
||||||
<sparkle:version>9</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>1.0.4</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes-1.0.4.zip" length="9518446" type="application/octet-stream" sparkle:edSignature="aWBAf2cnvhAf1WBEa9oUHzL+N5RBph8Xbsh/XXBhR7wo9Vkeh51CqBh+3pjWJqxaDGxb3aimgydjIKrCVVpyAw==" />
|
|
||||||
<sparkle:deltas>
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes9-8.delta" sparkle:deltaFrom="8" length="2038454" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="AUNQbGbKk3a7Phzxfm1yEQQ6KuaqvX0NASF7u657DtdkcTbxvBqPBon/JKaZIEVtlnq+qDyjfcUpTY1S1Cj0Cw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes9-7.delta" sparkle:deltaFrom="7" length="2027690" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="+libxIpQFVA0lsctoJzmH8dNGCcHZLE8agqRKMzbCN1+fRA8Zq0hrvIAxvrpyCim1StmOtvh/zWKz+483SyJBw==" />
|
|
||||||
<enclosure url="https://github.com/owengretzinger/meetingnotes/releases/download/v1.0.4/Meetingnotes9-6.delta" sparkle:deltaFrom="6" length="2022146" type="application/octet-stream" sparkle:deltaFromSparkleExecutableSize="864832" sparkle:deltaFromSparkleLocales="de,he,ar,el,ja,fa,en" sparkle:edSignature="LLFyZQK3TLSNvD8RCpQKNmKfefg8mp9Z7J1uw09zaYhahjSa+hV1jbzkSMIEG6P6ncBScBfmOrhhuimAG6OWBw==" />
|
|
||||||
</sparkle:deltas>
|
|
||||||
</item>
|
|
||||||
</channel>
|
|
||||||
</rss>
|
</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>
|
||||||
|
|||||||
@@ -183,9 +183,13 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported microphone format"])
|
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported microphone format"])
|
||||||
}
|
}
|
||||||
inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in
|
inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in
|
||||||
guard let self, buffer.frameLength > 0 else { return }
|
guard let self else { return }
|
||||||
self.updateAudioLevel(buffer, source: .mic)
|
self.processAudioBuffer(
|
||||||
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .mic)
|
{ buffer },
|
||||||
|
converter: converter,
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
source: .mic
|
||||||
|
)
|
||||||
}
|
}
|
||||||
audioEngine.prepare()
|
audioEngine.prepare()
|
||||||
try audioEngine.start()
|
try audioEngine.start()
|
||||||
@@ -303,11 +307,13 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported system audio format"])
|
throw NSError(domain: "AudioManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unsupported system audio format"])
|
||||||
}
|
}
|
||||||
try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in
|
try tap.run(on: tapQueue) { [weak self] _, inputData, _, _, _ in
|
||||||
guard let self,
|
guard let self else { return }
|
||||||
let buffer = AVAudioPCMBuffer(pcmFormat: inputFormat, bufferListNoCopy: inputData, deallocator: nil),
|
self.processAudioBuffer(
|
||||||
buffer.frameLength > 0 else { return }
|
{ AVAudioPCMBuffer(pcmFormat: inputFormat, bufferListNoCopy: inputData, deallocator: nil) },
|
||||||
self.updateAudioLevel(buffer, source: .system)
|
converter: converter,
|
||||||
self.processAudioBuffer(buffer, converter: converter, targetFormat: targetFormat, source: .system)
|
targetFormat: targetFormat,
|
||||||
|
source: .system
|
||||||
|
)
|
||||||
} invalidationHandler: { [weak self] _ in
|
} invalidationHandler: { [weak self] _ in
|
||||||
guard let self, !self.isRestartingSystemTap, self.isRecording else { return }
|
guard let self, !self.isRestartingSystemTap, self.isRecording else { return }
|
||||||
Task { await self.restartSystemAudioTap() }
|
Task { await self.restartSystemAudioTap() }
|
||||||
@@ -315,11 +321,20 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func processAudioBuffer(
|
private func processAudioBuffer(
|
||||||
_ inputBuffer: AVAudioPCMBuffer,
|
_ inputBufferProvider: () -> AVAudioPCMBuffer?,
|
||||||
converter: AVAudioConverter,
|
converter: AVAudioConverter,
|
||||||
targetFormat: AVAudioFormat,
|
targetFormat: AVAudioFormat,
|
||||||
source: AudioSource
|
source: AudioSource
|
||||||
) {
|
) {
|
||||||
|
// Keep callback-owned buffers alive until conversion finishes. Teardown
|
||||||
|
// takes this same lock before invalidating the Core Audio process tap.
|
||||||
|
audioFileLock.lock()
|
||||||
|
defer { audioFileLock.unlock() }
|
||||||
|
guard isAcceptingAudio,
|
||||||
|
let inputBuffer = inputBufferProvider(),
|
||||||
|
inputBuffer.frameLength > 0 else { return }
|
||||||
|
|
||||||
|
updateAudioLevel(inputBuffer, source: source)
|
||||||
let ratio = targetFormat.sampleRate / inputBuffer.format.sampleRate
|
let ratio = targetFormat.sampleRate / inputBuffer.format.sampleRate
|
||||||
let capacity = max(1, AVAudioFrameCount(ceil(Double(inputBuffer.frameLength) * ratio)))
|
let capacity = max(1, AVAudioFrameCount(ceil(Double(inputBuffer.frameLength) * ratio)))
|
||||||
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { return }
|
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { return }
|
||||||
@@ -336,11 +351,6 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { return }
|
guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { return }
|
||||||
|
|
||||||
audioFileLock.lock()
|
|
||||||
defer { audioFileLock.unlock() }
|
|
||||||
guard isAcceptingAudio else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
do {
|
||||||
switch source {
|
switch source {
|
||||||
case .mic:
|
case .mic:
|
||||||
@@ -378,8 +388,8 @@ final class AudioManager: NSObject, ObservableObject {
|
|||||||
pendingMicRestart = nil
|
pendingMicRestart = nil
|
||||||
AudioLevelManager.shared.updateRecordingState(false)
|
AudioLevelManager.shared.updateRecordingState(false)
|
||||||
|
|
||||||
// Stop new writes and wait for any callback already writing before
|
// Stop new callbacks and wait for any active conversion/write before
|
||||||
// AVAudioFile is finalized and released.
|
// invalidating callback-owned buffers or finalizing AVAudioFile.
|
||||||
audioFileLock.lock()
|
audioFileLock.lock()
|
||||||
isAcceptingAudio = false
|
isAcceptingAudio = false
|
||||||
audioFileLock.unlock()
|
audioFileLock.unlock()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -54,15 +54,6 @@ class MeetingViewModel: ObservableObject {
|
|||||||
|
|
||||||
private let recordingSessionManager = RecordingSessionManager.shared
|
private let recordingSessionManager = RecordingSessionManager.shared
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var isNewMeeting = false
|
|
||||||
|
|
||||||
// Computed property to check if meeting is empty
|
|
||||||
var isEmpty: Bool {
|
|
||||||
return meeting.transcriptChunks.isEmpty &&
|
|
||||||
meeting.userNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
|
||||||
meeting.generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
|
||||||
meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
init(meeting: Meeting = Meeting()) {
|
init(meeting: Meeting = Meeting()) {
|
||||||
// Load the latest version of the meeting from storage if it exists
|
// Load the latest version of the meeting from storage if it exists
|
||||||
@@ -76,9 +67,6 @@ class MeetingViewModel: ObservableObject {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Detect if this is a new meeting based on content, not storage existence
|
|
||||||
isNewMeeting = isEmpty
|
|
||||||
|
|
||||||
// Set initial tab based on notes existence
|
// Set initial tab based on notes existence
|
||||||
if !self.meeting.generatedNotes.isEmpty {
|
if !self.meeting.generatedNotes.isEmpty {
|
||||||
selectedTab = .enhancedNotes
|
selectedTab = .enhancedNotes
|
||||||
@@ -228,11 +216,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,12 +321,4 @@ class MeetingViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteIfEmpty() {
|
|
||||||
if isEmpty && !isRecording && !isProcessing {
|
|
||||||
print("🗑️ Auto-deleting empty meeting")
|
|
||||||
deleteMeeting()
|
|
||||||
} else {
|
|
||||||
saveMeeting()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,24 +52,7 @@ class SettingsViewModel: ObservableObject {
|
|||||||
|
|
||||||
func loadTemplates() {
|
func loadTemplates() {
|
||||||
templates = LocalStorageManager.shared.loadTemplates()
|
templates = LocalStorageManager.shared.loadTemplates()
|
||||||
|
settings.selectedTemplateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
|
||||||
// Validate that the selected template still exists
|
|
||||||
if let selectedId = settings.selectedTemplateId {
|
|
||||||
if !templates.contains(where: { $0.id == selectedId }) {
|
|
||||||
// Selected template was deleted, clear the selection
|
|
||||||
settings.selectedTemplateId = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no template is selected, select the first default template
|
|
||||||
if settings.selectedTemplateId == nil {
|
|
||||||
if let defaultTemplate = templates.first(where: { $0.title == "Standard Meeting" }) {
|
|
||||||
settings.selectedTemplateId = defaultTemplate.id
|
|
||||||
} else if let firstTemplate = templates.first {
|
|
||||||
// Fallback to first available template
|
|
||||||
settings.selectedTemplateId = firstTemplate.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveSettings(showMessage: Bool = true) {
|
func saveSettings(showMessage: Bool = true) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import SwiftUI
|
|||||||
@MainActor
|
@MainActor
|
||||||
class TemplatesViewModel: ObservableObject {
|
class TemplatesViewModel: ObservableObject {
|
||||||
@Published var templates: [NoteTemplate] = []
|
@Published var templates: [NoteTemplate] = []
|
||||||
|
@Published private(set) var defaultTemplateID: UUID? = nil
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
@@ -14,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()
|
||||||
|
|||||||
@@ -445,8 +445,9 @@ struct MeetingDetailContentView: View {
|
|||||||
Text("Are you sure you want to delete this meeting? This action cannot be undone.")
|
Text("Are you sure you want to delete this meeting? This action cannot be undone.")
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
// Auto-delete empty meetings when leaving, otherwise save
|
// A failed recording may still be empty. Keep it until the user
|
||||||
viewModel.deleteIfEmpty()
|
// explicitly deletes it so app updates cannot erase history.
|
||||||
|
viewModel.saveMeeting()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@ObservedObject var viewModel: SettingsViewModel
|
@ObservedObject var viewModel: SettingsViewModel
|
||||||
@StateObject private var localAPIServer = LocalAPIServer.shared
|
@StateObject private var localAPIServer = LocalAPIServer.shared
|
||||||
@State private var showingTemplateManager = false
|
@State private var showingTemplateManager = false
|
||||||
@State private var confirmingTokenRegeneration = false
|
@State private var confirmingTokenRegeneration = false
|
||||||
|
@State private var showingMeetingImporter = false
|
||||||
|
@State private var meetingImportMessage = ""
|
||||||
|
@State private var showingMeetingImportResult = false
|
||||||
@Binding var navigationPath: NavigationPath
|
@Binding var navigationPath: NavigationPath
|
||||||
|
|
||||||
init(viewModel: SettingsViewModel, navigationPath: Binding<NavigationPath> = .constant(NavigationPath())) {
|
init(viewModel: SettingsViewModel, navigationPath: Binding<NavigationPath> = .constant(NavigationPath())) {
|
||||||
@@ -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