diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7bf306e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +jobs: + build: + name: Build (${{ matrix.architecture }}) + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + architecture: arm64 + - runner: macos-15-intel + architecture: x86_64 + runs-on: ${{ matrix.runner }} + + steps: + - uses: actions/checkout@v4 + + - name: Build release binary + run: swift build --configuration release --arch "${{ matrix.architecture }}" + + - name: Verify version command and architecture + shell: bash + run: | + set -euo pipefail + binary="$(swift build --configuration release --arch '${{ matrix.architecture }}' --show-bin-path)/quill" + test "$("$binary" --version)" = "$(sed -n 's/.*static let current = "\([^"]*\)".*/\1/p' Sources/quill/Version.swift)" + lipo -archs "$binary" | tr ' ' '\n' | grep -qx '${{ matrix.architecture }}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..de5f4b9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,105 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Package (${{ matrix.architecture }}) + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + architecture: arm64 + - runner: macos-15-intel + architecture: x86_64 + runs-on: ${{ matrix.runner }} + + steps: + - uses: actions/checkout@v4 + + - name: Package release binary + shell: bash + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + ./scripts/package-release.sh "$version" "${{ matrix.architecture }}" + + - uses: actions/upload-artifact@v4 + with: + name: quill-macos-${{ matrix.architecture }} + path: dist/quill-macos-${{ matrix.architecture }}.tar.gz + if-no-files-found: error + + publish: + name: Publish GitHub release and Homebrew formula + needs: build + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 0 + + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Create checksums + working-directory: dist + run: sha256sum quill-macos-*.tar.gz > SHA256SUMS + + - name: Create or update GitHub release + shell: bash + run: | + set -euo pipefail + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" dist/quill-macos-*.tar.gz dist/SHA256SUMS \ + --repo "$GITHUB_REPOSITORY" --clobber + else + gh release create "$GITHUB_REF_NAME" dist/quill-macos-*.tar.gz dist/SHA256SUMS \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --generate-notes \ + --title "quill ${GITHUB_REF_NAME#v}" + fi + + - name: Update Homebrew formula + shell: bash + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + manifest_sha256="$(sha256sum dist/SHA256SUMS | cut -d' ' -f1)" + arm64_sha256="$(sha256sum dist/quill-macos-arm64.tar.gz | cut -d' ' -f1)" + x86_64_sha256="$(sha256sum dist/quill-macos-x86_64.tar.gz | cut -d' ' -f1)" + ./scripts/render-homebrew-formula.sh \ + "$version" "$GITHUB_REPOSITORY" "$manifest_sha256" \ + "$arm64_sha256" "$x86_64_sha256" + + - name: Commit Homebrew formula + shell: bash + run: | + set -euo pipefail + git add Formula/quill.rb + if git diff --cached --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "chore(homebrew): update quill to ${GITHUB_REF_NAME#v}" + git pull --rebase origin "${{ github.event.repository.default_branch }}" + git push origin "HEAD:${{ github.event.repository.default_branch }}" diff --git a/.gitignore b/.gitignore index e94f7ce..9a57655 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .build/ +.swiftpm/ +dist/ .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e5bad56 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable user-visible changes are recorded here. The project follows +[Semantic Versioning](https://semver.org/). + +## [0.1.0] - 2026-08-01 + +Initial public release. + +### Added + +- Separate microphone and system-audio recording on macOS 15 and later. +- Automatic, local Parakeet transcription with `me` and `them` speaker tags. +- Crash-resistant CAF sessions and filesystem-backed transcription recovery. +- Menu-bar controls, diagnostics, JSON configuration, and post-session hooks. +- Native Apple Silicon and Intel release archives with SHA-256 checksums. +- Homebrew installation through the repository's custom tap. +- `quill --version`, dual-architecture CI, and contributor documentation. + +[0.1.0]: https://github.com/digimata/quill/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..51f2f15 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,124 @@ +# Contributing to quill + +Thanks for helping improve quill. Small, focused pull requests are easiest to +review, especially around audio capture and permissions where macOS behavior +can be subtle. + +## Before you start + +- Search existing issues before opening a new one. +- For a substantial feature or behavior change, open an issue first so the + approach can be agreed before a large patch is written. +- Never upload a real meeting recording, transcript, config file containing + secrets, or another person's voice. Create a short synthetic audio sample + when a reproduction needs audio. + +## Development setup + +You need: + +- macOS 15 Sequoia or later +- Xcode 16 or later, or matching Command Line Tools +- Swift 6 +- A microphone for capture testing; headphones are useful when testing the + separate mic and system tracks + +Fork the repository, then clone your fork: + +```sh +git clone https://github.com/YOUR-NAME/quill.git +cd quill +git remote add upstream https://github.com/digimata/quill.git +swift package resolve +swift build +``` + +Check the CLI without starting the menu-bar daemon: + +```sh +.build/debug/quill --version +.build/debug/quill doctor +``` + +Running `.build/debug/quill` starts the daemon. macOS will request microphone +and System Audio Recording permissions on first use. Development and release +binaries may be treated as distinct permission identities after a rebuild, so +recheck System Settings → Privacy & Security when capture unexpectedly becomes +silent. + +## Project layout + +| Path | Purpose | +|---|---| +| `Sources/quill/Audio` | Microphone and Core Audio process-tap capture | +| `Sources/quill/Transcription` | On-device transcription and job coordination | +| `Sources/quill/UI` | Menu-bar UI | +| `Sources/quill/RecordingSession.swift` | Session lifecycle and on-disk metadata | +| `Sources/quill/Config.swift` | User config parsing and path resolution | +| `scripts` | Release packaging and Homebrew formula generation | +| `.github/workflows` | Intel/Apple Silicon CI and tagged releases | + +The filesystem is also the transcription queue: a session with `meta.json` +and no `transcript.json` is pending. Preserve that recovery behavior when +changing session or transcription code. + +## Making a change + +1. Create a branch from the latest `master`. +2. Keep the change focused and explain behavior changes in the commit or PR. +3. Add or update documentation for user-visible CLI, config, file-format, or + permission changes. +4. Build the production configuration before opening a PR: + + ```sh + swift build --configuration release + "$(swift build --configuration release --show-bin-path)/quill" --version + ``` + +5. For audio changes, test start, stop, and process termination. Confirm the + two CAF files remain readable and that a later recording can still start. + +CI performs a release build on native Apple Silicon and Intel macOS runners. +The project does not yet have a complete automated audio test suite, so include +the exact manual scenarios you ran in the PR description. + +## Pull requests + +A useful PR description answers: + +- What user problem does this solve? +- What changed, and what intentionally did not change? +- How was it tested, including Mac architecture and macOS version? +- Does it change permissions, config, metadata, transcript output, or recovery + after a crash? + +Do not include generated `.build` content, downloaded transcription models, or +recordings in commits. + +## Maintainer release process + +quill uses semantic versions and starts at `0.1.0` while interfaces are still +settling. + +1. Update `QuillVersion.current` in `Sources/quill/Version.swift` and + `CFBundleShortVersionString` in `Sources/quill/Info.plist` to the same value. +2. Move the release notes in `CHANGELOG.md` under the new version and date. +3. Run a release build and package the current Mac architecture: + + ```sh + swift build --configuration release + ./scripts/package-release.sh 0.1.0 "$(uname -m)" + ``` + +4. Commit the version bump, create an annotated tag, and push it: + + ```sh + git tag -a v0.1.0 -m "quill 0.1.0" + git push origin master v0.1.0 + ``` + +The release workflow verifies that the tag, Swift version, and Info.plist +version agree. It builds `arm64` and `x86_64` archives, creates a GitHub release +with checksums, renders `Formula/quill.rb`, and commits the updated formula to +the default branch. Repository Actions must have permission to write contents; +branch protection must allow the release workflow's formula update. diff --git a/README.md b/README.md index 14192fa..5519b83 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,69 @@ # quill +[![CI](https://github.com/digimata/quill/actions/workflows/ci.yml/badge.svg)](https://github.com/digimata/quill/actions/workflows/ci.yml) +[![GitHub release](https://img.shields.io/github/v/release/digimata/quill)](https://github.com/digimata/quill/releases/latest) + A minimal, fully local macOS meeting recorder + transcriber. One menu-bar click records your mic and all system audio as two separate tracks; when you stop, quill transcribes both on-device and writes a speaker-tagged transcript. Nothing ever leaves the machine. -Named for the feather. Sibling of [parrot](https://github.com/digimata/parrot), same skeleton: single -Swift binary, menu-bar tray, no app bundle. +Named for the feather. Sibling of [parrot](https://github.com/digimata/parrot), +same skeleton: single Swift binary, menu-bar tray, no app bundle. ## Install +### Homebrew (recommended) + +```sh +brew tap digimata/quill https://github.com/digimata/quill +brew install digimata/quill/quill +quill doctor +``` + +The custom tap lives in this repository. Each version tag publishes native +Apple Silicon and Intel binaries, then updates the formula with their verified +SHA-256 checksums. + +Start quill from a terminal: + +```sh +quill +``` + +Or register its per-user LaunchAgent so it starts when you sign in: + +```sh +quill install --launch-at-login +``` + +### Build from source + +You need macOS 15 or later, Xcode 16 or later (or matching Command Line Tools), +and Swift 6: + ```sh +git clone https://github.com/digimata/quill.git cd quill swift build -c release sudo cp .build/release/quill /usr/local/bin/quill -quill install --launch-at-login # optional — runs in the background on login +quill doctor ``` -**Requires:** macOS 15+ (Core Audio process taps for system audio — no -virtual device, no kernel extension). Apple Silicon recommended for -transcription speed. +If `/usr/local/bin` is not on your `PATH`, install the binary into another +directory that is. Apple Silicon is recommended for transcription speed. + +### Uninstall + +Remove the login item before removing the binary: + +```sh +quill install --uninstall +brew uninstall quill +``` + +Recordings and config are deliberately left in place. Delete +`~/Recordings` and `~/.config/quill` yourself only if you no longer need them. ## How to use @@ -114,6 +158,20 @@ quill install --uninstall - **FluidAudio / Parakeet** — on-device Core ML transcription - **NSStatusItem** — the whole UI +## Contributing + +Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) +for the development setup, project layout, validation checklist, and release +process. Please never attach a real private meeting recording to an issue; +use a short synthetic sample when audio is needed to reproduce a bug. + +## Releases + +quill follows semantic versioning while the CLI and transcript formats settle. +A tag such as `v0.1.0` triggers builds on native Apple Silicon and Intel GitHub +runners, publishes both archives plus `SHA256SUMS`, and refreshes the Homebrew +formula. See [CHANGELOG.md](CHANGELOG.md) for user-visible changes. + ## Gotchas - A global tap records *everything* the Mac plays — notification dings, @@ -125,3 +183,7 @@ quill install --uninstall engine. - The binary embeds its Info.plist (`__TEXT,__info_plist`) so TCC can attribute permissions to quill itself when running as a LaunchAgent. + +## License + +[MIT](LICENSE) diff --git a/Sources/quill/Info.plist b/Sources/quill/Info.plist index 85805b4..439802c 100644 --- a/Sources/quill/Info.plist +++ b/Sources/quill/Info.plist @@ -6,6 +6,10 @@ com.digimata.quill CFBundleName quill + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 NSMicrophoneUsageDescription quill records your microphone during meetings so you can transcribe them later. Audio never leaves this Mac. NSAudioCaptureUsageDescription diff --git a/Sources/quill/Install.swift b/Sources/quill/Install.swift index 200912a..9c64d1d 100644 --- a/Sources/quill/Install.swift +++ b/Sources/quill/Install.swift @@ -4,8 +4,8 @@ import Foundation /// Manage quill's LaunchAgent so the daemon starts at login. /// /// We deliberately do NOT use SMAppService.mainApp here — that requires a full -/// .app bundle. Since quill ships as a single binary in /usr/local/bin, a -/// plain LaunchAgent plist is the simpler, more honest mechanism. +/// .app bundle. Since quill ships as a single command-line binary, a plain +/// LaunchAgent plist is the simpler, more honest mechanism. struct Install: ParsableCommand { static let configuration = CommandConfiguration( abstract: "Install or remove the launch-at-login LaunchAgent." @@ -95,26 +95,56 @@ struct Install: ParsableCommand { } private func resolveBinaryPath() throws -> String { - // /usr/local/bin/quill is the canonical install path. Honor a real - // location if running from elsewhere (e.g. dev). - let candidate = "/usr/local/bin/quill" - if FileManager.default.isExecutableFile(atPath: candidate) { - return candidate - } - // Fall back to the running executable's resolved path. + // Keep the path the user invoked whenever possible. In particular, + // this preserves Homebrew's stable /opt/homebrew/bin symlink instead + // of resolving it into a versioned Cellar path that breaks on upgrade. let argv0 = CommandLine.arguments.first ?? "quill" - if argv0.hasPrefix("/"), FileManager.default.isExecutableFile(atPath: argv0) { - FileHandle.standardError.write(Data( - "note: /usr/local/bin/quill not found; using \(argv0)\n".utf8 - )) - return argv0 + if argv0.contains("/") { + let workingDirectory = URL( + fileURLWithPath: FileManager.default.currentDirectoryPath, + isDirectory: true + ) + let invokedPath = URL(fileURLWithPath: argv0, relativeTo: workingDirectory) + .standardizedFileURL.path + if FileManager.default.isExecutableFile(atPath: invokedPath) { + return invokedPath + } } + + // Shells do not have to pass an absolute argv[0]. Resolve the command + // against PATH so Homebrew, MacPorts, and developer installs work. + if let resolved = resolveOnPath(argv0) { + return resolved + } + + // Common stable install paths are useful when argv[0] is unusual + // (for example when another process launches quill directly). + for candidate in ["/opt/homebrew/bin/quill", "/usr/local/bin/quill"] + where FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + FileHandle.standardError.write(Data( - "couldn't locate the quill binary. install it to /usr/local/bin/quill first.\n".utf8 + "couldn't locate the quill binary. install it with Homebrew or copy it into a directory on PATH first.\n".utf8 )) throw ExitCode(1) } + private func resolveOnPath(_ command: String) -> String? { + guard !command.contains("/") else { return nil } + let path = ProcessInfo.processInfo.environment["PATH"] ?? "" + for directory in path.split(separator: ":", omittingEmptySubsequences: false) { + let base = directory.isEmpty ? FileManager.default.currentDirectoryPath : String(directory) + let candidate = URL(fileURLWithPath: base, isDirectory: true) + .appendingPathComponent(command) + .standardizedFileURL.path + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + private func uid() -> uid_t { getuid() } private func runLaunchctl(_ args: [String]) -> (status: Int32, stderr: String) { diff --git a/Sources/quill/Quill.swift b/Sources/quill/Quill.swift index 57d0bb6..fd2e348 100644 --- a/Sources/quill/Quill.swift +++ b/Sources/quill/Quill.swift @@ -7,6 +7,7 @@ struct Quill: ParsableCommand { static let configuration = CommandConfiguration( commandName: "quill", abstract: "Local meeting recorder + transcriber. Records mic and system audio as two tracks, then transcribes on-device.", + version: QuillVersion.current, subcommands: [Run.self, Doctor.self, Install.self], defaultSubcommand: Run.self ) diff --git a/Sources/quill/Version.swift b/Sources/quill/Version.swift new file mode 100644 index 0000000..480f327 --- /dev/null +++ b/Sources/quill/Version.swift @@ -0,0 +1,5 @@ +enum QuillVersion { + /// Keep this value in sync with CFBundleShortVersionString in Info.plist. + /// The release workflow refuses to package a tag with a different version. + static let current = "0.1.0" +} diff --git a/packaging/homebrew/quill.rb.template b/packaging/homebrew/quill.rb.template new file mode 100644 index 0000000..2ff0a3e --- /dev/null +++ b/packaging/homebrew/quill.rb.template @@ -0,0 +1,43 @@ +# typed: strict +# frozen_string_literal: true + +# Homebrew formula for the quill macOS recorder and transcriber. +class Quill < Formula + desc "Local macOS meeting recorder and on-device transcriber" + homepage "https://github.com/@REPOSITORY@" + url "https://github.com/@REPOSITORY@/releases/download/v@VERSION@/SHA256SUMS", using: :nounzip + version "@VERSION@" + sha256 "@MANIFEST_SHA256@" + license "MIT" + depends_on macos: :sequoia + + resource "binary" do + on_arm do + url "https://github.com/@REPOSITORY@/releases/download/v@VERSION@/quill-macos-arm64.tar.gz" + sha256 "@ARM64_SHA256@" + end + + on_intel do + url "https://github.com/@REPOSITORY@/releases/download/v@VERSION@/quill-macos-x86_64.tar.gz" + sha256 "@X86_64_SHA256@" + end + end + + def install + resource("binary").stage { bin.install "quill" } + end + + def caveats + <<~EOS + Before the first recording, check permissions and model availability: + quill doctor + + To start quill automatically when you sign in: + quill install --launch-at-login + EOS + end + + test do + assert_match version.to_s, shell_output("#{bin}/quill --version") + end +end diff --git a/scripts/package-release.sh b/scripts/package-release.sh new file mode 100755 index 0000000..df0b1a1 --- /dev/null +++ b/scripts/package-release.sh @@ -0,0 +1,73 @@ +#!/bin/sh +set -eu + +usage() { + echo "usage: $0 [output-directory]" >&2 + exit 64 +} + +[ "$#" -ge 2 ] && [ "$#" -le 3 ] || usage + +version=$1 +architecture=$2 +output_directory=${3:-dist} + +if ! printf '%s\n' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "version must be a semantic version such as 0.1.0" >&2 + exit 64 +fi + +case "$architecture" in + arm64|x86_64) ;; + *) echo "unsupported architecture: $architecture" >&2; exit 64 ;; +esac + +source_version=$(sed -n 's/.*static let current = "\([^"]*\)".*/\1/p' Sources/quill/Version.swift) +plist_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Sources/quill/Info.plist) + +if [ "$source_version" != "$version" ] || [ "$plist_version" != "$version" ]; then + echo "release version mismatch: requested=$version source=$source_version plist=$plist_version" >&2 + exit 65 +fi + +host_architecture=$(uname -m) +if [ "$host_architecture" != "$architecture" ]; then + echo "package on a native $architecture runner (current host: $host_architecture)" >&2 + exit 69 +fi + +swift build --configuration release --arch "$architecture" +binary_directory=$(swift build --configuration release --arch "$architecture" --show-bin-path) +binary="$binary_directory/quill" + +if [ ! -x "$binary" ]; then + echo "release binary not found at $binary" >&2 + exit 66 +fi + +actual_version=$($binary --version) +if [ "$actual_version" != "$version" ]; then + echo "binary reports $actual_version, expected $version" >&2 + exit 65 +fi + +if ! /usr/bin/lipo -archs "$binary" | tr ' ' '\n' | grep -qx "$architecture"; then + echo "binary does not contain the $architecture architecture" >&2 + exit 65 +fi + +# Ad-hoc signing gives the standalone Mach-O a stable internal identity. A +# future Developer ID certificate can replace '-' without changing packaging. +/usr/bin/codesign --force --sign - --identifier com.digimata.quill "$binary" +/usr/bin/codesign --verify --strict "$binary" + +mkdir -p "$output_directory" +stage=$(mktemp -d "${TMPDIR:-/tmp}/quill-release.XXXXXX") +trap 'rm -rf "$stage"' EXIT HUP INT TERM +archive="$output_directory/quill-macos-$architecture.tar.gz" +cp "$binary" "$stage/quill" +cp LICENSE README.md "$stage/" + +rm -f "$archive" +COPYFILE_DISABLE=1 /usr/bin/tar -czf "$archive" -C "$stage" quill LICENSE README.md +echo "$archive" diff --git a/scripts/render-homebrew-formula.sh b/scripts/render-homebrew-formula.sh new file mode 100755 index 0000000..f74600b --- /dev/null +++ b/scripts/render-homebrew-formula.sh @@ -0,0 +1,48 @@ +#!/bin/sh +set -eu + +usage() { + echo "usage: $0 [output]" >&2 + exit 64 +} + +[ "$#" -ge 5 ] && [ "$#" -le 6 ] || usage + +version=$1 +repository=$2 +manifest_sha256=$3 +arm64_sha256=$4 +x86_64_sha256=$5 +output=${6:-Formula/quill.rb} + +if ! printf '%s\n' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "version must be a semantic version such as 0.1.0" >&2 + exit 64 +fi + +if ! printf '%s\n' "$repository" | grep -Eq '^[0-9A-Za-z_.-]+/[0-9A-Za-z_.-]+$'; then + echo "repository must be owner/name" >&2 + exit 64 +fi + +case "$manifest_sha256:$arm64_sha256:$x86_64_sha256" in + *[!0-9a-f:]*|*:|:*) echo "checksums must be lowercase hexadecimal" >&2; exit 64 ;; +esac + +[ "${#manifest_sha256}" -eq 64 ] && \ + [ "${#arm64_sha256}" -eq 64 ] && \ + [ "${#x86_64_sha256}" -eq 64 ] || { + echo "checksums must contain 64 characters" >&2 + exit 64 +} + +mkdir -p "$(dirname "$output")" +sed \ + -e "s|@VERSION@|$version|g" \ + -e "s|@REPOSITORY@|$repository|g" \ + -e "s|@MANIFEST_SHA256@|$manifest_sha256|g" \ + -e "s|@ARM64_SHA256@|$arm64_sha256|g" \ + -e "s|@X86_64_SHA256@|$x86_64_sha256|g" \ + packaging/homebrew/quill.rb.template > "$output" + +echo "$output"