Skip to content

Latest commit

 

History

History
691 lines (522 loc) · 25.3 KB

File metadata and controls

691 lines (522 loc) · 25.3 KB

Contributing to TermQ

Thank you for your interest in contributing to TermQ! This guide will help you get started.

Table of Contents

Quick Start

# Clone and build
git clone https://github.com/eyelock/termq.git
cd termq
make build
open TermQDebug.app

Requirements

Requirement For Notes
macOS 14.0+ Building & running Required
Xcode Command Line Tools Building xcode-select --install
Full Xcode.app Unit tests & linting Download from App Store
SwiftLint Linting brew install swiftlint (requires Xcode)
swift-format Formatting brew install swift-format

Important: Unit tests and SwiftLint require the full Xcode.app installation, not just Command Line Tools. If you only have Command Line Tools, you can still build and run the app - tests will run in CI.

Development Workflow

  1. Fork the repository
  2. Create a feature branch from develop: git checkout -b feature/amazing-feature develop
  3. Make your changes
  4. Run checks: make check
  5. Commit: git commit -m 'Add amazing feature'
  6. Push: git push origin feature/amazing-feature
  7. Open a Pull Request targeting develop

Note for maintainers: Internal development uses hyphen-separated branch names (e.g. feat-amazing-feature) for worktree-directory compatibility. External contributors may use either convention — both work with GitHub.

Project Structure

termq/
├── Package.swift              # Swift Package Manager manifest
├── Makefile                   # Build, test, lint, release commands
├── TermQ.app/                 # macOS app bundle
│   └── Contents/
│       ├── Info.plist         # App metadata & URL scheme
│       └── MacOS/             # Binary location
├── TermQ.entitlements         # Code signing entitlements
├── Sources/
│   ├── TermQCore/             # Core library (testable models)
│   │   ├── Board.swift
│   │   ├── Column.swift
│   │   ├── Tag.swift
│   │   └── TerminalCard.swift
│   ├── TermQ/                 # Main app
│   │   ├── TermQApp.swift     # App entry point & URL handling
│   │   ├── ViewModels/
│   │   │   ├── BoardViewModel.swift
│   │   │   └── TerminalSessionManager.swift
│   │   └── Views/
│   │       ├── ContentView.swift
│   │       ├── KanbanBoardView.swift
│   │       ├── ColumnView.swift
│   │       ├── TerminalCardView.swift
│   │       ├── ExpandedTerminalView.swift
│   │       ├── TerminalHostView.swift
│   │       ├── CardEditorView.swift
│   │       └── ColumnEditorView.swift
│   └── termq-cli/             # CLI tool
│       └── main.swift
├── Tests/
│   └── TermQTests/            # Unit tests
└── .github/
    └── workflows/
        ├── ci.yml             # CI workflow
        ├── release.yml        # Release workflow
        └── protect-main.yml   # Enforces develop/hotfix/* → main only

Building

make build          # Build debug app bundle (TermQDebug.app, signed)
make build-release  # Build release binaries
make release-app    # Build and sign release app bundle (TermQ.app)
make compile        # Compile Swift binaries only (no app bundle)
make install        # Install app to /Applications
make install-cli    # Install CLI to /usr/local/bin

Run the debug app:

open TermQDebug.app
# Or use the convenience target
make debug

Testing

make test

The Makefile automatically sets DEVELOPER_DIR to use the full Xcode toolchain, avoiding "no such module 'XCTest'" errors that occur with just Command Line Tools.

Note: Tests require full Xcode.app installed (not just Command Line Tools). If unavailable locally, tests will still run in CI.

Test output: "0 tests in 0 suites"

You will see this line at the end of make test output:

✔ Test run with 0 tests in 0 suites passed after 0.001 seconds.

This is expected and is not a failure. Xcode 16+ ships two test runners: XCTest (which runs the project's test suite) and the newer Swift Testing framework (@Test-attributed functions). TermQ uses XCTest exclusively, so the Swift Testing runner finds nothing and exits cleanly with this message. All 900+ tests ran via XCTest and their results appear earlier in the output.

Linting & Formatting

# Install tools (first time only)
make install-swiftlint
make install-swift-format

# Lint
make lint           # Check for issues
make lint-fix       # Auto-fix issues

# Format
make format         # Format all code
make format-check   # Check formatting (CI mode)

# Run all checks
make check

Debugging

Logging with TermQLogger

TermQ uses a structured logging system in Sources/TermQ/Services/TermQLogger.swift. Do not use print() or NSLog() for diagnostics — use TermQLogger instead.

Adding log points

TermQLogger.tmux.debug("sizeChanged pane=\(id) \(cols)x\(rows)")
TermQLogger.session.info("Connected to session=\(name) existing=\(wasExisting)")
TermQLogger.focus.warning("makeFirstResponder called on nil window")
TermQLogger.session.error("connect() threw: \(error)")
Level When to use
debug High-frequency events: sizeChanged, output bytes, layout passes
info Noteworthy state changes: session connected, pane added, focus granted
warning Unexpected but recoverable: missing pane, skipped resize
error Failures that affect functionality: connect threw, process died
Category Covers
tmux Control mode protocol, resize, layout changes, pane output, commands
pane Pane lifecycle: creation, layout, border updates, cleanup
session Terminal session lifecycle: connect, disconnect, backend switching
focus Keyboard focus: first responder, tab switching, click-to-focus
io Input/output routing: key events, raw pane output bytes
ui SwiftUI/AppKit view lifecycle: appear, disappear, layout passes

Unified Logging (always active)

Every log message goes to Apple's Unified Logging system. Stream live in a second terminal:

# All TermQ messages
log stream --predicate 'subsystem == "net.eyelock.termq"'

# Filter by category
log stream --predicate 'subsystem == "net.eyelock.termq" AND category == "tmux"'

# Show past session (last 10 minutes)
log show --predicate 'subsystem == "net.eyelock.termq"' --last 10m

Or browse in Console.app — search by subsystem net.eyelock.termq.

File logging mode (TERMQ_DEBUG=1)

For active debugging, set TERMQ_DEBUG=1 to also write messages to /tmp/termq-debug.log. The file is truncated at each launch, so each run starts clean.

# Launch debug app with file logging
TERMQ_DEBUG=1 open TermQDebug.app

# Tail live output in another terminal
tail -f /tmp/termq-debug.log

# Filter to a specific category
tail -f /tmp/termq-debug.log | grep '\[tmux\]'
tail -f /tmp/termq-debug.log | grep '\[focus\]'

Note: The io category (raw output bytes) is verbose — only enable it when specifically debugging input/output routing, and filter aggressively with grep.

Xcode Debugging

Generate an Xcode project for full debugging support:

swift package generate-xcodeproj
open TermQ.xcodeproj

Then use Xcode's debugger, breakpoints, and Instruments.

Key Files for Debugging

Issue File to Check
Terminal sessions TerminalSessionManager.swift
tmux control mode TerminalSessionManager+ControlMode.swift, TmuxControlMode.swift
Board persistence BoardViewModel.swift
URL scheme handling TermQApp.swift
Drag & drop ColumnView.swift
Multi-pane layout TmuxMultiPaneView.swift, PaneTerminalView.swift

URL Scheme Testing

Test CLI integration:

open "termq://open?name=Test&path=/tmp"

Debug Binaries

The project includes separate debug CLI and MCP binaries (termqclid and termqmcpd) that are built with TERMQ_DEBUG_BUILD flag and default to using:

  • Debug bundle ID: net.eyelock.termq.app.debug
  • Debug data directory: ~/Library/Application Support/TermQ-Debug

These binaries are automatically built and installed to TermQDebug.app/Contents/Resources/ for testing the debug app without needing --debug flags.

Usage:

# Debug CLI (works with TermQDebug.app)
./TermQDebug.app/Contents/Resources/termqclid list
./TermQDebug.app/Contents/Resources/termqclid delete <terminal-name>

# Debug MCP server
./TermQDebug.app/Contents/Resources/termqmcpd

# Regular CLI (works with production TermQ.app)
./TermQ.app/Contents/Resources/termqcli list --debug

Note: The regular termqcli and termqmcp binaries target the production bundle ID by default but can use the debug data directory with the --debug flag.

Debug Build Behavior (TERMQ_DEBUG_BUILD)

The Makefile passes -Xswiftc -DTERMQ_DEBUG_BUILD when building the debug app bundle. Several behaviours differ from a release build:

Behaviour Release build Debug build (TERMQ_DEBUG_BUILD)
Signing Real Development/Distribution certificate (Team ID required) Ad-hoc (codesign --sign -, no Team ID)
Keychain Data Protection Keychain (kSecUseDataProtectionKeychain: true) — survives rebuilds Not used — see below
Encryption key storage Keychain: CFBundleIdentifier + Team ID access group File: ~/.../TermQ-Debug/.enc-key (0o600 permissions)
Bundle ID net.eyelock.termq.app net.eyelock.termq.app.debug
Data directory ~/Library/Application Support/TermQ ~/Library/Application Support/TermQ-Debug
About build number CFBundleVersion = version string (e.g. 0.7.2) CFBundleVersion = <SHA>-debug (e.g. 446ee59-debug)
Sparkle auto-updater Active — checks appcast and installs updates Disabled — updater not started, "Check for Updates" menu hidden

Why file-based key storage in debug?

The Data Protection Keychain ties items to the app's Team ID via kSecAttrAccessControl. Ad-hoc signed binaries have no Team ID, so the keychain falls back to the legacy Login Keychain and its binary-hash ACL — which invalidates on every rebuild and triggers "TermQ Debug wants to access confidential information" on every launch.

The file-based backend stores a 256-bit AES key in ~/.../TermQ-Debug/.enc-key (0o600). The secrets file itself remains AES-GCM encrypted; only the key storage location changes. Release builds are unaffected and continue to use the proper keychain backend.

How to distinguish a debug build at runtime:

# Check the About panel — the build number shows the git SHA:
# "0.7.2 (446ee59-debug)"

# Or inspect the plist directly:
plutil -p TermQDebug.app/Contents/Info.plist | grep CFBundleVersion

Releasing

The project uses semantic versioning. The version is derived entirely from git tags — there is no VERSION file.

Branch Model for Releases

  • Beta releases are tagged from develop
  • Stable releases are tagged from main, after promoting develop → main via PR

Beta Release

git checkout develop
git pull
git tag -a v0.8.0-beta.1 -m "Release v0.8.0-beta.1"
git push origin v0.8.0-beta.1

The release workflow's verify-ci step is skipped for beta builds (CI still runs on develop for every push and PR — only the release workflow's own verification step is skipped). The workflow signs, notarizes, and publishes the GitHub release as a pre-release. The appcast-beta.xml feed is updated automatically.

Stable Release

  1. Open a PR: develop → main
  2. Wait for CI to pass and merge
  3. Tag the merge commit:
git checkout main
git pull
make release   # or: make release-patch / release-minor / release-major

The release workflow verifies CI passed on that commit before building.

Pre-Release Checklist

Before tagging (beta or stable):

make check
./scripts/localization/validate-strings.sh
make test

Manual Release (if make release is unavailable)

For a beta from develop:

git checkout develop && git pull
git tag -a v0.8.0-beta.1 -m "Release v0.8.0-beta.1"
git push origin v0.8.0-beta.1

For a stable from main (after merging the develop → main PR):

git checkout main && git pull
git tag -a v0.8.0 -m "Release v0.8.0"
git push origin v0.8.0

Pushing the tag triggers release.yml. Do not push the branch separately — the workflow is tag-driven, not branch-driven.

CI/CD

Local/CI Parity

The same Makefile targets run locally and in CI. This ensures:

  • What passes locally will pass in CI
  • No surprises from environment differences
  • Reduced CI debugging cycles

Always run make check before pushing.

Path Filtering

CI only runs when code-relevant files change:

  • Sources/**, Tests/**, Package.swift, Makefile
  • .swiftlint.yml, .swift-format, .github/workflows/**

Documentation-only changes (README, CONTRIBUTING, etc.) won't trigger CI, reducing energy usage.

Pull Requests & Pushes

The CI workflow (.github/workflows/ci.yml) runs on push to main, develop, and hotfix/* branches, and on pull requests targeting main or develop.

Checks run:

  • make build - Build verification
  • make test - Unit tests
  • make lint - SwiftLint (with GitHub annotations in CI)
  • make format-check - Format check
  • make build-release - Release build verification

Releases

The release workflow (.github/workflows/release.yml) triggers on version tags (v*):

  • Builds release binaries
  • Creates signed app bundle
  • Generates checksums
  • Publishes GitHub Release with:
    • TermQ-{version}.dmg - Installer disk image
    • TermQ-{version}.zip - App bundle (CLI tool bundled inside)
    • checksums.txt - SHA-256 hashes

Makefile Reference

Run make help for all available targets:

Target Description
build Build signed debug app bundle (TermQDebug.app)
compile Compile Swift binaries only (no app bundle)
build-release Build release binaries
release-app Build signed release app bundle (TermQ.app)
clean Clean build artifacts
test Run tests (requires Xcode)
lint Run SwiftLint
lint-fix Run SwiftLint with auto-fix
debug Build and launch debug app
run Build and launch release app
format Format code with swift-format
format-check Check formatting (CI mode)
check Run all checks
app Build debug app bundle
sign Build and sign debug app
release-app Build and sign release app
install Install CLI to /usr/local/bin
uninstall Remove CLI
dmg Create distributable DMG
zip Create distributable zip
version Show current version
release Interactive release
release-major Release major version
release-minor Release minor version
release-patch Release patch version

Localization

TermQ supports 40 languages. All user-facing strings should be localized.

Adding New Strings

  1. Add the key to Sources/TermQ/Utilities/Strings.swift:
enum Settings {
    static let newOption = String(localized: "settings.new.option")
}
  1. Add the English translation to Sources/TermQ/Resources/en.lproj/Localizable.strings:
"settings.new.option" = "New Option";
  1. Add to all other language files (or run the template script):
./scripts/localization/generate-translations.sh
  1. Validate all languages have the key:
./scripts/localization/validate-strings.sh

Translation Workflow

For LLM-assisted translation:

# Extract strings to JSON
./scripts/localization/extract-to-json.sh > strings.json

# Have Claude translate the JSON, then update the .strings files

Key Files

File Purpose
Sources/TermQ/Utilities/Strings.swift Centralized string key definitions
Sources/TermQ/Utilities/SupportedLanguage.swift Language picker model
Sources/TermQ/Resources/en.lproj/Localizable.strings English (base) translations
Sources/TermQ/Resources/<lang>.lproj/Localizable.strings Other language translations
scripts/localization/*.sh Translation management scripts
.claude/commands/localization.md Claude command for localization tasks

Key Naming Convention

Keys follow the pattern: domain.description.qualifier

  • board.column.options - Board domain, column options
  • editor.field.name - Editor domain, name field
  • settings.section.language - Settings domain, language section

Language Support

The app supports all macOS languages including: English, Spanish, French, German, Italian, Portuguese, Dutch, Swedish, Danish, Finnish, Norwegian, Polish, Russian, Ukrainian, Czech, Slovak, Hungarian, Romanian, Croatian, Slovenian, Greek, Turkish, Hebrew, Arabic, Thai, Vietnamese, Indonesian, Malay, Chinese (Simplified, Traditional, Hong Kong), Japanese, Korean, Hindi, and Catalan.

Auto-Update System (Sparkle)

TermQ uses Sparkle 2.x for automatic updates.

Architecture

┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│   TermQ App     │────▶│  appcast.xml     │────▶│ GitHub Releases │
│  (Sparkle 2.x)  │     │  (GitHub Pages)  │     │   (DMG/ZIP)     │
└─────────────────┘     └──────────────────┘     └─────────────────┘
  1. App checks appcast.xml hosted on GitHub Pages for new versions
  2. Appcast points to GitHub Release artifacts (DMG/ZIP)
  3. Sparkle downloads, verifies EdDSA signature, installs, and relaunches

Key Files

File Purpose
Sources/TermQ/TermQApp.swift SparkleUpdaterDelegate and UpdaterViewModel
Sources/TermQ/Views/SettingsView.swift Update settings UI
Info.plist.template Sparkle configuration keys
scripts/generate-appcast.sh Generates appcast from GitHub Releases
.github/workflows/update-appcast.yml Auto-updates appcast on release
Docs/appcast.xml Stable channel appcast
Docs/appcast-beta.xml Beta channel appcast (includes pre-releases)

Version Format

Sparkle's SUStandardVersionComparator truncates version strings at the first dash character. This means 0.7.0-beta.8 and 0.7.0-beta.9 both reduce to 0.7.0 and compare as equal — no update is ever offered between consecutive betas.

To avoid this, the app uses dot-notation for all version fields. The git tag uses dashes (required for GitHub to detect pre-releases); a conversion is applied at build time:

Git tag App version (CFBundleVersion, sparkle:version)
v0.7.0-beta.9 0.7.0.b9
v0.7.0-alpha.3 0.7.0.a3
v0.7.0-rc.2 0.7.0.rc2
v0.7.0 0.7.0

Both CFBundleVersion and CFBundleShortVersionString use the dot-notation format. The git SHA is stored separately in the custom plist key TermQBuildSHA and displayed in Settings → About.

Appcast Generation

The appcast is automatically regenerated when a new release is published:

# Manual generation (usually not needed)
./scripts/generate-appcast.sh

This script:

  1. Fetches releases from GitHub API
  2. Extracts version, download URL, file size, and release notes
  3. Converts dash-notation tags to dot-notation (sparkle_version() function)
  4. Generates Docs/appcast.xml (stable) and Docs/appcast-beta.xml (includes prereleases)

EdDSA Signing

Sparkle 2.x uses EdDSA signatures for update verification:

  1. Generate key pair (one-time setup):

    # From Sparkle tools
    ./bin/generate_keys
  2. Store private key as GitHub Secret: SPARKLE_PRIVATE_KEY

  3. Add public key to Info.plist.template:

    <key>SUPublicEDKey</key>
    <string>[your-public-key]</string>

Release Channels

Channel Appcast Description
Stable appcast.xml Production releases only
Beta appcast-beta.xml Includes pre-releases (alpha, beta, rc)

Users select their channel in Settings > Updates > "Include beta releases".

Testing Updates Locally

  1. Run a local HTTP server:

    cd Docs
    python3 -m http.server 8080
  2. Temporarily modify SparkleUpdaterDelegate.feedURLString(for:) to use http://localhost:8080/appcast.xml

  3. Build and run the app to test update detection

Info.plist Configuration

Key Sparkle settings in Info.plist.template:

<key>SUFeedURL</key>
<string>https://eyelock.github.io/TermQ/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>[EdDSA public key]</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUAllowsAutomaticUpdates</key>
<true/>
<key>SUScheduledCheckInterval</key>
<integer>86400</integer>  <!-- 24 hours -->

Troubleshooting

  • Build errors with Sparkle: Ensure Xcode is installed (not just Command Line Tools)
  • Updates not detected: Check appcast URL accessibility and XML validity. Also verify that CFBundleVersion in the installed app is dot-notation (e.g. 0.7.0.b9), not a git SHA — run plutil -p TermQ.app/Contents/Info.plist | grep CFBundleVersion
  • Signature verification failed: Ensure SUPublicEDKey matches the private key used for signing
  • All betas compare equal: Confirms dash notation is still in use. Check that sparkle:version in the appcast and CFBundleVersion in the app both use dot-notation (0.7.0.b9, not 0.7.0-beta.9)

Dependencies

TMUX vs Direct Session Behavior Reference

Understanding how TermQ operations affect terminal sessions is critical for development. The table below documents the complete behavior matrix for all operations.

Operation Direct Session TMUX Session Card State Notes
Open Terminal Creates new shell process Creates/attaches to tmux session Active Terminal opens in tab
Close Tab Terminates shell (sends "exit\n") Detaches (sends Ctrl+B d) Active Session preserved for TMUX
Delete Card (soft delete) Terminates shell (sends "exit\n") Detaches (sends Ctrl+B d) deletedAt set Card moved to bin, TMUX session becomes orphaned
Delete Tab Card (close+delete) Terminates shell Detaches deletedAt set Same as Delete Card
Permanently Delete Card (from bin) No action No action Card removed Session remains orphaned if still running
Restore Card (from bin) - - deletedAt cleared Restores card metadata only
Kill Session SIGKILL process Runs tmux kill-session Active Forcefully terminates everything
Kill Terminal (for stuck sessions) SIGKILL process Runs tmux kill-session Active Same as Kill Session
Restart Session Marks for restart, recreates Marks for restart, recreates Active Fresh session created
Close Unfavourited Tabs Terminates shell Detaches Active Batch close operation
Recover Orphaned Session N/A Reattaches to existing session Creates new card Only for TMUX
Dismiss Recoverable Session N/A No action - Hides from recovery list
Kill Recoverable Session N/A Runs tmux kill-session - Terminates orphaned session

Key Insights

  • TMUX sessions persist independently - Closing tabs or deleting cards detaches but preserves the session
  • Direct sessions are ephemeral - They terminate when tabs close or cards are deleted
  • Soft delete creates orphaned sessions - Deleted cards with TMUX sessions show up in the recovery dialog
  • Kill operations are destructive - Use only for stuck/unresponsive terminals or intentional cleanup
  • Recovery is TMUX-only - Direct sessions cannot be recovered once terminated

Implementation Notes

See Sources/TermQ/ViewModels/TerminalSessionManager.swift:482-507 for the core session removal logic:

func removeSession(for cardId: UUID, killTmuxSession: Bool = false) {
    // ...
    switch session.backend {
    case .direct:
        // Direct mode: terminate the shell
        session.terminal.send(txt: "exit\n")
    case .tmux:
        if killTmuxSession {
            // Explicitly kill the tmux session
            try? await tmuxManager.killSession(name: sessionName)
        } else {
            // Just detach - session keeps running
            session.terminal.send(txt: "\u{02}d")  // Ctrl+B, d
        }
    }
}