First commit
This commit is contained in:
commit
488e42b9c8
325 changed files with 104074 additions and 0 deletions
3
.cargo/config.toml
Normal file
3
.cargo/config.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Windows builds are cross-compiled from macOS or Linux with mingw-w64.
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "x86_64-w64-mingw32-gcc"
|
||||
262
.claude/skills/speckit-analyze/SKILL.md
Normal file
262
.claude/skills/speckit-analyze/SKILL.md
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
---
|
||||
name: "speckit-analyze"
|
||||
description: "Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation."
|
||||
argument-hint: "Optional focus areas for analysis"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/analyze.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before analysis)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_analyze` key
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Goal.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Goal
|
||||
|
||||
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit-tasks` has successfully produced a complete `tasks.md`.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
|
||||
|
||||
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit-analyze`.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Initialize Analysis Context
|
||||
|
||||
Run `.specify/scripts/bash/check-prerequisites.sh --json --require-spec --require-tasks --include-tasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
|
||||
|
||||
- SPEC = FEATURE_DIR/spec.md
|
||||
- PLAN = FEATURE_DIR/plan.md
|
||||
- TASKS = FEATURE_DIR/tasks.md
|
||||
|
||||
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
|
||||
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
### 2. Load Artifacts (Progressive Disclosure)
|
||||
|
||||
Load only the minimal necessary context from each artifact:
|
||||
|
||||
**From spec.md:**
|
||||
|
||||
- Overview/Context
|
||||
- Functional Requirements
|
||||
- Success Criteria (measurable outcomes — e.g., performance, security, availability, user success, business impact)
|
||||
- User Stories
|
||||
- Edge Cases (if present)
|
||||
|
||||
**From plan.md:**
|
||||
|
||||
- Architecture/stack choices
|
||||
- Data Model references
|
||||
- Phases
|
||||
- Technical constraints
|
||||
|
||||
**From tasks.md:**
|
||||
|
||||
- Task IDs
|
||||
- Descriptions
|
||||
- Phase grouping
|
||||
- Parallel markers [P]
|
||||
- Referenced file paths
|
||||
|
||||
**From constitution:**
|
||||
|
||||
- Load `.specify/memory/constitution.md` for principle validation
|
||||
|
||||
### 3. Build Semantic Models
|
||||
|
||||
Create internal representations (do not include raw artifacts in output):
|
||||
|
||||
- **Requirements inventory**: For each Functional Requirement (FR-###) and Success Criterion (SC-###), record a stable key. Use the explicit FR-/SC- identifier as the primary key when present, and optionally also derive an imperative-phrase slug for readability (e.g., "User can upload file" → `user-can-upload-file`). Include only Success Criteria items that require buildable work (e.g., load-testing infrastructure, security audit tooling), and exclude post-launch outcome metrics and business KPIs (e.g., "Reduce support tickets by 50%").
|
||||
- **User story/action inventory**: Discrete user actions with acceptance criteria
|
||||
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)
|
||||
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements
|
||||
|
||||
### 4. Detection Passes (Token-Efficient Analysis)
|
||||
|
||||
Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary.
|
||||
|
||||
#### A. Duplication Detection
|
||||
|
||||
- Identify near-duplicate requirements
|
||||
- Mark lower-quality phrasing for consolidation
|
||||
|
||||
#### B. Ambiguity Detection
|
||||
|
||||
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria
|
||||
- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.)
|
||||
|
||||
#### C. Underspecification
|
||||
|
||||
- Requirements with verbs but missing object or measurable outcome
|
||||
- User stories missing acceptance criteria alignment
|
||||
- Tasks referencing files or components not defined in spec/plan
|
||||
|
||||
#### D. Constitution Alignment
|
||||
|
||||
- Any requirement or plan element conflicting with a MUST principle
|
||||
- Missing mandated sections or quality gates from constitution
|
||||
|
||||
#### E. Coverage Gaps
|
||||
|
||||
- Requirements with zero associated tasks
|
||||
- Tasks with no mapped requirement/story
|
||||
- Success Criteria requiring buildable work (performance, security, availability) not reflected in tasks
|
||||
|
||||
#### F. Inconsistency
|
||||
|
||||
- Terminology drift (same concept named differently across files)
|
||||
- Data entities referenced in plan but absent in spec (or vice versa)
|
||||
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note)
|
||||
- Conflicting requirements (e.g., one requires Next.js while other specifies Vue)
|
||||
|
||||
### 5. Severity Assignment
|
||||
|
||||
Use this heuristic to prioritize findings:
|
||||
|
||||
- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality
|
||||
- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion
|
||||
- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case
|
||||
- **LOW**: Style/wording improvements, minor redundancy not affecting execution order
|
||||
|
||||
### 6. Produce Compact Analysis Report
|
||||
|
||||
Output a Markdown report (no file writes) with the following structure:
|
||||
|
||||
## Specification Analysis Report
|
||||
|
||||
| ID | Category | Severity | Location(s) | Summary | Recommendation |
|
||||
|----|----------|----------|-------------|---------|----------------|
|
||||
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |
|
||||
|
||||
(Add one row per finding; generate stable IDs prefixed by category initial.)
|
||||
|
||||
**Coverage Summary Table:**
|
||||
|
||||
| Requirement Key | Has Task? | Task IDs | Notes |
|
||||
|-----------------|-----------|----------|-------|
|
||||
|
||||
**Constitution Alignment Issues:** (if any)
|
||||
|
||||
**Unmapped Tasks:** (if any)
|
||||
|
||||
**Metrics:**
|
||||
|
||||
- Total Requirements
|
||||
- Total Tasks
|
||||
- Coverage % (requirements with >=1 task)
|
||||
- Ambiguity Count
|
||||
- Duplication Count
|
||||
- Critical Issues Count
|
||||
|
||||
### 7. Provide Next Actions
|
||||
|
||||
At end of report, output a concise Next Actions block:
|
||||
|
||||
- If CRITICAL issues exist: Recommend resolving before `/speckit-implement`
|
||||
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
|
||||
- Provide explicit command suggestions: e.g., "Run /speckit-specify with refinement", "Run /speckit-plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
|
||||
|
||||
### 8. Offer Remediation
|
||||
|
||||
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
|
||||
|
||||
### 9. Check for extension hooks
|
||||
|
||||
After reporting, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_analyze` key
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Operating Principles
|
||||
|
||||
### Context Efficiency
|
||||
|
||||
- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation
|
||||
- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis
|
||||
- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow
|
||||
- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts
|
||||
|
||||
### Analysis Guidelines
|
||||
|
||||
- **NEVER modify files** (this is read-only analysis)
|
||||
- **NEVER hallucinate missing sections** (if absent, report them accurately)
|
||||
- **Prioritize constitution violations** (these are always CRITICAL)
|
||||
- **Use examples over exhaustive rules** (cite specific instances, not generic patterns)
|
||||
- **Report zero issues gracefully** (emit success report with coverage statistics)
|
||||
|
||||
## Context
|
||||
|
||||
$ARGUMENTS
|
||||
386
.claude/skills/speckit-checklist/SKILL.md
Normal file
386
.claude/skills/speckit-checklist/SKILL.md
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
---
|
||||
name: "speckit-checklist"
|
||||
description: "Generate a custom checklist for the current feature based on user requirements."
|
||||
argument-hint: "Domain or focus area for the checklist"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/checklist.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## Checklist Purpose: "Unit Tests for English"
|
||||
|
||||
**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain.
|
||||
|
||||
**NOT for verification/testing**:
|
||||
|
||||
- ❌ NOT "Verify the button clicks correctly"
|
||||
- ❌ NOT "Test error handling works"
|
||||
- ❌ NOT "Confirm the API returns 200"
|
||||
- ❌ NOT checking if code/implementation matches the spec
|
||||
|
||||
**FOR requirements quality validation**:
|
||||
|
||||
- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness)
|
||||
- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity)
|
||||
- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency)
|
||||
- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage)
|
||||
- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases)
|
||||
|
||||
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
|
||||
|
||||
**Ownership and checkbox lifecycle**:
|
||||
|
||||
- Custom checklists generated by this command are reviewer-owned requirements-quality review artifacts.
|
||||
- `[x]` means the reviewer determined the requirements-quality criterion is satisfied.
|
||||
- `[x]` does NOT mean implementation work is complete.
|
||||
- This command generates or appends checklist items; it MUST NOT mark generated items `[x]`.
|
||||
- An agent may assist with evaluating items only when explicitly asked by the reviewer.
|
||||
- `checklists/requirements.md` is a separate built-in spec-quality checklist maintained by `/speckit-specify` and `/speckit-clarify`; do not treat that exception as applying to custom checklists generated here.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before checklist generation)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_checklist` key
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Execution Steps.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json --template checklist-template` from repo root and parse JSON for FEATURE_DIR, AVAILABLE_DOCS list, and TEMPLATE_CONTENT.
|
||||
- All file paths must be absolute.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
|
||||
|
||||
3. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
|
||||
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
|
||||
- Only ask about information that materially changes checklist content
|
||||
- Be skipped individually if already unambiguous in `$ARGUMENTS`
|
||||
- Prefer precision over breadth
|
||||
|
||||
Generation algorithm:
|
||||
1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts").
|
||||
2. Cluster signals into candidate focus areas (max 4) ranked by relevance.
|
||||
3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit.
|
||||
4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria.
|
||||
5. Formulate questions chosen from these archetypes:
|
||||
- Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?")
|
||||
- Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?")
|
||||
- Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?")
|
||||
- Audience framing (e.g., "Will this be used by the author only or peers during PR review?")
|
||||
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
|
||||
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")
|
||||
|
||||
Question formatting rules:
|
||||
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
|
||||
- Limit to A–E options maximum; omit table if a free-form answer is clearer
|
||||
- Never ask the user to restate what they already said
|
||||
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
|
||||
|
||||
Defaults when interaction impossible:
|
||||
- Depth: Standard
|
||||
- Audience: Reviewer (PR) if code-related; Author otherwise
|
||||
- Focus: Top 2 relevance clusters
|
||||
|
||||
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
|
||||
|
||||
4. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
|
||||
- Derive checklist theme (e.g., security, review, deploy, ux)
|
||||
- Consolidate explicit must-have items mentioned by user
|
||||
- Map focus selections to category scaffolding
|
||||
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
|
||||
|
||||
5. **Load feature context**: Read from FEATURE_DIR:
|
||||
- spec.md: Feature requirements and scope
|
||||
- plan.md (if exists): Technical details, dependencies
|
||||
- tasks.md (if exists): Implementation tasks
|
||||
|
||||
**Context Loading Strategy**:
|
||||
- Load only necessary portions relevant to active focus areas (avoid full-file dumping)
|
||||
- Prefer summarizing long sections into concise scenario/requirement bullets
|
||||
- Use progressive disclosure: add follow-on retrieval only if gaps detected
|
||||
- If source docs are large, generate interim summary items instead of embedding raw text
|
||||
|
||||
6. **Generate checklist** - Use TEMPLATE_CONTENT as the structural template and create "Unit Tests for Requirements":
|
||||
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
|
||||
- Generate unique checklist filename:
|
||||
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
|
||||
- Format: `[domain].md`
|
||||
- File handling behavior:
|
||||
- If file does NOT exist: Create new file and number items starting from CHK001
|
||||
- If file exists: Append new items to existing file, continuing from the last CHK ID (e.g., if last item is CHK015, start new items at CHK016)
|
||||
- Never delete or replace existing checklist content - always preserve and append
|
||||
- Leave every newly generated item unchecked (`[ ]`); checkbox state belongs to the reviewer
|
||||
|
||||
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
|
||||
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
|
||||
- **Completeness**: Are all necessary requirements present?
|
||||
- **Clarity**: Are requirements unambiguous and specific?
|
||||
- **Consistency**: Do requirements align with each other?
|
||||
- **Measurability**: Can requirements be objectively verified?
|
||||
- **Coverage**: Are all scenarios/edge cases addressed?
|
||||
|
||||
**Category Structure** - Group items by requirement quality dimensions:
|
||||
- **Requirement Completeness** (Are all necessary requirements documented?)
|
||||
- **Requirement Clarity** (Are requirements specific and unambiguous?)
|
||||
- **Requirement Consistency** (Do requirements align without conflicts?)
|
||||
- **Acceptance Criteria Quality** (Are success criteria measurable?)
|
||||
- **Scenario Coverage** (Are all flows/cases addressed?)
|
||||
- **Edge Case Coverage** (Are boundary conditions defined?)
|
||||
- **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?)
|
||||
- **Dependencies & Assumptions** (Are they documented and validated?)
|
||||
- **Ambiguities & Conflicts** (What needs clarification?)
|
||||
|
||||
**HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**:
|
||||
|
||||
❌ **WRONG** (Testing implementation):
|
||||
- "Verify landing page displays 3 episode cards"
|
||||
- "Test hover states work on desktop"
|
||||
- "Confirm logo click navigates home"
|
||||
|
||||
✅ **CORRECT** (Testing requirements quality):
|
||||
- "Are the exact number and layout of featured episodes specified?" [Completeness]
|
||||
- "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity]
|
||||
- "Are hover state requirements consistent across all interactive elements?" [Consistency]
|
||||
- "Are keyboard navigation requirements defined for all interactive UI?" [Coverage]
|
||||
- "Is the fallback behavior specified when logo image fails to load?" [Edge Cases]
|
||||
- "Are loading states defined for asynchronous episode data?" [Completeness]
|
||||
- "Does the spec define visual hierarchy for competing UI elements?" [Clarity]
|
||||
|
||||
**ITEM STRUCTURE**:
|
||||
Each item should follow this pattern:
|
||||
- Question format asking about requirement quality
|
||||
- Focus on what's WRITTEN (or not written) in the spec/plan
|
||||
- Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.]
|
||||
- Reference spec section `[Spec §X.Y]` when checking existing requirements
|
||||
- Use `[Gap]` marker when checking for missing requirements
|
||||
|
||||
**EXAMPLES BY QUALITY DIMENSION**:
|
||||
|
||||
Completeness:
|
||||
- "Are error handling requirements defined for all API failure modes? [Gap]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Completeness]"
|
||||
- "Are mobile breakpoint requirements defined for responsive layouts? [Gap]"
|
||||
|
||||
Clarity:
|
||||
- "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]"
|
||||
- "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]"
|
||||
- "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]"
|
||||
|
||||
Consistency:
|
||||
- "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]"
|
||||
- "Are card component requirements consistent between landing and detail pages? [Consistency]"
|
||||
|
||||
Coverage:
|
||||
- "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]"
|
||||
- "Are concurrent user interaction scenarios addressed? [Coverage, Gap]"
|
||||
- "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]"
|
||||
|
||||
Measurability:
|
||||
- "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]"
|
||||
- "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]"
|
||||
|
||||
**Scenario Classification & Coverage** (Requirements Quality Focus):
|
||||
- Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios
|
||||
- For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?"
|
||||
- If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]"
|
||||
- Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]"
|
||||
|
||||
**Traceability Requirements**:
|
||||
- MINIMUM: ≥80% of items MUST include at least one traceability reference
|
||||
- Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]`
|
||||
- If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]"
|
||||
|
||||
**Surface & Resolve Issues** (Requirements Quality Problems):
|
||||
Ask questions about the requirements themselves:
|
||||
- Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]"
|
||||
- Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]"
|
||||
- Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]"
|
||||
- Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]"
|
||||
- Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]"
|
||||
|
||||
**Content Consolidation**:
|
||||
- Soft cap: If raw candidate items > 40, prioritize by risk/impact
|
||||
- Merge near-duplicates checking the same requirement aspect
|
||||
- If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]"
|
||||
|
||||
**🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test:
|
||||
- ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior
|
||||
- ❌ References to code execution, user actions, system behavior
|
||||
- ❌ "Displays correctly", "works properly", "functions as expected"
|
||||
- ❌ "Click", "navigate", "render", "load", "execute"
|
||||
- ❌ Test cases, test plans, QA procedures
|
||||
- ❌ Implementation details (frameworks, APIs, algorithms)
|
||||
|
||||
**✅ REQUIRED PATTERNS** - These test requirements quality:
|
||||
- ✅ "Are [requirement type] defined/specified/documented for [scenario]?"
|
||||
- ✅ "Is [vague term] quantified/clarified with specific criteria?"
|
||||
- ✅ "Are requirements consistent between [section A] and [section B]?"
|
||||
- ✅ "Can [requirement] be objectively measured/verified?"
|
||||
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
|
||||
- ✅ "Does the spec define [missing aspect]?"
|
||||
|
||||
7. **Structure Reference**: Generate the checklist following the canonical template in `.specify/templates/checklist-template.md` for title, meta section, category headings, ownership note, notes section, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, an ownership note explaining that `[x]` means reviewer approval of requirements quality, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001, and notes that `/speckit-implement` reads checklist state but does not modify markers.
|
||||
|
||||
8. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:
|
||||
- Focus areas selected
|
||||
- Depth level
|
||||
- Actor/timing
|
||||
- Any explicit user-specified must-have items incorporated
|
||||
|
||||
**Important**: Each `/speckit-checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
|
||||
|
||||
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
|
||||
- Simple, memorable filenames that indicate checklist purpose
|
||||
- Easy identification and navigation in the `checklists/` folder
|
||||
|
||||
To avoid clutter, use descriptive types and clean up obsolete checklists when done.
|
||||
|
||||
## Example Checklist Types & Sample Items
|
||||
|
||||
**UX Requirements Quality:** `ux.md`
|
||||
|
||||
Sample items (testing the requirements, NOT the implementation):
|
||||
|
||||
- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]"
|
||||
- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]"
|
||||
- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]"
|
||||
- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]"
|
||||
- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]"
|
||||
|
||||
**API Requirements Quality:** `api.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are error response formats specified for all failure scenarios? [Completeness]"
|
||||
- "Are rate limiting requirements quantified with specific thresholds? [Clarity]"
|
||||
- "Are authentication requirements consistent across all endpoints? [Consistency]"
|
||||
- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]"
|
||||
- "Is versioning strategy documented in requirements? [Gap]"
|
||||
|
||||
**Performance Requirements Quality:** `performance.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are performance requirements quantified with specific metrics? [Clarity]"
|
||||
- "Are performance targets defined for all critical user journeys? [Coverage]"
|
||||
- "Are performance requirements under different load conditions specified? [Completeness]"
|
||||
- "Can performance requirements be objectively measured? [Measurability]"
|
||||
- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]"
|
||||
|
||||
**Security Requirements Quality:** `security.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are authentication requirements specified for all protected resources? [Coverage]"
|
||||
- "Are data protection requirements defined for sensitive information? [Completeness]"
|
||||
- "Is the threat model documented and requirements aligned to it? [Traceability]"
|
||||
- "Are security requirements consistent with compliance obligations? [Consistency]"
|
||||
- "Are security failure/breach response requirements defined? [Gap, Exception Flow]"
|
||||
|
||||
## Anti-Examples: What NOT To Do
|
||||
|
||||
**❌ WRONG - These test implementation, not requirements:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001]
|
||||
- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003]
|
||||
- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010]
|
||||
- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005]
|
||||
```
|
||||
|
||||
**✅ CORRECT - These test requirements quality:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001]
|
||||
- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003]
|
||||
- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010]
|
||||
- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005]
|
||||
- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap]
|
||||
- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001]
|
||||
```
|
||||
|
||||
**Key Differences:**
|
||||
|
||||
- Wrong: Tests if the system works correctly
|
||||
- Correct: Tests if the requirements are written correctly
|
||||
- Wrong: Verification of behavior
|
||||
- Correct: Validation of requirement quality
|
||||
- Wrong: "Does it do X?"
|
||||
- Correct: "Is X clearly specified?"
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after checklist generation)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_checklist` key
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
294
.claude/skills/speckit-clarify/SKILL.md
Normal file
294
.claude/skills/speckit-clarify/SKILL.md
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
---
|
||||
name: "speckit-clarify"
|
||||
description: "Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec."
|
||||
argument-hint: "Optional areas to clarify in the spec"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/clarify.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before clarification)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_clarify` key
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
|
||||
|
||||
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit-plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
|
||||
|
||||
Execution steps:
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
|
||||
- `FEATURE_DIR`
|
||||
- `FEATURE_SPEC`
|
||||
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
|
||||
- If JSON parsing fails, abort and instruct user to re-run `/speckit-specify` or verify feature branch environment.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
|
||||
|
||||
3. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||
|
||||
Functional Scope & Behavior:
|
||||
- Core user goals & success criteria
|
||||
- Explicit out-of-scope declarations
|
||||
- User roles / personas differentiation
|
||||
|
||||
Domain & Data Model:
|
||||
- Entities, attributes, relationships
|
||||
- Identity & uniqueness rules
|
||||
- Lifecycle/state transitions
|
||||
- Data volume / scale assumptions
|
||||
|
||||
Interaction & UX Flow:
|
||||
- Critical user journeys / sequences
|
||||
- Error/empty/loading states
|
||||
- Accessibility or localization notes
|
||||
|
||||
Non-Functional Quality Attributes:
|
||||
- Performance (latency, throughput targets)
|
||||
- Scalability (horizontal/vertical, limits)
|
||||
- Reliability & availability (uptime, recovery expectations)
|
||||
- Observability (logging, metrics, tracing signals)
|
||||
- Security & privacy (authN/Z, data protection, threat assumptions)
|
||||
- Compliance / regulatory constraints (if any)
|
||||
|
||||
Integration & External Dependencies:
|
||||
- External services/APIs and failure modes
|
||||
- Data import/export formats
|
||||
- Protocol/versioning assumptions
|
||||
|
||||
Edge Cases & Failure Handling:
|
||||
- Negative scenarios
|
||||
- Rate limiting / throttling
|
||||
- Conflict resolution (e.g., concurrent edits)
|
||||
|
||||
Constraints & Tradeoffs:
|
||||
- Technical constraints (language, storage, hosting)
|
||||
- Explicit tradeoffs or rejected alternatives
|
||||
|
||||
Terminology & Consistency:
|
||||
- Canonical glossary terms
|
||||
- Avoided synonyms / deprecated terms
|
||||
|
||||
Completion Signals:
|
||||
- Acceptance criteria testability
|
||||
- Measurable Definition of Done style indicators
|
||||
|
||||
Misc / Placeholders:
|
||||
- TODO markers / unresolved decisions
|
||||
- Ambiguous adjectives ("robust", "intuitive") lacking quantification
|
||||
|
||||
For each category with Partial or Missing status, add a candidate question opportunity unless:
|
||||
- Clarification would not materially change implementation or validation strategy
|
||||
- The item is specifically about implementation method, tech-stack comparison, or task breakdown (note internally)
|
||||
|
||||
4. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
|
||||
- Maximum of 5 total questions across the whole session.
|
||||
- Each question must be answerable with EITHER:
|
||||
- A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR
|
||||
- A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words").
|
||||
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.
|
||||
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.
|
||||
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).
|
||||
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
|
||||
- If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.
|
||||
|
||||
5. Sequential questioning loop (interactive):
|
||||
- Present EXACTLY ONE question at a time.
|
||||
- **Question writing quality (applies to every question, MC or short-answer):**
|
||||
- Lead with `**Question:**` followed by a full interrogative that ends with `?`. The question text before the `?` must make sense on its own.
|
||||
- NEVER use a topic label, section heading, or requirement id as the question itself. For example, `Acceptance device/runtime matrix (FR-023)` is INVALID — it is a label, not a question.
|
||||
- After the `?`, the only permitted suffix is an optional parenthesized requirement/question id. Exact format: `**Question:** <interrogative>?` or `**Question:** <interrogative>? (FR-023)`. Never put the id before the `?`, and never use the id (alone or with a topic label) as the whole prompt.
|
||||
- Immediately after the question line, add one plain-language "Why it matters" sentence (the stake for acceptance or shipping) before the recommendation/options.
|
||||
- Use everyday wording; introduce jargon only if defined in the same sentence. Self-check: a reader who does not know Spec Kit must be able to answer from the Question line alone. Terse is fine; cryptic labels are not.
|
||||
- For multiple‑choice questions:
|
||||
- **Analyze all options** and determine the **most suitable option** based on:
|
||||
- Best practices for the project type
|
||||
- Common patterns in similar implementations
|
||||
- Risk reduction (security, performance, maintainability)
|
||||
- Alignment with any explicit project goals or constraints visible in the spec
|
||||
- Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).
|
||||
- Format as: `**Recommended:** Option [X] - <reasoning>`
|
||||
- Then render all options as a Markdown table:
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| A | <Option A description> |
|
||||
| B | <Option B description> |
|
||||
| C | <Option C description> (add D/E as needed up to 5) |
|
||||
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |
|
||||
|
||||
- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
|
||||
- For short‑answer style (no meaningful discrete options):
|
||||
- Provide your **suggested answer** based on best practices and context.
|
||||
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
|
||||
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
|
||||
- After the user answers:
|
||||
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
|
||||
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
|
||||
- If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
|
||||
- Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
|
||||
- Stop asking further questions when:
|
||||
- All critical ambiguities resolved early (remaining queued items become unnecessary), OR
|
||||
- User signals completion ("done", "good", "no more"), OR
|
||||
- You reach 5 asked questions.
|
||||
- Never reveal future queued questions in advance.
|
||||
- If no valid questions exist at start, immediately report no critical ambiguities.
|
||||
|
||||
6. Integration after EACH accepted answer (incremental update approach):
|
||||
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
|
||||
- For the first integrated answer in this session:
|
||||
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
|
||||
- Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.
|
||||
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.
|
||||
- Then immediately apply the clarification to the most appropriate section(s):
|
||||
- Functional ambiguity → Update or add a bullet in Functional Requirements.
|
||||
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
|
||||
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
|
||||
- Non-functional constraint → Add/modify measurable criteria in Success Criteria > Measurable Outcomes (convert vague adjective to metric or explicit target).
|
||||
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
|
||||
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
|
||||
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
|
||||
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).
|
||||
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
|
||||
- Keep each inserted clarification minimal and testable (avoid narrative drift).
|
||||
|
||||
7. Validation (performed after EACH write plus final pass):
|
||||
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
|
||||
- Total asked (accepted) questions ≤ 5.
|
||||
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
|
||||
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed).
|
||||
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
|
||||
- Terminology consistency: same canonical term used across all updated sections.
|
||||
|
||||
8. Write the updated spec back to `FEATURE_SPEC`.
|
||||
|
||||
9. **Re-validate Spec Quality Checklist** (if it exists):
|
||||
- Check if `FEATURE_DIR/checklists/requirements.md` exists.
|
||||
- If it does NOT exist, skip this step silently.
|
||||
- If it exists:
|
||||
1. Read the checklist file.
|
||||
2. Identify all GitHub task-list checkbox lines — lines matching `- [ ]`, `- [x]`, or `- [X]` (case-insensitive, tolerant of leading whitespace for nested items) outside of code fences. Ignore all other content (headings, notes, non-checkbox bullets, metadata).
|
||||
3. For each checkbox line, record its current marker state (checked or unchecked) and item text into a before-snapshot list.
|
||||
4. Re-evaluate each checkbox item against the **updated** spec (the version just saved in step 7).
|
||||
5. For each checkbox item, update only if the checked/unchecked state actually changes:
|
||||
- If the item now passes and was unchecked: change `[ ]` to `[x]`.
|
||||
- If the item now fails and was checked: change `[x]`/`[X]` to `[ ]`.
|
||||
- If the state is unchanged: leave the marker as-is (preserve existing case to avoid cosmetic diffs).
|
||||
6. Save the updated checklist file. **Only toggle the `[ ]`/`[x]` marker portion of checkbox lines whose state changed.** All other file content — headings, metadata, notes, line ordering, whitespace — must remain unchanged to avoid noisy diffs.
|
||||
7. Compare the before-snapshot with the current state to compute three lists for the Completion Report:
|
||||
- **Newly passing**: items that changed from unchecked to checked.
|
||||
- **Regressions**: items that changed from checked to unchecked.
|
||||
- **Still unchecked**: items that remain unchecked.
|
||||
8. Record the before/after pass counts as checked/total checkbox items (e.g., "12/16 → 15/16 items passing").
|
||||
|
||||
Behavior rules:
|
||||
|
||||
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
|
||||
- If spec file missing, instruct user to run `/speckit-specify` first (do not create a new spec here).
|
||||
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
|
||||
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
|
||||
- Respect user early termination signals ("stop", "done", "proceed").
|
||||
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.
|
||||
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
|
||||
|
||||
Context for prioritization: $ARGUMENTS
|
||||
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_clarify`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_clarify` key.
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Report completion (after questioning loop ends or early termination):
|
||||
- Number of questions asked & answered.
|
||||
- Path to updated spec.
|
||||
- Sections touched (list names).
|
||||
- Spec quality checklist status (if `FEATURE_DIR/checklists/requirements.md` was re-validated): show before/after pass counts (e.g., "Spec Quality Checklist: 12/16 → 15/16 items passing") and list any items that changed state — both newly checked (unchecked → checked) and any regressions (checked → unchecked). If any items remain unchecked, list them as areas needing attention.
|
||||
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota, or remaining item is specifically implementation method, tech-stack comparison, or task breakdown), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
|
||||
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit-plan` or run `/speckit-clarify` again later post-plan.
|
||||
- Suggested next command.
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Spec ambiguities identified and clarifications integrated into spec file
|
||||
- [ ] Spec quality checklist re-validated against updated spec (if `FEATURE_DIR/checklists/requirements.md` exists)
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with questions answered, sections touched, checklist status, and coverage summary
|
||||
182
.claude/skills/speckit-constitution/SKILL.md
Normal file
182
.claude/skills/speckit-constitution/SKILL.md
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
---
|
||||
name: "speckit-constitution"
|
||||
description: "Create or update the project constitution from interactive or provided principle inputs."
|
||||
argument-hint: "Principles or values for the project constitution"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/constitution.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Scope Guard
|
||||
|
||||
This command's own work is limited to updating the project constitution itself. Dependent templates
|
||||
and commands read the constitution at runtime and are not modified here.
|
||||
|
||||
- Classify every part of the user input as either constitution content or a separate,
|
||||
non-governance intent.
|
||||
- If the input includes feature implementation, code generation, refactoring, building, or
|
||||
deployment requests, you **MUST NOT** execute them. Extract them as deferred intents instead.
|
||||
- You **MUST NOT** create, modify, or delete application source files, feature routes,
|
||||
components, tests, deployment files, or other artifacts unrelated to the constitution
|
||||
workflow.
|
||||
- If it is unclear whether an instruction is constitution content, ask for clarification before
|
||||
making changes.
|
||||
- After completing the constitution update, include a `Next Actions` section for each deferred
|
||||
intent. List the original intent and suggest the appropriate follow-up Spec Kit command, such
|
||||
as `/speckit-specify`, without invoking it.
|
||||
- If there are no non-governance intents, omit the `Next Actions` section.
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before constitution update)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_constitution` key
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
You are updating the project constitution at `.specify/memory/constitution.md`. The active
|
||||
constitution scaffold is resolved at command time from `constitution-template` through the Spec Kit
|
||||
preset/template resolution stack.
|
||||
|
||||
Follow this execution flow:
|
||||
|
||||
1. Run `.specify/scripts/bash/resolve-template.sh constitution-template --json` from the repository root and parse `TEMPLATE_CONTENT` as the active template.
|
||||
- The shared resolver applies project overrides, composing preset layers, and extension layers
|
||||
before the core template fallback. It MUST succeed before continuing.
|
||||
- If it fails, stop and report the resolution error; do not continue with only one contributing
|
||||
template layer.
|
||||
- If `.specify/memory/constitution.md` exists, load it as the source of current project-specific
|
||||
values and amendments. Preserve information that is still applicable when applying the newly
|
||||
resolved scaffold.
|
||||
- If it does not exist, use the resolved template as the initial document.
|
||||
- Do not write back to any versioned template layer.
|
||||
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
|
||||
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
|
||||
|
||||
2. Collect/derive values for placeholders:
|
||||
- If user input (conversation) supplies a value, use it.
|
||||
- Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded).
|
||||
- For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous.
|
||||
- `CONSTITUTION_VERSION` must increment according to semantic versioning rules:
|
||||
- MAJOR: Backward incompatible governance/principle removals or redefinitions.
|
||||
- MINOR: New principle/section added or materially expanded guidance.
|
||||
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
|
||||
- If version bump type ambiguous, propose reasoning before finalizing.
|
||||
|
||||
3. Draft the updated constitution content using the resolved template as the required structure:
|
||||
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
|
||||
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
|
||||
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious.
|
||||
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
|
||||
|
||||
4. Produce a Sync Impact Report as an HTML comment at the top of the constitution file after update.
|
||||
This report is temporary scratch material for human review of the amendment, not governance
|
||||
content; it is expected to be removed before the amended constitution file is committed.
|
||||
- Version change: old → new
|
||||
- List of modified principles (old title → new title if renamed)
|
||||
- Added sections
|
||||
- Removed sections
|
||||
- Follow-up TODOs if any placeholders intentionally deferred.
|
||||
|
||||
5. Validation before final output:
|
||||
- No remaining unexplained bracket tokens.
|
||||
- Version line matches report.
|
||||
- Dates ISO format YYYY-MM-DD.
|
||||
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
|
||||
|
||||
6. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
|
||||
|
||||
7. Output a final summary to the user with:
|
||||
- New version and bump rationale.
|
||||
- Any TODO placeholders or deferred items requiring manual follow-up.
|
||||
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
|
||||
- A `Next Actions` section for any deferred non-governance intents.
|
||||
|
||||
Formatting & Style Requirements:
|
||||
|
||||
- Use Markdown headings exactly as in the template (do not demote/promote levels).
|
||||
- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks.
|
||||
- Keep a single blank line between sections.
|
||||
- Avoid trailing whitespace.
|
||||
|
||||
If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps.
|
||||
|
||||
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
|
||||
|
||||
Write only `.specify/memory/constitution.md`; do not create or modify template source files.
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after constitution update)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_constitution` key
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
279
.claude/skills/speckit-converge/SKILL.md
Normal file
279
.claude/skills/speckit-converge/SKILL.md
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
---
|
||||
name: "speckit-converge"
|
||||
description: "Assess the current codebase against the feature's spec, plan, and tasks, then append any remaining unbuilt work as new tasks to tasks.md so implement can complete it."
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/converge.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before convergence)**:
|
||||
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_converge` key
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
|
||||
```text
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
|
||||
```text
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Goal.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Goal
|
||||
|
||||
Close the gap between what a feature's specification, plan, and tasks call for and what the
|
||||
codebase currently implements. Read `spec.md`, `plan.md`, and `tasks.md` as the **sole
|
||||
source of intent** (with the constitution as governing constraints), assess the current
|
||||
state of the code, determine which requirements, acceptance criteria, plan decisions, and
|
||||
existing tasks are unmet, incomplete, or only partially satisfied, and **append each piece
|
||||
of remaining work as a new, traceable task** at the bottom of `tasks.md` so that
|
||||
`/speckit-implement` can complete it. This command MUST run only after
|
||||
`/speckit-implement` has run on the current `tasks.md`, and after `/speckit-tasks` has produced a complete `tasks.md`.
|
||||
|
||||
This is **not** a diff tool and does **not** track changes. It assesses the present state
|
||||
of the code relative to the feature's artifacts — no git, no branch comparison, no history.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
**APPEND-ONLY, NEVER REWRITE**: The command's **only** write is appending a new
|
||||
`## Phase N: Convergence` section to `tasks.md`. It MUST NOT:
|
||||
|
||||
- modify `spec.md` or `plan.md` in any way;
|
||||
- rewrite, renumber, reorder, or delete any existing task (including tasks from a prior
|
||||
Convergence phase);
|
||||
- modify, create, or delete any application code — completing the appended tasks is the
|
||||
job of `/speckit-implement`.
|
||||
|
||||
When the codebase already satisfies everything, the command MUST leave `tasks.md`
|
||||
**byte-for-byte unchanged** (no empty Convergence header) and report a clean result.
|
||||
|
||||
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is
|
||||
**non-negotiable**. Code that violates a MUST principle is the highest-severity finding and
|
||||
produces a corresponding remediation task. If the constitution is an unfilled template,
|
||||
skip constitution checks gracefully rather than failing.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Initialize Convergence Context
|
||||
|
||||
Run `.specify/scripts/bash/check-prerequisites.sh --json --require-spec --require-tasks --include-tasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
|
||||
|
||||
- SPEC = FEATURE_DIR/spec.md
|
||||
- PLAN = FEATURE_DIR/plan.md
|
||||
- TASKS = FEATURE_DIR/tasks.md
|
||||
- CONSTITUTION = `.specify/memory/constitution.md` (if present)
|
||||
If `spec.md`, `plan.md`, or `tasks.md` is missing, STOP with a clear, actionable message naming the
|
||||
prerequisite command to run (`/speckit-specify` for a missing spec, `/speckit-plan` for a missing plan,
|
||||
`/speckit-tasks` for missing tasks). Do not produce partial output.
|
||||
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
### 2. Load Artifacts (Progressive Disclosure)
|
||||
|
||||
Load only the minimal necessary context from each artifact:
|
||||
|
||||
**From spec.md:**
|
||||
|
||||
- Functional Requirements (FR-###)
|
||||
- Success Criteria (SC-###) — include only items requiring buildable work; exclude
|
||||
post-launch outcome metrics and business KPIs
|
||||
- User Stories and their Acceptance Scenarios
|
||||
- Edge Cases (if present)
|
||||
|
||||
**From plan.md:**
|
||||
|
||||
- Architecture/stack choices and technical decisions
|
||||
- Data Model references
|
||||
- Phases and named touch-points (files/components the plan says will be created or edited)
|
||||
- Technical constraints
|
||||
|
||||
**From tasks.md:**
|
||||
|
||||
- Task IDs (to compute the next ID and next phase number)
|
||||
- Descriptions, phase grouping, and referenced file paths
|
||||
|
||||
**From constitution (if not an unfilled template):**
|
||||
|
||||
- Principle names and MUST/SHOULD normative statements
|
||||
|
||||
### 3. Build the Intent Inventory
|
||||
|
||||
Create an internal model (do not echo raw artifacts):
|
||||
|
||||
- **Requirements inventory**: one stable key per FR-### / SC-### / user-story acceptance
|
||||
scenario (e.g. `US1/AC2`), plus the plan decisions and constitution principles that
|
||||
impose buildable obligations.
|
||||
- **Code-scope map**: from the file paths named in `plan.md` and `tasks.md`, plus a keyword
|
||||
search for the concepts each requirement describes, derive the set of source files and
|
||||
components in scope for assessment. Bound the assessment to these — do **not** infer
|
||||
scope beyond what the artifacts define.
|
||||
|
||||
### 4. Assess the Codebase and Classify Findings
|
||||
|
||||
For each item in the intent inventory, inspect the current code in scope and produce a
|
||||
`Finding` only where there is a gap. Classify every finding by **gap type**:
|
||||
|
||||
- **`missing`**: the required work is absent from the code entirely.
|
||||
- **`partial`**: the work exists but does not yet fully satisfy the requirement /
|
||||
acceptance criterion / plan decision.
|
||||
- **`contradicts`**: the code does something that conflicts with stated intent or a
|
||||
constitution MUST principle.
|
||||
- **`unrequested`**: the code contains work not called for by the spec, plan, or tasks
|
||||
(surfaced for awareness — converge does **not** delete code, it only appends a task to
|
||||
review/justify or remove it).
|
||||
|
||||
Each `Finding` records: a stable id, the `source-ref` it traces to, the `gap-type`, a
|
||||
severity, and a short human-readable description with the evidence (the file/area observed).
|
||||
|
||||
**Edge cases:**
|
||||
|
||||
- **Little or no code yet**: treat the entire specified scope as `missing` remaining work
|
||||
rather than failing.
|
||||
- **Nothing remains**: produce zero findings and follow the converged branch in Step 7.
|
||||
|
||||
### 5. Assign Severity
|
||||
|
||||
- **CRITICAL**: violates a constitution MUST principle, or a `missing`/`contradicts` gap
|
||||
that blocks baseline functionality of a P1 user story.
|
||||
- **HIGH**: a `missing` or `partial` gap on a core functional requirement or acceptance
|
||||
criterion.
|
||||
- **MEDIUM**: a `partial` gap on a secondary requirement, or an `unrequested` addition with
|
||||
unclear justification.
|
||||
- **LOW**: minor partial gaps, polish, or low-risk `unrequested` additions.
|
||||
|
||||
### 6. Present the In-Session Findings Summary
|
||||
|
||||
Before appending anything, output a compact, severity-graded summary (no file writes yet):
|
||||
|
||||
## Convergence Findings
|
||||
|
||||
| ID | Gap Type | Severity | Source | Evidence | Remaining Work |
|
||||
|----|----------|----------|--------|----------|----------------|
|
||||
| F1 | missing | HIGH | FR-008 | Example: no append-only guard detected in path/to/module.py when writing tasks.md | Add append-only enforcement |
|
||||
|
||||
**Summary metrics:**
|
||||
|
||||
- Requirements / acceptance criteria checked
|
||||
- Plan decisions checked
|
||||
- Constitution principles checked (or "skipped — template")
|
||||
- Findings by gap type (missing / partial / contradicts / unrequested)
|
||||
- Findings by severity
|
||||
|
||||
### 7. Append Convergence Tasks (or report converged)
|
||||
|
||||
**If there are one or more actionable findings** (`tasks_appended` outcome):
|
||||
|
||||
Append to the **end** of `tasks.md`, per the append contract:
|
||||
|
||||
1. Scan all existing task IDs; let `M` be the maximum. Determine the next phase number `N`
|
||||
(highest existing phase + 1).
|
||||
2. Write a single new section header `## Phase N: Convergence`.
|
||||
3. Emit one checklist item per actionable finding, ordered CRITICAL/HIGH first, assigning
|
||||
zero-padded IDs `T{M+1:03d}, T{M+2:03d}, …`:
|
||||
|
||||
```markdown
|
||||
- [ ] T042 <imperative description> per <source-ref> (<gap-type>)
|
||||
```
|
||||
|
||||
`<source-ref>` traces the task to its origin: e.g. `FR-003`, `SC-002`,
|
||||
`US1/AC2`, `plan: storage decision`, `Constitution II`.
|
||||
|
||||
`<gap-type>` is one of `missing`, `partial`, `contradicts`, `unrequested`.
|
||||
|
||||
Constitution-violation tasks MUST be emitted first and described as
|
||||
`CRITICAL`.
|
||||
4. Never reuse or renumber existing IDs. If a prior Convergence phase exists, add a new,
|
||||
separately-numbered one below it — do not touch the old one.
|
||||
|
||||
**If there are no actionable findings** (`converged` outcome):
|
||||
|
||||
- Do **not** modify `tasks.md` at all — no empty phase header.
|
||||
- Report: **"✅ Converged — the implementation satisfies the spec, plan, and tasks."**
|
||||
- Include the summary counts of what was checked.
|
||||
|
||||
### 8. Provide Next Actions (Handoff)
|
||||
|
||||
- On `tasks_appended`: state how many tasks were appended under which phase, and recommend
|
||||
running `/speckit-implement` to complete them; note that a follow-up converge
|
||||
run will find fewer or no remaining items.
|
||||
- On `converged`: recommend proceeding to review / opening a PR. No further implement pass
|
||||
is needed for this feature's specified scope.
|
||||
|
||||
### 9. Check for extension hooks
|
||||
|
||||
After producing the result, check if `.specify/extensions.yml` exists in the project root.
|
||||
|
||||
- If it exists, read it and look for entries under the `hooks.after_converge` key
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- Report the convergence outcome (`converged` or `tasks_appended`) in-session before listing
|
||||
any hooks, so users can decide whether to run optional follow-up commands.
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
|
||||
```text
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
|
||||
```text
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
229
.claude/skills/speckit-implement/SKILL.md
Normal file
229
.claude/skills/speckit-implement/SKILL.md
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
---
|
||||
name: "speckit-implement"
|
||||
description: "Execute the implementation plan by processing and executing all tasks defined in tasks.md"
|
||||
argument-hint: "Optional implementation guidance or task filter"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/implement.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before implementation)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_implement` key
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
|
||||
- Treat checklist markers as a read-only gate: scan checkbox state, report status, and ask before proceeding when needed; do NOT modify checklist files or markers
|
||||
- `checklists/requirements.md` is the built-in spec-quality checklist maintained by `/speckit-specify` and `/speckit-clarify`; custom checklists generated by `/speckit-checklist` are reviewer-owned requirements-quality review artifacts
|
||||
- For custom checklists, `[x]` means the reviewer determined the requirements-quality criterion is satisfied; it does NOT mean implementation work is complete
|
||||
- Scan all checklist files in the checklists/ directory
|
||||
- For each checklist, count:
|
||||
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
|
||||
- Checked items: Lines matching `- [X]` or `- [x]`
|
||||
- Unchecked items: Lines matching `- [ ]`
|
||||
- Create a status table:
|
||||
|
||||
```text
|
||||
| Checklist | Total | Checked | Unchecked | Status |
|
||||
|-----------|-------|---------|-----------|--------|
|
||||
| ux.md | 12 | 12 | 0 | ✓ PASS |
|
||||
| test.md | 8 | 5 | 3 | ✗ FAIL |
|
||||
| security.md | 6 | 6 | 0 | ✓ PASS |
|
||||
```
|
||||
|
||||
- Calculate overall status:
|
||||
- **PASS**: All checklists have 0 unchecked items
|
||||
- **FAIL**: One or more checklists have unchecked items
|
||||
|
||||
- **If any checklist has unchecked items**:
|
||||
- Display the table with unchecked item counts
|
||||
- **STOP** and ask: "Some checklists have unchecked items. Do you want to proceed with implementation anyway? (yes/no)"
|
||||
- Wait for user response before continuing
|
||||
- If user says "no" or "wait" or "stop", halt execution
|
||||
- If user says "yes" or "proceed" or "continue", proceed to step 3
|
||||
|
||||
- **If all checklists are checked**:
|
||||
- Display the table showing all checklists passed
|
||||
- Automatically proceed to step 3
|
||||
|
||||
3. Load and analyze the implementation context:
|
||||
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
|
||||
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
|
||||
- **IF EXISTS**: Read data-model.md for entities and relationships
|
||||
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
|
||||
- **IF EXISTS**: Read research.md for technical decisions and constraints
|
||||
- **IF EXISTS**: Read .specify/memory/constitution.md for governance constraints
|
||||
- **IF EXISTS**: Read quickstart.md for integration scenarios
|
||||
|
||||
4. **Project Setup Verification**:
|
||||
- **REQUIRED**: Create/verify ignore files based on actual project setup:
|
||||
|
||||
**Detection & Creation Logic**:
|
||||
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):
|
||||
|
||||
```sh
|
||||
git rev-parse --git-dir 2>/dev/null
|
||||
```
|
||||
|
||||
- Check if Dockerfile* exists or Docker in plan.md → create/verify .dockerignore
|
||||
- Check if .eslintrc* exists → create/verify .eslintignore
|
||||
- Check if eslint.config.* exists → ensure the config's `ignores` entries cover required patterns
|
||||
- Check if .prettierrc* exists → create/verify .prettierignore
|
||||
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)
|
||||
- Check if terraform files (*.tf) exist → create/verify .terraformignore
|
||||
- Check if .helmignore needed (helm charts present) → create/verify .helmignore
|
||||
|
||||
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only
|
||||
**If ignore file missing**: Create with full pattern set for detected technology
|
||||
|
||||
**Common Patterns by Technology** (from plan.md tech stack):
|
||||
- **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`
|
||||
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`
|
||||
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`
|
||||
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`
|
||||
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`
|
||||
- **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/`
|
||||
- **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env`
|
||||
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`
|
||||
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`
|
||||
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`
|
||||
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `*.dll`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*`
|
||||
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`
|
||||
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`
|
||||
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
|
||||
|
||||
**Tool-Specific Patterns**:
|
||||
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`
|
||||
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`
|
||||
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
|
||||
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`
|
||||
- **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt`
|
||||
|
||||
5. Parse tasks.md structure and extract:
|
||||
- **Task phases**: Setup, Tests, Core, Integration, Polish
|
||||
- **Task dependencies**: Sequential vs parallel execution rules
|
||||
- **Task details**: ID, description, file paths, parallel markers [P]
|
||||
- **Execution flow**: Order and dependency requirements
|
||||
|
||||
6. Execute implementation following the task plan:
|
||||
- **Phase-by-phase execution**: Complete each phase before moving to the next
|
||||
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
|
||||
- **Two test tiers**: Follow AGENTS.md "Testing". Each story's integration test must fail without the story's change and pass with it; add unit tests only where the rules call for them
|
||||
- **File-based coordination**: Tasks affecting the same files must run sequentially
|
||||
- **Validation checkpoints**: Verify each phase completion before proceeding
|
||||
|
||||
7. Implementation execution rules:
|
||||
- **Setup first**: Initialize project structure, dependencies, configuration
|
||||
- **Integration first**: Prove each story through the project as a whole over real files and sockets, with fakes only at the platform seams
|
||||
- **Core development**: Implement models, services, CLI commands, endpoints
|
||||
- **Integration work**: Database connections, middleware, logging, external services
|
||||
- **Polish and validation**: Pruning tests that break the testing rules, performance optimization, documentation
|
||||
|
||||
8. Progress tracking and error handling:
|
||||
- Report progress after each completed task
|
||||
- Halt execution if any non-parallel task fails
|
||||
- For parallel tasks [P], continue with successful tasks, report failed ones
|
||||
- Provide clear error messages with context for debugging
|
||||
- Suggest next steps if implementation cannot proceed
|
||||
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
|
||||
|
||||
9. Completion validation:
|
||||
- Verify all required tasks are completed
|
||||
- Check that implemented features match the original specification
|
||||
- Validate that `make test` and `make test-integration` pass and that every claimed resilience behaviour has an integration test
|
||||
- Confirm the implementation follows the technical plan
|
||||
|
||||
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit-tasks` first to regenerate the task list.
|
||||
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_implement`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_implement` key.
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Report final status with summary of completed work.
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] All tasks in tasks.md completed and marked `[X]`
|
||||
- [ ] Implementation validated against specification and plan, with the tests the two-tier rule calls for
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with summary of completed work
|
||||
169
.claude/skills/speckit-plan/SKILL.md
Normal file
169
.claude/skills/speckit-plan/SKILL.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
---
|
||||
name: "speckit-plan"
|
||||
description: "Execute the implementation planning workflow using the plan template to generate design artifacts."
|
||||
argument-hint: "Optional guidance for the planning phase"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/plan.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before planning)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_plan` key
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, FEATURE_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load context**: Read FEATURE_SPEC and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
|
||||
|
||||
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
|
||||
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
|
||||
- Fill Constitution Check section from constitution
|
||||
- Evaluate gates (ERROR if violations unjustified)
|
||||
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
|
||||
- Phase 1: Generate data-model.md, contracts/, quickstart.md
|
||||
- Re-evaluate Constitution Check post-design
|
||||
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_plan`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_plan` key.
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Command ends after Phase 1 design. Report branch, IMPL_PLAN path, and generated artifacts.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0: Outline & Research
|
||||
|
||||
1. **Extract unknowns from Technical Context** above:
|
||||
- For each NEEDS CLARIFICATION → research task
|
||||
- For each dependency → best practices task
|
||||
- For each integration → patterns task
|
||||
|
||||
2. **Generate and dispatch research agents**:
|
||||
|
||||
```text
|
||||
For each unknown in Technical Context:
|
||||
Task: "Research {unknown} for {feature context}"
|
||||
For each technology choice:
|
||||
Task: "Find best practices for {tech} in {domain}"
|
||||
```
|
||||
|
||||
3. **Consolidate findings** in `research.md` using format:
|
||||
- Decision: [what was chosen]
|
||||
- Rationale: [why chosen]
|
||||
- Alternatives considered: [what else evaluated]
|
||||
|
||||
**Output**: research.md with all NEEDS CLARIFICATION resolved
|
||||
|
||||
### Phase 1: Design & Contracts
|
||||
|
||||
**Prerequisites:** `research.md` complete
|
||||
|
||||
1. **Extract entities from feature spec** → `data-model.md`:
|
||||
- Entity name, fields, relationships
|
||||
- Validation rules from requirements
|
||||
- State transitions if applicable
|
||||
|
||||
2. **Define interface contracts** (if project has external interfaces) → `/contracts/`:
|
||||
- Identify what interfaces the project exposes to users or other systems
|
||||
- Document the contract format appropriate for the project type
|
||||
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
|
||||
- Skip if project is purely internal (build scripts, one-off tools, etc.)
|
||||
|
||||
3. **Create quickstart validation guide** → `quickstart.md`:
|
||||
- Document runnable validation scenarios that prove the feature works end-to-end
|
||||
- Include prerequisites, setup commands, test/run commands, and expected outcomes
|
||||
- Use links or references to contracts and data model details instead of duplicating them
|
||||
- Do not include full implementation code, model/service/controller bodies, migrations, or complete test suites
|
||||
- Keep this artifact as a validation/run guide; implementation details belong in `tasks.md` and the implementation phase
|
||||
|
||||
**Output**: data-model.md, /contracts/*, quickstart.md
|
||||
|
||||
## Key rules
|
||||
|
||||
- Use absolute paths for filesystem operations; use project-relative paths for references in documentation
|
||||
- ERROR on gate failures or unresolved clarifications
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Plan workflow executed and design artifacts generated
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with branch, plan path, and generated artifacts
|
||||
348
.claude/skills/speckit-specify/SKILL.md
Normal file
348
.claude/skills/speckit-specify/SKILL.md
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
---
|
||||
name: "speckit-specify"
|
||||
description: "Create or update the feature specification from a natural language feature description."
|
||||
argument-hint: "Describe the feature you want to specify"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/specify.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before specification)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_specify` key
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
The text the user typed after `/speckit-specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
|
||||
|
||||
Given that feature description, do this:
|
||||
|
||||
1. **Generate a concise short name** (2-4 words) for the feature:
|
||||
- Analyze the feature description and extract the most meaningful keywords
|
||||
- Create a 2-4 word short name that captures the essence of the feature
|
||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||
- Keep it concise but descriptive enough to understand the feature at a glance
|
||||
- Examples:
|
||||
- "I want to add user authentication" → "user-auth"
|
||||
- "Implement OAuth2 integration for the API" → "oauth2-api-integration"
|
||||
- "Create a dashboard for analytics" → "analytics-dashboard"
|
||||
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
||||
|
||||
2. **Branch creation** (optional, via hook):
|
||||
|
||||
If a `before_specify` hook ran successfully in the Pre-Execution Checks above, it will have created/switched to a git branch and output JSON containing `BRANCH_NAME` and `FEATURE_NUM`. Note these values for reference, but the branch name does **not** dictate the spec directory name.
|
||||
|
||||
If the user explicitly provided `GIT_BRANCH_NAME`, pass it through to the hook so the branch script uses the exact value as the branch name (bypassing all prefix/suffix generation).
|
||||
|
||||
3. **Create the spec feature directory**:
|
||||
|
||||
Specs live under the default `specs/` directory unless the user explicitly provides `SPECIFY_FEATURE_DIRECTORY`.
|
||||
|
||||
**Resolution order for `SPECIFY_FEATURE_DIRECTORY`**:
|
||||
1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is
|
||||
2. Otherwise, auto-generate it under `specs/`:
|
||||
- Check `.specify/init-options.json` for `feature_numbering` (preferred) or `branch_numbering` (deprecated, migration only — will be removed in a future release)
|
||||
- If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
|
||||
- If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
|
||||
- Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||
- Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
|
||||
- If `branch_numbering` was used (and `feature_numbering` was absent), emit a one-line warning: "⚠️ `branch_numbering` in init-options.json is deprecated. Rename to `feature_numbering`."
|
||||
|
||||
**Create the directory and spec file**:
|
||||
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`
|
||||
- Resolve the active `spec-template` through the Spec Kit preset/template resolution stack (equivalent to `specify preset resolve spec-template`)
|
||||
- Copy the resolved `spec-template` file to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point
|
||||
- Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md`
|
||||
- Persist the resolved path to `.specify/feature.json`:
|
||||
```json
|
||||
{
|
||||
"feature_directory": "<resolved feature dir>"
|
||||
}
|
||||
```
|
||||
Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`.
|
||||
This allows downstream commands (`/speckit-plan`, `/speckit-tasks`, etc.) to locate the feature directory without relying on git branch name conventions.
|
||||
|
||||
**IMPORTANT**:
|
||||
- You must only create one feature per `/speckit-specify` invocation
|
||||
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
|
||||
- The spec directory and file are always created by this command, never by the hook
|
||||
|
||||
4. Load the resolved active `spec-template` file to understand required sections.
|
||||
|
||||
5. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
|
||||
|
||||
6. Follow this execution flow:
|
||||
1. Parse user description from arguments
|
||||
If empty: ERROR "No feature description provided"
|
||||
2. Extract key concepts from description
|
||||
Identify: actors, actions, data, constraints
|
||||
3. For unclear aspects:
|
||||
- Make informed guesses based on context and industry standards
|
||||
- Only mark with [NEEDS CLARIFICATION: specific question] if:
|
||||
- The choice significantly impacts feature scope or user experience
|
||||
- Multiple reasonable interpretations exist with different implications
|
||||
- No reasonable default exists
|
||||
- **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total**
|
||||
- Prioritize clarifications by impact: scope > security/privacy > user experience > technical details
|
||||
4. Fill User Scenarios & Testing section
|
||||
If no clear user flow: ERROR "Cannot determine user scenarios"
|
||||
5. Generate Functional Requirements
|
||||
Each requirement must be testable
|
||||
Use reasonable defaults for unspecified details (document assumptions in Assumptions section)
|
||||
6. Define Success Criteria
|
||||
Create measurable, technology-agnostic outcomes
|
||||
Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion)
|
||||
Each criterion must be verifiable without implementation details
|
||||
7. Identify Key Entities (if data involved)
|
||||
8. Return: SUCCESS (spec ready for planning)
|
||||
|
||||
7. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
|
||||
|
||||
8. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
|
||||
|
||||
a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items:
|
||||
|
||||
```markdown
|
||||
# Specification Quality Checklist: [FEATURE NAME]
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md]
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [ ] No implementation details (languages, frameworks, APIs)
|
||||
- [ ] Focused on user value and business needs
|
||||
- [ ] Written for non-technical stakeholders
|
||||
- [ ] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [ ] No [NEEDS CLARIFICATION] markers remain
|
||||
- [ ] Requirements are testable and unambiguous
|
||||
- [ ] Success criteria are measurable
|
||||
- [ ] Success criteria are technology-agnostic (no implementation details)
|
||||
- [ ] All acceptance scenarios are defined
|
||||
- [ ] Edge cases are identified
|
||||
- [ ] Scope is clearly bounded
|
||||
- [ ] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [ ] All functional requirements have clear acceptance criteria
|
||||
- [ ] User scenarios cover primary flows
|
||||
- [ ] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [ ] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan`
|
||||
```
|
||||
|
||||
b. **Run Validation Check**: Review the spec against each checklist item:
|
||||
- For each item, determine if it passes or fails
|
||||
- Document specific issues found (quote relevant spec sections)
|
||||
|
||||
c. **Handle Validation Results**:
|
||||
|
||||
- **If all items pass**: Mark checklist complete and proceed to the Mandatory Post-Execution Hooks section
|
||||
|
||||
- **If items fail (excluding [NEEDS CLARIFICATION])**:
|
||||
1. List the failing items and specific issues
|
||||
2. Update the spec to address each issue
|
||||
3. Re-run validation until all items pass (max 3 iterations)
|
||||
4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user
|
||||
|
||||
- **If [NEEDS CLARIFICATION] markers remain**:
|
||||
1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec
|
||||
2. **LIMIT CHECK**: If more than 3 markers exist, keep only the 3 most critical (by scope/security/UX impact) and make informed guesses for the rest
|
||||
3. For each clarification needed (max 3), present options to user in this format:
|
||||
|
||||
```markdown
|
||||
## Question [N]: [Topic]
|
||||
|
||||
**Context**: [Quote relevant spec section]
|
||||
|
||||
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
|
||||
|
||||
**Suggested Answers**:
|
||||
|
||||
| Option | Answer | Implications |
|
||||
|--------|--------|--------------|
|
||||
| A | [First suggested answer] | [What this means for the feature] |
|
||||
| B | [Second suggested answer] | [What this means for the feature] |
|
||||
| C | [Third suggested answer] | [What this means for the feature] |
|
||||
| Custom | Provide your own answer | [Explain how to provide custom input] |
|
||||
|
||||
**Your choice**: _[Wait for user response]_
|
||||
```
|
||||
|
||||
4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted:
|
||||
- Use consistent spacing with pipes aligned
|
||||
- Each cell should have spaces around content: `| Content |` not `|Content|`
|
||||
- Header separator must have at least 3 dashes: `|--------|`
|
||||
- Test that the table renders correctly in markdown preview
|
||||
5. Number questions sequentially (Q1, Q2, Q3 - max 3 total)
|
||||
6. Present all questions together before waiting for responses
|
||||
7. Wait for user to respond with their choices for all questions (e.g., "Q1: A, Q2: Custom - [details], Q3: B")
|
||||
8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer
|
||||
9. Re-run validation after all clarifications are resolved
|
||||
|
||||
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
|
||||
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_specify`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_specify` key.
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Report completion to the user with:
|
||||
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
|
||||
- `SPEC_FILE` — the spec file path
|
||||
- Checklist results summary
|
||||
- Readiness for the next phase (`/speckit-clarify` or `/speckit-plan`)
|
||||
|
||||
**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command.
|
||||
|
||||
## Quick Guidelines
|
||||
|
||||
- Focus on **WHAT** users need and **WHY**.
|
||||
- Avoid HOW to implement (no tech stack, APIs, code structure).
|
||||
- Written for business stakeholders, not developers.
|
||||
- DO NOT create any checklists that are embedded in the spec. That will be a separate command.
|
||||
|
||||
### Section Requirements
|
||||
|
||||
- **Mandatory sections**: Must be completed for every feature
|
||||
- **Optional sections**: Include only when relevant to the feature
|
||||
- When a section doesn't apply, remove it entirely (don't leave as "N/A")
|
||||
|
||||
### For AI Generation
|
||||
|
||||
When creating this spec from a user prompt:
|
||||
|
||||
1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps
|
||||
2. **Document assumptions**: Record reasonable defaults in the Assumptions section
|
||||
3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that:
|
||||
- Significantly impact feature scope or user experience
|
||||
- Have multiple reasonable interpretations with different implications
|
||||
- Lack any reasonable default
|
||||
4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details
|
||||
5. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item
|
||||
6. **Common areas needing clarification** (only if no reasonable default exists):
|
||||
- Feature scope and boundaries (include/exclude specific use cases)
|
||||
- User types and permissions (if multiple conflicting interpretations possible)
|
||||
- Security/compliance requirements (when legally/financially significant)
|
||||
|
||||
**Examples of reasonable defaults** (don't ask about these):
|
||||
|
||||
- Data retention: Industry-standard practices for the domain
|
||||
- Performance targets: Standard web/mobile app expectations unless specified
|
||||
- Error handling: User-friendly messages with appropriate fallbacks
|
||||
- Authentication method: Standard session-based or OAuth2 for web apps
|
||||
- Integration patterns: Use project-appropriate patterns (REST/GraphQL for web services, function calls for libraries, CLI args for tools, etc.)
|
||||
|
||||
### Success Criteria Guidelines
|
||||
|
||||
Success criteria must be:
|
||||
|
||||
1. **Measurable**: Include specific metrics (time, percentage, count, rate)
|
||||
2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools
|
||||
3. **User-focused**: Describe outcomes from user/business perspective, not system internals
|
||||
4. **Verifiable**: Can be tested/validated without knowing implementation details
|
||||
|
||||
**Good examples**:
|
||||
|
||||
- "Users can complete checkout in under 3 minutes"
|
||||
- "System supports 10,000 concurrent users"
|
||||
- "95% of searches return results in under 1 second"
|
||||
- "Task completion rate improves by 40%"
|
||||
|
||||
**Bad examples** (implementation-focused):
|
||||
|
||||
- "API response time is under 200ms" (too technical, use "Users see results instantly")
|
||||
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric)
|
||||
- "React components render efficiently" (framework-specific)
|
||||
- "Redis cache hit rate above 80%" (technology-specific)
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Specification written to `SPEC_FILE` and validated against quality checklist
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with feature directory, spec file path, and checklist results
|
||||
218
.claude/skills/speckit-tasks/SKILL.md
Normal file
218
.claude/skills/speckit-tasks/SKILL.md
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
---
|
||||
name: "speckit-tasks"
|
||||
description: "Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts."
|
||||
argument-hint: "Optional task generation constraints"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/tasks.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before tasks generation)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_tasks` key
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `.specify/scripts/bash/setup-tasks.sh --json` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE_CONTENT, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load design documents**: Read from FEATURE_DIR:
|
||||
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
|
||||
- **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios)
|
||||
- **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints
|
||||
- Note: Not all projects have all documents. Generate tasks based on what's available.
|
||||
|
||||
3. **Execute task generation workflow**:
|
||||
- Load plan.md and extract tech stack, libraries, project structure
|
||||
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)
|
||||
- If data-model.md exists: Extract entities and map to user stories
|
||||
- If contracts/ exists: Map interface contracts to user stories
|
||||
- If research.md exists: Extract decisions for setup tasks
|
||||
- Generate tasks organized by user story (see Task Generation Rules below)
|
||||
- Generate dependency graph showing user story completion order
|
||||
- Create parallel execution examples per user story
|
||||
- Validate task completeness (each user story has all needed tasks, independently testable)
|
||||
|
||||
4. **Generate tasks.md**: Use TASKS_TEMPLATE_CONTENT (from the JSON output above) as the structure. For compatibility with older setup scripts that omit TASKS_TEMPLATE_CONTENT, read TASKS_TEMPLATE instead. Fill with:
|
||||
- Correct feature name from plan.md
|
||||
- Phase 1: Setup tasks (project initialization)
|
||||
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
|
||||
- Phase 3+: One phase per user story (in priority order from spec.md)
|
||||
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks
|
||||
- Final Phase: Polish & cross-cutting concerns
|
||||
- All tasks must follow the strict checklist format (see Task Generation Rules below)
|
||||
- Clear file paths for each task
|
||||
- Dependencies section showing story completion order
|
||||
- Parallel execution examples per story
|
||||
- Implementation strategy section (MVP first, incremental delivery)
|
||||
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_tasks`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_tasks` key.
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Output path to generated tasks.md and summary:
|
||||
- Total task count
|
||||
- Task count per user story
|
||||
- Parallel opportunities identified
|
||||
- Independent test criteria for each story
|
||||
- Suggested MVP scope (typically just User Story 1)
|
||||
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
|
||||
|
||||
Context for task generation: $ARGUMENTS
|
||||
|
||||
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.
|
||||
|
||||
## Task Generation Rules
|
||||
|
||||
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.
|
||||
|
||||
**Tests follow the two-tier rule** (constitution Principle VI, AGENTS.md "Testing"): each user story gets an integration test task that drives the project as a whole over real files and sockets, and that induces and recovers from any failure the story claims to survive. Generate unit test tasks only for serialization boundaries, external formats and facts, and non-obvious algorithms. Never generate test tasks for simple logic, for what the story's integration test already proves, or to raise coverage.
|
||||
|
||||
### Checklist Format (REQUIRED)
|
||||
|
||||
Every task MUST strictly follow this format:
|
||||
|
||||
```text
|
||||
- [ ] [TaskID] [P?] [Story?] Description with file path
|
||||
```
|
||||
|
||||
**Format Components**:
|
||||
|
||||
1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox)
|
||||
2. **Task ID**: Sequential number (T001, T002, T003...) in execution order
|
||||
3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks)
|
||||
4. **[Story] label**: REQUIRED for user story phase tasks only
|
||||
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
|
||||
- Setup phase: NO story label
|
||||
- Foundational phase: NO story label
|
||||
- User Story phases: MUST have story label
|
||||
- Polish phase: NO story label
|
||||
5. **Description**: Clear action with exact file path
|
||||
|
||||
**Examples**:
|
||||
|
||||
- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
|
||||
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
|
||||
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
|
||||
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
|
||||
- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label)
|
||||
- ❌ WRONG: `T001 [US1] Create model` (missing checkbox)
|
||||
- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID)
|
||||
- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path)
|
||||
|
||||
### Task Organization
|
||||
|
||||
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:
|
||||
- Each user story (P1, P2, P3...) gets its own phase
|
||||
- Map all related components to their story:
|
||||
- Models needed for that story
|
||||
- Services needed for that story
|
||||
- Interfaces/UI needed for that story
|
||||
- The story's integration test, and any unit test the two-tier rule calls for
|
||||
- Mark story dependencies (most stories should be independent)
|
||||
|
||||
2. **From Contracts**:
|
||||
- Map each interface contract → to the user story it serves
|
||||
- A contract clients switch on (error codes, exit codes, wire formats) → one unit test task pinning it, in that story's phase
|
||||
|
||||
3. **From Data Model**:
|
||||
- Map each entity to the user story(ies) that need it
|
||||
- If entity serves multiple stories: Put in earliest story or Setup phase
|
||||
- Relationships → service layer tasks in appropriate story phase
|
||||
- For each field with constraints in data-model.md (max length, nullable/required, enum values, validation rules), quote the constraint verbatim in the task description so it is not left to implementation-time discretion
|
||||
|
||||
4. **From Setup/Infrastructure**:
|
||||
- Shared infrastructure → Setup phase (Phase 1)
|
||||
- Foundational/blocking tasks → Foundational phase (Phase 2)
|
||||
- Story-specific setup → within that story's phase
|
||||
|
||||
### Phase Structure
|
||||
|
||||
- **Phase 1**: Setup (project initialization)
|
||||
- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories)
|
||||
- **Phase 3+**: User Stories in priority order (P1, P2, P3...)
|
||||
- Within each story: Models → Services → Endpoints → Integration → the story's tests
|
||||
- Each phase should be a complete, independently testable increment
|
||||
- **Final Phase**: Polish & Cross-Cutting Concerns
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] tasks.md generated with all phases, task IDs, and file paths
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with task count, story breakdown, and MVP scope
|
||||
112
.claude/skills/speckit-taskstoissues/SKILL.md
Normal file
112
.claude/skills/speckit-taskstoissues/SKILL.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
---
|
||||
name: "speckit-taskstoissues"
|
||||
description: "Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts."
|
||||
argument-hint: "Optional filter or label for GitHub issues"
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: "github-spec-kit"
|
||||
source: "templates/commands/taskstoissues.md"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before tasks-to-issues conversion)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_taskstoissues` key
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
1. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
|
||||
1. From the executed script, extract the path to **tasks**.
|
||||
1. Get the Git remote by running:
|
||||
|
||||
```bash
|
||||
git config --get remote.origin.url
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL
|
||||
|
||||
1. **Fetch existing issues for deduplication**: Before creating anything, build the set of task IDs you are about to process from `tasks.md` (each is a `T` followed by **at least** three digits, e.g. `T001` — `/speckit-converge` assigns new IDs with `T{M+1:03d}`, which is a floor rather than a cap, so once a file has more than 999 tasks the IDs are four digits or longer). Then use the GitHub MCP server's `list_issues` tool to look for issues that already cover those IDs. Do not pass a `state` value, since omitting it makes the tool return both open and closed issues. Request `perPage: 100` to keep the number of calls down, and since the tool uses cursor-based pagination, request pages with the `after` parameter (using the `endCursor` from the previous response). For each issue title, match it against the task ID pattern `\bT\d{3,}\b` (the `{3,}` accepts four-digit and longer IDs — with `\d{3}` a title containing `T1000` would not match at all, because the trailing `\b` cannot fall between two digits, so that task would be silently neither deduplicated nor created; word boundaries still stop a token like `ST001` from matching, and force the whole digit run to be consumed so `T100` can never match inside `T1000`; this also recognises titles written as `T001 ...`, `T001: ...` or `[T001] ...`) and, when it matches one of your task IDs, mark that ID as already having an issue. Stop paginating as soon as every task ID has been matched, or when there are no more pages, so you do not keep fetching the whole repository's issue history once all task IDs are accounted for. This bounds the number of calls on repos with large issue histories and still prevents duplicates when the command is re-run after `tasks.md` is regenerated or the skill is re-invoked.
|
||||
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote. Task lines in `tasks.md` start with a markdown checkbox, so first strip the leading `- [ ]` (and any `[P]` / `[US#]` markers) to recover the task ID and its description. Create the issue with a single canonical title of the form `T001: <description>`, with the ID written once followed by the task description (for example, the line `- [ ] T001 Create project structure` becomes the title `T001: Create project structure`).
|
||||
- **Skip** any task whose ID is already present in the set of existing issues from the previous step, and report it (for example, `T001 already has an issue, skipping`).
|
||||
- Only create issues for tasks that do not yet have a matching issue.
|
||||
|
||||
> [!CAUTION]
|
||||
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after tasks-to-issues conversion)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_taskstoissues` key
|
||||
- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/target
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
.DS_Store
|
||||
/dist
|
||||
.release-env
|
||||
|
||||
# The contributor guide and the project's Claude Code skills are published, whatever a global
|
||||
# ignore file says. The rest of .claude/ stays local; git cannot re-include a path inside an
|
||||
# ignored directory, so the directory is re-included and everything but skills/ ignored again.
|
||||
!/AGENTS.md
|
||||
!/.claude/
|
||||
/.claude/*
|
||||
!/.claude/skills/
|
||||
|
||||
# Developer ID certificate and notary key, which packaging/macos/bundle.sh finds on its own.
|
||||
/.signing
|
||||
205
.goreleaser.yaml
Normal file
205
.goreleaser.yaml
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
# Builds every release artifact from one machine, inside the image packaging/cross/Dockerfile
|
||||
# makes. Run it through the Makefile: `make snapshot` or `make release`.
|
||||
version: 2
|
||||
|
||||
project_name: midi-harbor
|
||||
|
||||
builds:
|
||||
# The full build, with the graphical interface, for Linux and Windows.
|
||||
- id: full
|
||||
builder: rust
|
||||
binary: midi-harbor
|
||||
targets:
|
||||
# Linux against Debian 12's glibc, the sysroots' release.
|
||||
- x86_64-unknown-linux-gnu.2.36
|
||||
- aarch64-unknown-linux-gnu.2.36
|
||||
- x86_64-pc-windows-gnu
|
||||
flags: &flags
|
||||
- --release
|
||||
- --locked
|
||||
- --package=midi-harbor
|
||||
env: &env
|
||||
- MACOSX_DEPLOYMENT_TARGET=11.0
|
||||
- SDKROOT=/osxcross/MacOSX.sdk
|
||||
- PKG_CONFIG_ALLOW_CROSS=1
|
||||
- PKG_CONFIG_SYSROOT_DIR={{ if eq .Os "linux" }}/sysroot/linux_{{ .Arch }}{{ end }}
|
||||
- PKG_CONFIG_LIBDIR={{ if eq .Os "linux" }}/sysroot/linux_{{ .Arch }}/usr/lib/{{ if eq .Arch "amd64" }}x86_64{{ else }}aarch64{{ end }}-linux-gnu/pkgconfig{{ end }}
|
||||
# cargo-zigbuild hands bindgen zig's own libc headers in place of the system's, so the
|
||||
# sysroot's are added after them for the Avahi bindings.
|
||||
# Room in the Mach-O header for the code signature packaging/macos/bundle.sh adds, which zig
|
||||
# otherwise leaves too little of.
|
||||
- CARGO_TARGET_X86_64_APPLE_DARWIN_RUSTFLAGS=-C link-arg=-Wl,-headerpad_max_install_names
|
||||
- CARGO_TARGET_AARCH64_APPLE_DARWIN_RUSTFLAGS=-C link-arg=-Wl,-headerpad_max_install_names
|
||||
- BINDGEN_EXTRA_CLANG_ARGS={{ if eq .Os "linux" }}-idirafter /sysroot/linux_{{ .Arch }}/usr/include -idirafter /sysroot/linux_{{ .Arch }}/usr/include/{{ if eq .Arch "amd64" }}x86_64{{ else }}aarch64{{ end }}-linux-gnu{{ end }}
|
||||
|
||||
# The full build for the Mac, which ships only inside the app on the disk image, never in an
|
||||
# archive of its own.
|
||||
- id: full-macos
|
||||
builder: rust
|
||||
binary: midi-harbor
|
||||
targets:
|
||||
- x86_64-apple-darwin
|
||||
- aarch64-apple-darwin
|
||||
flags: *flags
|
||||
env: *env
|
||||
|
||||
# The headless build carries none of the GUI toolkit, for servers and machines without a
|
||||
# display. It is the only loose binary the Mac gets.
|
||||
- id: headless
|
||||
builder: rust
|
||||
binary: midi-harbor
|
||||
targets:
|
||||
- x86_64-unknown-linux-gnu.2.36
|
||||
- aarch64-unknown-linux-gnu.2.36
|
||||
- x86_64-apple-darwin
|
||||
- aarch64-apple-darwin
|
||||
flags:
|
||||
- --release
|
||||
- --locked
|
||||
- --package=midi-harbor
|
||||
- --no-default-features
|
||||
env: *env
|
||||
|
||||
# One binary for both kinds of Mac, which the app bundle carries. Its id keeps it out of the
|
||||
# archives, which on the Mac hold only the headless binaries.
|
||||
universal_binaries:
|
||||
- id: macos-app
|
||||
ids: [full-macos]
|
||||
name_template: midi-harbor
|
||||
hooks:
|
||||
# GoReleaser's own edition makes no app bundles or disk images, so the script does.
|
||||
post:
|
||||
- cmd: packaging/macos/bundle.sh "{{ .Path }}" "{{ .Version }}" dist/macos dist
|
||||
|
||||
archives:
|
||||
- id: full
|
||||
ids: [full]
|
||||
name_template: "{{ .ProjectName }}-{{ .Version }}.{{ .Os }}-{{ .Arch }}"
|
||||
formats: [tar.gz]
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
files: &files
|
||||
- LICENSE.txt
|
||||
- src: docs/*.md
|
||||
dst: docs
|
||||
- id: headless
|
||||
ids: [headless]
|
||||
name_template: "{{ .ProjectName }}-headless-{{ .Version }}.{{ .Os }}-{{ .Arch }}"
|
||||
formats: [tar.gz]
|
||||
files: *files
|
||||
|
||||
nfpms:
|
||||
- id: full
|
||||
ids: [full]
|
||||
package_name: midi-harbor
|
||||
file_name_template: "{{ .ConventionalFileName }}"
|
||||
formats: [deb, rpm]
|
||||
vendor: Midi Harbor
|
||||
homepage: https://github.com/grmrgecko/midi-harbor
|
||||
# Taken from the environment at build time rather than written into the repository.
|
||||
maintainer: "{{ .Env.MIDI_HARBOR_MAINTAINER }}"
|
||||
description: &description |-
|
||||
Manages virtual MIDI ports, MIDI hardware, RTP-MIDI network sessions and
|
||||
Bluetooth LE MIDI links, and reconnects every one of them by itself after
|
||||
sleep, network changes, unplugging and restarts.
|
||||
license: MIT
|
||||
section: sound
|
||||
conflicts: [midi-harbor-headless]
|
||||
replaces: [midi-harbor-headless]
|
||||
contents:
|
||||
# Named for the window's application ID, which is how a desktop pairs the running window
|
||||
# with this entry and its icon.
|
||||
- src: packaging/linux/com.mrgeckosmedia.MidiHarbor.desktop
|
||||
dst: /usr/share/applications/com.mrgeckosmedia.MidiHarbor.desktop
|
||||
- src: Icon.svg
|
||||
dst: /usr/share/icons/hicolor/scalable/apps/com.mrgeckosmedia.MidiHarbor.svg
|
||||
- src: LICENSE.txt
|
||||
dst: /usr/share/doc/midi-harbor/copyright
|
||||
- src: docs/*.md
|
||||
dst: /usr/share/doc/midi-harbor/
|
||||
overrides:
|
||||
deb:
|
||||
dependencies:
|
||||
# The binary uses symbols up to glibc 2.35, which nfpm does not detect by itself.
|
||||
- libc6 (>= 2.35)
|
||||
- libasound2
|
||||
- libavahi-client3
|
||||
- libavahi-common3
|
||||
- libdbus-1-3
|
||||
- libxkbcommon0
|
||||
# Without these the package still works, but sessions are not advertised and Bluetooth
|
||||
# is unavailable. The rest are what the interface loads while it runs rather than links
|
||||
# against: one of X11 or Wayland to draw a window, and one of EGL or Vulkan to render.
|
||||
recommends:
|
||||
- avahi-daemon
|
||||
- bluez
|
||||
- libwayland-client0
|
||||
- libx11-6
|
||||
- libx11-xcb1
|
||||
- libxi6
|
||||
- libxkbcommon-x11-0
|
||||
- libegl1
|
||||
- libvulkan1
|
||||
rpm:
|
||||
dependencies:
|
||||
- glibc >= 2.35
|
||||
- alsa-lib
|
||||
- avahi-libs
|
||||
- dbus-libs
|
||||
- libxkbcommon
|
||||
recommends:
|
||||
- avahi
|
||||
- bluez
|
||||
- libwayland-client
|
||||
- libX11
|
||||
- libX11-xcb
|
||||
- libXi
|
||||
- libxkbcommon-x11
|
||||
- libglvnd-egl
|
||||
- vulkan-loader
|
||||
- id: headless
|
||||
ids: [headless]
|
||||
package_name: midi-harbor-headless
|
||||
file_name_template: "{{ .ConventionalFileName }}"
|
||||
formats: [deb, rpm]
|
||||
vendor: Midi Harbor
|
||||
homepage: https://github.com/grmrgecko/midi-harbor
|
||||
maintainer: "{{ .Env.MIDI_HARBOR_MAINTAINER }}"
|
||||
description: *description
|
||||
license: MIT
|
||||
section: sound
|
||||
conflicts: [midi-harbor]
|
||||
replaces: [midi-harbor]
|
||||
contents:
|
||||
- src: LICENSE.txt
|
||||
dst: /usr/share/doc/midi-harbor-headless/copyright
|
||||
- src: docs/*.md
|
||||
dst: /usr/share/doc/midi-harbor-headless/
|
||||
overrides:
|
||||
deb:
|
||||
dependencies: ["libc6 (>= 2.35)", libasound2, libavahi-client3, libavahi-common3, libdbus-1-3]
|
||||
recommends: [avahi-daemon, bluez]
|
||||
rpm:
|
||||
dependencies: [glibc >= 2.35, alsa-lib, avahi-libs, dbus-libs]
|
||||
recommends: [avahi, bluez]
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
extra_files:
|
||||
- glob: dist/*.dmg
|
||||
|
||||
# A release takes its version from the tag, which make release checks against VERSION. A snapshot
|
||||
# has no tag, so it takes VERSION, which the Makefile passes in.
|
||||
snapshot:
|
||||
version_template: "{{ .Env.MIDI_HARBOR_VERSION }}-SNAPSHOT-{{ .ShortCommit }}"
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: grmrgecko
|
||||
name: midi-harbor
|
||||
extra_files:
|
||||
- glob: dist/*.dmg
|
||||
9
.specify/.gitignore
vendored
Normal file
9
.specify/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Machine-local Spec Kit state — not meant to be shared.
|
||||
# Managed by the Specify CLI; safe to edit (your changes are preserved on refresh).
|
||||
|
||||
# Local pointer to the current feature directory. Rewritten every time you
|
||||
# switch features, so it is per-checkout state rather than something to share.
|
||||
feature.json
|
||||
|
||||
# Per-machine extension config overrides.
|
||||
extensions/*/local-config.yml
|
||||
9
.specify/init-options.json
Normal file
9
.specify/init-options.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"ai": "claude",
|
||||
"ai_skills": true,
|
||||
"feature_numbering": "sequential",
|
||||
"here": true,
|
||||
"integration": "claude",
|
||||
"script": "sh",
|
||||
"speckit_version": "1.0.8"
|
||||
}
|
||||
15
.specify/integration.json
Normal file
15
.specify/integration.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"version": "1.0.8",
|
||||
"integration_state_schema": 1,
|
||||
"installed_integrations": [
|
||||
"claude"
|
||||
],
|
||||
"integration_settings": {
|
||||
"claude": {
|
||||
"script": "sh",
|
||||
"invoke_separator": "-"
|
||||
}
|
||||
},
|
||||
"integration": "claude",
|
||||
"default_integration": "claude"
|
||||
}
|
||||
17
.specify/integrations/claude.manifest.json
Normal file
17
.specify/integrations/claude.manifest.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"integration": "claude",
|
||||
"version": "1.0.8",
|
||||
"installed_at": "2026-09-20T14:37:20.781062+00:00",
|
||||
"files": {
|
||||
".claude/skills/speckit-analyze/SKILL.md": "3237cd827194efaf6bb06ba05ef9bc1a703a223d362f5e34d0558c5a38ae34d0",
|
||||
".claude/skills/speckit-clarify/SKILL.md": "164f49ef87cb2e922585316f5281b455a8e1940b1efd25317d517a567acae472",
|
||||
".claude/skills/speckit-constitution/SKILL.md": "60c27f3962e78edf204b2b2e7cba91a616980860b61d6bf2051dc6965d483dda",
|
||||
".claude/skills/speckit-implement/SKILL.md": "7186d59488f89dcf23abd17baed118b05c242d4f62f1de3f9f44625a2b1728f0",
|
||||
".claude/skills/speckit-converge/SKILL.md": "fac66428c93e6e4fa0559a618a620d979fd8124d567f6f79db741fce0081cb3d",
|
||||
".claude/skills/speckit-plan/SKILL.md": "9e97fc0b4ce895c70a1ea720dc0377618c23e27c83ecf4d87b6e0f6c323790a4",
|
||||
".claude/skills/speckit-checklist/SKILL.md": "5dc436df0c727e9078bf182dd385e5cb1b60ee67ebe644d3f659ec12e3b78abb",
|
||||
".claude/skills/speckit-specify/SKILL.md": "d4d93f2444355136e78bb533988aa2dd560dc4b258f4923f67b1a437e7de38f8",
|
||||
".claude/skills/speckit-tasks/SKILL.md": "d148aeda1011260c4465c5f105fa1e045a0b0224ccb3e6e0c1ec32c732cae1ae",
|
||||
".claude/skills/speckit-taskstoissues/SKILL.md": "464bbaf464a28ffa371b0452599c18e7cb5799630148d9f945e6a4d7066a1234"
|
||||
}
|
||||
}
|
||||
19
.specify/integrations/speckit.manifest.json
Normal file
19
.specify/integrations/speckit.manifest.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"integration": "speckit",
|
||||
"version": "1.0.8",
|
||||
"installed_at": "2026-09-20T14:37:20.794534+00:00",
|
||||
"files": {
|
||||
".specify/scripts/bash/common.sh": "170e91ece502b88d83c715e427473843e0b358f550e86e3ff524546df405a9ca",
|
||||
".specify/scripts/bash/setup-plan.sh": "d0bd298c026446f3c63167130d65e49e4aa4485de6bef18bcdfd57b19aa0de96",
|
||||
".specify/scripts/bash/setup-tasks.sh": "4a33dd1e6c32ddc7d570f4b538572c54069192573eed0ec654d9fb826e31a4fe",
|
||||
".specify/scripts/bash/check-prerequisites.sh": "daa377146db4fb69912f42611a7b91c5d55873b3e3e27ed33bd8d67505826344",
|
||||
".specify/scripts/bash/resolve-template.sh": "829e227096abc8bf0889889ec9f792f503ca5d395b7836a8a7eb739ee75214e7",
|
||||
".specify/scripts/bash/create-new-feature.sh": "f4244c6cf95025e5726669938e9152d4a557cc7c1e8b027a43cb4de72677da16",
|
||||
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
|
||||
".specify/templates/checklist-template.md": "856532b3cb66171c662cc16f16b31a5856e4655a8666aad1e545bbfc7f603ca1",
|
||||
".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842",
|
||||
".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e",
|
||||
".specify/templates/plan-template.md": "7e637502d41eccf0ca672496636365691fdca62ef37b27ec07fcb412dbfa90d4",
|
||||
".specify/.gitignore": "8c908410d177a1ef3d0dee16d7ad55f2ac3333df3104c4d4adee1c9b82f1dbc1"
|
||||
}
|
||||
}
|
||||
4
.specify/memory/.constitution-template.json
Normal file
4
.specify/memory/.constitution-template.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"sha256": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
|
||||
"source": "core"
|
||||
}
|
||||
233
.specify/memory/constitution.md
Normal file
233
.specify/memory/constitution.md
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
<!--
|
||||
SYNC IMPACT REPORT (temporary scratch material — remove before committing the amendment)
|
||||
|
||||
Version change: (none) → 1.0.0
|
||||
Rationale: Initial ratification. No prior version existed; the template was unfilled.
|
||||
|
||||
Modified principles: none (initial adoption)
|
||||
|
||||
Added sections:
|
||||
- Core Principles I–VII
|
||||
- Platform Support Matrix
|
||||
- Development Workflow & Quality Gates
|
||||
- Governance
|
||||
|
||||
Removed sections: none
|
||||
|
||||
Deferred items / TODOs: none. RATIFICATION_DATE set to the date of initial adoption
|
||||
(2026-09-20) per project start.
|
||||
-->
|
||||
|
||||
# Midi Harbor Constitution
|
||||
|
||||
## Core Principles
|
||||
|
||||
### I. Resilience Is The Product (NON-NEGOTIABLE)
|
||||
|
||||
Midi Harbor exists because the platform-native alternatives drop connections. Any change that
|
||||
trades away recovery behaviour for convenience is rejected by default.
|
||||
|
||||
- Every transport (virtual port, physical device, RTP-MIDI session, BLE MIDI link) MUST be
|
||||
modelled as an
|
||||
explicit state machine with a defined reconnect path from every failure state.
|
||||
- Reconnection MUST be automatic, unbounded in attempts, and use exponential backoff with
|
||||
jitter, capped at a bounded maximum interval. A link MUST never enter a terminal
|
||||
"give up" state as a result of a transient error.
|
||||
- A link MUST only stay down when the user has explicitly disabled it, or when the failure is
|
||||
provably permanent (for example a configuration conflict that requires a user decision).
|
||||
Permanent failures MUST be surfaced to the user with a specific, actionable reason.
|
||||
- MIDI state MUST be repaired across reconnects: on link recovery, the system MUST resolve
|
||||
hanging notes and resynchronise controller state rather than leaving sounding notes stuck.
|
||||
- Rationale: users adopt this tool specifically to stop babysitting MIDI connections. A
|
||||
connection that silently stays dead is a total product failure, not a degraded mode.
|
||||
|
||||
### II. Daemon Owns State, GUI Is A Client
|
||||
|
||||
All MIDI, network, and Bluetooth state lives in a headless daemon. The libcosmic GUI is a
|
||||
replaceable view over that state.
|
||||
|
||||
- The daemon MUST own every virtual port, network session, and BLE link, and MUST run
|
||||
independently of any GUI process (launchd user agent on macOS, systemd user unit on Linux, a
|
||||
Task Scheduler logon task running the daemon under its own supervisor on Windows, and in the
|
||||
sandboxed Mac App Store build a bundled helper process the app starts and supervises).
|
||||
- Closing, crashing, restarting, or never launching the GUI MUST NOT disturb any active MIDI
|
||||
connection.
|
||||
- The GUI and CLI MUST communicate with the daemon only through the published IPC contract.
|
||||
Neither MUST open MIDI, network, or Bluetooth handles directly.
|
||||
- The IPC contract MUST be versioned, and the daemon MUST reject clients whose major version
|
||||
it does not support with a clear error rather than degrading silently.
|
||||
- Rationale: self-healing that only works while a window is open is not self-healing. This
|
||||
split also makes the engine testable and scriptable without a display server.
|
||||
|
||||
### III. Real-Time Safety On The MIDI Data Path (NON-NEGOTIABLE)
|
||||
|
||||
The path that carries MIDI bytes is a real-time context and is governed by real-time rules.
|
||||
|
||||
- Code executing in a MIDI read callback, audio thread, or packet-dispatch hot path MUST NOT
|
||||
allocate, free, lock a mutex, perform I/O, log to disk, or block on any channel.
|
||||
- Communication between real-time and non-real-time contexts MUST use pre-allocated wait-free
|
||||
structures (ring buffers, lock-free queues, atomics).
|
||||
- All buffers used on the data path MUST be pre-allocated at link setup with a documented
|
||||
capacity, and overflow MUST be handled by an explicit, counted drop policy — never by
|
||||
growing a buffer in the hot path.
|
||||
- Any violation MUST be treated as a correctness bug, not a performance issue.
|
||||
- Rationale: MIDI timing defects are audible. A priority inversion or an allocation stall
|
||||
produces jitter and dropped events that users perceive directly as the product failing.
|
||||
|
||||
### IV. Platform Parity Through Explicit Abstraction
|
||||
|
||||
macOS, Linux and Windows are equal first-class targets. None is a port of another.
|
||||
|
||||
- Platform-specific code MUST sit behind a trait boundary in a dedicated platform module.
|
||||
Core logic MUST NOT contain `cfg(target_os)` branches.
|
||||
- Every platform trait MUST have an implementation for macOS (CoreMIDI, CoreBluetooth), Linux
|
||||
(ALSA sequencer, BlueZ) and Windows (WinMM with Windows MIDI Services, WinRT Bluetooth), plus an
|
||||
in-memory fake for tests.
|
||||
- A feature MUST NOT ship enabled on one platform and silently missing on another. Where a
|
||||
capability genuinely cannot exist on a platform, or is not yet built there, it MUST be declared
|
||||
in the Platform Support Matrix and reported through a capability query the UI reads at
|
||||
runtime.
|
||||
- Every supported platform MUST build and pass tests before merge.
|
||||
- Rationale: divergence compounds. Enforcing parity at the type level keeps the two targets
|
||||
from drifting into two different products.
|
||||
|
||||
### V. Protocol Correctness Over Convenience
|
||||
|
||||
Midi Harbor implements published protocols. Interoperability with other implementations is a
|
||||
hard requirement, not a goal.
|
||||
|
||||
- The RTP-MIDI implementation MUST implement the recovery journal (RFC 6295) so that MIDI
|
||||
state survives UDP packet loss. Shipping a journal-less session is prohibited.
|
||||
- The implementation MUST interoperate with Apple's Network MIDI, rtpMIDI on Windows,
|
||||
rtpmidid on Linux, and hardware RTP-MIDI endpoints. Interoperability MUST be verified, not
|
||||
assumed.
|
||||
- BLE MIDI MUST follow the standard GATT service and the specified packet encoding, including
|
||||
timestamp handling and running-status rules across packet boundaries.
|
||||
- Protocol parsers MUST treat all input from the network or from a peer device as hostile:
|
||||
no panics, no unbounded allocation, no out-of-bounds access on malformed input.
|
||||
- Rationale: a MIDI tool that only talks to itself is useless. Protocol shortcuts surface as
|
||||
field failures against peers we cannot debug.
|
||||
|
||||
### VI. Testable Without Hardware
|
||||
|
||||
The correctness of this system MUST be demonstrable on a CI runner with no MIDI interface, no
|
||||
network peer, and no Bluetooth radio.
|
||||
|
||||
- Protocol encoders/decoders, the recovery journal, timing/clock sync, and every connection
|
||||
state machine MUST be pure and deterministic, so they can be tested in isolation.
|
||||
- Time MUST be injected as a dependency. Tests MUST NOT rely on wall-clock sleeps to advance
|
||||
a state machine.
|
||||
- Tests come in two tiers. Integration tests are the primary safety net: they drive the daemon,
|
||||
the CLI and the protocols as a whole over real files and real sockets, with fakes only at the
|
||||
platform seams. Unit tests are few and cover serialization boundaries, external formats and
|
||||
facts, and non-obvious algorithms; tests of simple logic, or of what an integration test
|
||||
already proves, MUST NOT be added.
|
||||
- Every resilience behaviour claimed in Principle I MUST have an integration test that induces
|
||||
the failure (dropped packets, peer timeout, device removal, daemon restart) and asserts
|
||||
recovery.
|
||||
- Wire-format code MUST be covered by round-trip property tests and by fuzzing on parsers.
|
||||
- Rationale: resilience claims that are only tested by hand are untested. Failure injection is
|
||||
the only way to know the healing paths actually run, and a suite padded with tests that restate
|
||||
the code slows every change without catching anything.
|
||||
|
||||
### VII. Observable By Default
|
||||
|
||||
Diagnosing a dropped connection MUST NOT require a debugger or a rebuild.
|
||||
|
||||
- Every connection MUST expose structured, queryable state: current phase, last error, uptime,
|
||||
attempt count, and counters for packets/messages sent, received, lost, and recovered.
|
||||
- Every state transition MUST emit a structured, timestamped log event with a stable event
|
||||
name and the transport identity.
|
||||
- The daemon MUST retain a bounded in-memory event history that the GUI and CLI can read, so
|
||||
users can see what happened while they were not watching.
|
||||
- Logging MUST be off the real-time path, per Principle III.
|
||||
- Rationale: the product's core promise is about behaviour over time. Users and maintainers
|
||||
need evidence of what the connection did, especially for failures that already recovered.
|
||||
|
||||
## Platform Support Matrix
|
||||
|
||||
Supported targets are macOS 11+ (Apple Silicon and x86_64), Linux (x86_64 and aarch64), and
|
||||
Windows 10 and 11 (x86_64).
|
||||
|
||||
| Capability | macOS | Linux | Windows |
|
||||
|---|---|---|---|
|
||||
| Virtual MIDI ports | CoreMIDI virtual endpoints | ALSA sequencer ports | Windows MIDI Services virtual devices (in Windows from its late-2026 update; before it, Microsoft's App SDK runtime) |
|
||||
| Physical MIDI devices | CoreMIDI endpoints | ALSA sequencer / rawmidi | WinMM |
|
||||
| RTP-MIDI sessions | Midi Harbor implementation | Midi Harbor implementation | Midi Harbor implementation |
|
||||
| Service discovery | mDNS (`_apple-midi._udp`) | mDNS (`_apple-midi._udp`) | mDNS (`_apple-midi._udp`) |
|
||||
| BLE MIDI central | CoreBluetooth | BlueZ | WinRT, through btleplug |
|
||||
| BLE MIDI peripheral | CoreBluetooth | BlueZ | Not yet built; reported unavailable |
|
||||
| Service supervision | launchd user agent; in the App Store build, a helper the app supervises | systemd user unit | Task Scheduler logon task and the daemon's own supervisor |
|
||||
|
||||
Binding constraints:
|
||||
|
||||
- Midi Harbor MUST NOT depend on Apple's IAC driver or `MIDINetworkSession`. It creates and
|
||||
owns its own endpoints and sessions. Apple's configuration MAY be read once for migration
|
||||
purposes, but MUST NOT be written to.
|
||||
- Midi Harbor MUST NOT require root or elevated privileges for any normal operation. On Windows
|
||||
before its late-2026 update, installing Microsoft's Windows MIDI Services App SDK runtime once
|
||||
is a prerequisite for virtual ports and not part of normal operation; without it the
|
||||
capability is reported unavailable.
|
||||
- Any capability that is unavailable at runtime (for example, no Bluetooth adapter present)
|
||||
MUST be reported through the capability query and rendered as unavailable in the UI, never
|
||||
as a failure.
|
||||
|
||||
## Development Workflow & Quality Gates
|
||||
|
||||
The following gates apply to every change and MUST pass before merge:
|
||||
|
||||
- `cargo fmt --check` and `cargo clippy -- -D warnings` MUST pass.
|
||||
- `make test` and `make test-integration` MUST pass on macOS, Linux and Windows.
|
||||
- New protocol or state-machine code MUST arrive with the tests the two-tier rule calls for, in
|
||||
the same change.
|
||||
- Changes touching the real-time data path MUST state in the change description how
|
||||
Principle III is upheld.
|
||||
- Public IPC contract changes MUST include a version bump and a compatibility note.
|
||||
- Unsafe code MUST be confined to platform FFI bindings, MUST carry a `// SAFETY:` comment
|
||||
stating the invariant upheld, and MUST NOT appear in core logic.
|
||||
- Dependencies MUST be justified. Prefer a small, audited dependency set given the
|
||||
privileged, always-running nature of the daemon.
|
||||
|
||||
## Governance
|
||||
|
||||
This constitution supersedes other development practices for this project. Where a plan, spec,
|
||||
or review comment conflicts with a principle here, the principle wins.
|
||||
|
||||
- **Amendment procedure**: amendments MUST be proposed as a change to this file, MUST state
|
||||
the motivating problem, and MUST record the resulting version bump and date below.
|
||||
- **Versioning policy**: MAJOR for removing or redefining a principle in a backward-
|
||||
incompatible way; MINOR for adding a principle or materially expanding guidance; PATCH for
|
||||
clarifications and wording that do not change meaning.
|
||||
- **Compliance review**: every change is reviewed against these principles. Complexity that
|
||||
violates a principle MUST be recorded in the Complexity Tracking table of the relevant plan
|
||||
with the simpler rejected alternative named. Unjustified violations block the merge.
|
||||
- **Runtime guidance**: agent-facing and contributor-facing implementation guidance, including
|
||||
the project's code style, lives in `AGENTS.md` at the repository root and MUST stay consistent
|
||||
with this document.
|
||||
|
||||
**Version**: 1.4.1 | **Ratified**: 2026-09-20 | **Last Amended**: 2026-09-27
|
||||
|
||||
Amendment 1.1.0 (2026-09-26): Windows added as a first-class target (feature
|
||||
013-windows-support). Principles II and IV and the Platform Support Matrix name its
|
||||
implementations, and the test gate covers it. The BLE MIDI peripheral role is declared not yet
|
||||
built on Windows: no Windows machine with a Bluetooth radio was available to verify it on.
|
||||
|
||||
Amendment 1.2.0 (2026-09-26): Windows virtual ports move from the teVirtualMIDI driver, which may
|
||||
not be distributed without its author's clearance, to Windows MIDI Services. Principle IV and the
|
||||
Platform Support Matrix name it, and the privilege constraint names the App SDK runtime as the
|
||||
one-time prerequisite until Windows carries the API.
|
||||
|
||||
Amendment 1.3.0 (2026-09-26): Principle VI and the quality gates adopt two test tiers. The suite
|
||||
had grown to about 900 tests, many restating the code they covered. Integration tests become the
|
||||
primary safety net and carry the resilience proofs; unit tests are limited to serialization,
|
||||
external formats and facts, and non-obvious algorithms. AGENTS.md holds the detailed rules.
|
||||
|
||||
Amendment 1.4.0 (2026-09-27): the sandboxed Mac App Store build cannot register a launchd agent,
|
||||
so Principle II and the Platform Support Matrix name a third way of running the daemon apart from
|
||||
the GUI: a helper executable in the app bundle, which the app starts, restarts after a failure and
|
||||
stops when the user quits entirely (feature 014-mac-app-store-mode, research R-095). The daemon
|
||||
stays a separate process, so a crash in the window still disturbs no connection.
|
||||
|
||||
Amendment 1.4.1 (2026-09-27): wording only. The specifications were split into one per
|
||||
capability and renumbered by topic, so the features named above are cited by their new names:
|
||||
the Windows port was 002-windows-support and the App Store mode 003-mac-app-store-mode.
|
||||
243
.specify/scripts/bash/check-prerequisites.sh
Executable file
243
.specify/scripts/bash/check-prerequisites.sh
Executable file
|
|
@ -0,0 +1,243 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Consolidated prerequisite checking script
|
||||
#
|
||||
# This script provides unified prerequisite checking for Spec-Driven Development workflow.
|
||||
# It replaces the functionality previously spread across multiple scripts.
|
||||
#
|
||||
# Usage: ./check-prerequisites.sh [OPTIONS]
|
||||
#
|
||||
# OPTIONS:
|
||||
# --json Output in JSON format
|
||||
# --require-spec Require spec.md to exist (for analysis phase)
|
||||
# --require-tasks Require tasks.md to exist (for implementation phase)
|
||||
# --include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||
# --paths-only Only output path variables (no validation)
|
||||
# --template NAME Include composed template content in JSON output
|
||||
# --help, -h Show help message
|
||||
#
|
||||
# OUTPUTS:
|
||||
# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]}
|
||||
# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n ✓/✗ file.md
|
||||
# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc.
|
||||
|
||||
set -e
|
||||
|
||||
# Parse command line arguments
|
||||
JSON_MODE=false
|
||||
REQUIRE_SPEC=false
|
||||
REQUIRE_TASKS=false
|
||||
INCLUDE_TASKS=false
|
||||
PATHS_ONLY=false
|
||||
TEMPLATE_NAME=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
--require-spec)
|
||||
REQUIRE_SPEC=true
|
||||
;;
|
||||
--require-tasks)
|
||||
REQUIRE_TASKS=true
|
||||
;;
|
||||
--include-tasks)
|
||||
INCLUDE_TASKS=true
|
||||
;;
|
||||
--paths-only)
|
||||
PATHS_ONLY=true
|
||||
;;
|
||||
--template)
|
||||
shift
|
||||
if [[ $# -eq 0 ]]; then
|
||||
echo "ERROR: --template requires a template name" >&2
|
||||
exit 1
|
||||
fi
|
||||
TEMPLATE_NAME="$1"
|
||||
;;
|
||||
--help|-h)
|
||||
cat << 'EOF'
|
||||
Usage: check-prerequisites.sh [OPTIONS]
|
||||
|
||||
Consolidated prerequisite checking for Spec-Driven Development workflow.
|
||||
|
||||
OPTIONS:
|
||||
--json Output in JSON format
|
||||
--require-spec Require spec.md to exist (for analysis phase)
|
||||
--require-tasks Require tasks.md to exist (for implementation phase)
|
||||
--include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||
--paths-only Only output path variables (no prerequisite validation)
|
||||
--template NAME Include composed template content in JSON output
|
||||
--help, -h Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
# Check task prerequisites (plan.md required)
|
||||
./check-prerequisites.sh --json
|
||||
|
||||
# Check implementation prerequisites (plan.md + tasks.md required)
|
||||
./check-prerequisites.sh --json --require-tasks --include-tasks
|
||||
|
||||
# Get feature paths only (no validation)
|
||||
./check-prerequisites.sh --paths-only
|
||||
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown option '$1'. Use --help for usage information." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Source common functions
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get feature paths.
|
||||
# In --paths-only mode this is pure resolution, so pass --no-persist to opt out
|
||||
# of the feature.json write side effect (issue #3025).
|
||||
if $PATHS_ONLY; then
|
||||
_paths_output=$(get_feature_paths --no-persist) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||
else
|
||||
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||
fi
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
|
||||
# If paths-only mode, output paths and exit (no validation)
|
||||
if $PATHS_ONLY; then
|
||||
if $JSON_MODE; then
|
||||
# Minimal JSON paths payload (no validation performed)
|
||||
if has_jq; then
|
||||
jq -cn \
|
||||
--arg repo_root "$REPO_ROOT" \
|
||||
--arg branch "$CURRENT_BRANCH" \
|
||||
--arg feature_dir "$FEATURE_DIR" \
|
||||
--arg feature_spec "$FEATURE_SPEC" \
|
||||
--arg impl_plan "$IMPL_PLAN" \
|
||||
--arg tasks "$TASKS" \
|
||||
'{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}'
|
||||
else
|
||||
printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \
|
||||
"$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")"
|
||||
fi
|
||||
else
|
||||
echo "REPO_ROOT: $REPO_ROOT"
|
||||
echo "BRANCH: $CURRENT_BRANCH"
|
||||
echo "FEATURE_DIR: $FEATURE_DIR"
|
||||
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||
echo "TASKS: $TASKS"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate required directories and files
|
||||
if [[ ! -d "$FEATURE_DIR" ]]; then
|
||||
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
|
||||
echo "Run /speckit-specify first to create the feature structure." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit-plan first to create the implementation plan." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for spec.md if required
|
||||
if $REQUIRE_SPEC && [[ ! -f "$FEATURE_SPEC" ]]; then
|
||||
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit-specify first to create the feature specification." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for tasks.md if required
|
||||
if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then
|
||||
echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit-tasks first to create the task list." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build list of available documents
|
||||
docs=()
|
||||
|
||||
# Always check these optional docs
|
||||
[[ -f "$RESEARCH" ]] && docs+=("research.md")
|
||||
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
|
||||
|
||||
# Check contracts directory (only if it exists and has files)
|
||||
if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then
|
||||
docs+=("contracts/")
|
||||
fi
|
||||
|
||||
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
|
||||
|
||||
# Include tasks.md if requested and it exists
|
||||
if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then
|
||||
docs+=("tasks.md")
|
||||
fi
|
||||
|
||||
TEMPLATE_CONTENT=""
|
||||
if [[ -n "$TEMPLATE_NAME" ]]; then
|
||||
if TEMPLATE_CONTENT=$(resolve_template_content "$TEMPLATE_NAME" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then
|
||||
TEMPLATE_CONTENT="${TEMPLATE_CONTENT%x}"
|
||||
else
|
||||
echo "ERROR: Could not resolve required $TEMPLATE_NAME from the template override stack for $REPO_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Output results
|
||||
if $JSON_MODE; then
|
||||
# Build JSON array of documents
|
||||
if has_jq; then
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
else
|
||||
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
|
||||
fi
|
||||
if [[ -n "$TEMPLATE_NAME" ]]; then
|
||||
jq -cn \
|
||||
--arg feature_dir "$FEATURE_DIR" \
|
||||
--argjson docs "$json_docs" \
|
||||
--arg template_content "$TEMPLATE_CONTENT" \
|
||||
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TEMPLATE_CONTENT:$template_content}'
|
||||
else
|
||||
jq -cn \
|
||||
--arg feature_dir "$FEATURE_DIR" \
|
||||
--argjson docs "$json_docs" \
|
||||
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}'
|
||||
fi
|
||||
else
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
else
|
||||
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
|
||||
json_docs="[${json_docs%,}]"
|
||||
fi
|
||||
if [[ -n "$TEMPLATE_NAME" ]]; then
|
||||
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TEMPLATE_CONTENT":"%s"}\n' \
|
||||
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "$TEMPLATE_CONTENT")"
|
||||
else
|
||||
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# Text output
|
||||
echo "FEATURE_DIR:$FEATURE_DIR"
|
||||
echo "AVAILABLE_DOCS:"
|
||||
|
||||
# Show status of each potential document
|
||||
check_file "$RESEARCH" "research.md"
|
||||
check_file "$DATA_MODEL" "data-model.md"
|
||||
check_dir "$CONTRACTS_DIR" "contracts/"
|
||||
check_file "$QUICKSTART" "quickstart.md"
|
||||
|
||||
if $INCLUDE_TASKS; then
|
||||
check_file "$TASKS" "tasks.md"
|
||||
fi
|
||||
fi
|
||||
926
.specify/scripts/bash/common.sh
Executable file
926
.specify/scripts/bash/common.sh
Executable file
|
|
@ -0,0 +1,926 @@
|
|||
#!/usr/bin/env bash
|
||||
# Common functions and variables for all scripts
|
||||
|
||||
# Find repository root by searching upward for .specify directory
|
||||
# This is the primary marker for spec-kit projects
|
||||
find_specify_root() {
|
||||
local dir="${1:-$(pwd)}"
|
||||
# Normalize to absolute path to prevent infinite loop with relative paths
|
||||
# Use -- to handle paths starting with - (e.g., -P, -L)
|
||||
dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1
|
||||
local prev_dir=""
|
||||
while true; do
|
||||
if [ -d "$dir/.specify" ]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
# Stop if we've reached filesystem root or dirname stops changing
|
||||
if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then
|
||||
break
|
||||
fi
|
||||
prev_dir="$dir"
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
|
||||
# *contains* .specify/), for non-interactive / CI use — e.g. running a Spec Kit
|
||||
# command against a member project from a monorepo root without cd.
|
||||
#
|
||||
# Precondition: SPECIFY_INIT_DIR is non-empty. Echoes the validated absolute
|
||||
# project root, or prints an error and returns 1. Strict by design: the path
|
||||
# must exist and contain .specify/, with no silent fallback to cwd or the
|
||||
# script-location default (which would silently write to the wrong project).
|
||||
#
|
||||
# This is the single resolver: bundled extensions inherit it by sourcing core
|
||||
# (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
|
||||
resolve_specify_init_dir() {
|
||||
local init_root
|
||||
# Normalize: relative paths resolve against $(pwd); a trailing slash collapses.
|
||||
# CDPATH="" so a relative value cannot be resolved against the caller's CDPATH
|
||||
# (which would also echo to stdout and corrupt the captured path).
|
||||
if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then
|
||||
echo "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $SPECIFY_INIT_DIR" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -d "$init_root/.specify" ]]; then
|
||||
echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $init_root" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$init_root"
|
||||
}
|
||||
|
||||
# Get repository root, prioritizing .specify directory
|
||||
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
|
||||
get_repo_root() {
|
||||
# Explicit project override wins (see resolve_specify_init_dir).
|
||||
if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then
|
||||
resolve_specify_init_dir
|
||||
return
|
||||
fi
|
||||
|
||||
# First, look for .specify directory (spec-kit's own marker)
|
||||
local specify_root
|
||||
if specify_root=$(find_specify_root); then
|
||||
echo "$specify_root"
|
||||
return
|
||||
fi
|
||||
|
||||
# Final fallback to script location
|
||||
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
(cd "$script_dir/../../.." && pwd)
|
||||
}
|
||||
|
||||
# Get current feature name from explicit state only.
|
||||
# Returns the feature identifier or empty string if none is set.
|
||||
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
|
||||
# the git extension) or implicitly via .specify/feature.json.
|
||||
get_current_branch() {
|
||||
if [[ -n "${SPECIFY_FEATURE:-}" ]]; then
|
||||
echo "$SPECIFY_FEATURE"
|
||||
return
|
||||
fi
|
||||
|
||||
# No explicit feature set — caller must handle this via feature.json
|
||||
# in get_feature_paths(). Return empty to signal "unknown".
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Safely read .specify/feature.json's "feature_directory" value.
|
||||
# Prints the raw value (possibly relative) to stdout, or empty string if the file
|
||||
# is missing, unparseable, or does not contain the key. Always returns 0 so callers
|
||||
# under `set -e` cannot be aborted by parser failure.
|
||||
# Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed.
|
||||
read_feature_json_feature_directory() {
|
||||
local repo_root="$1"
|
||||
local fj="$repo_root/.specify/feature.json"
|
||||
[[ -f "$fj" ]] || { printf '%s' ''; return 0; }
|
||||
|
||||
# Try parsers in order (jq -> python3 -> grep/sed), falling through on
|
||||
# failure. Selection is by *parse success*, not mere availability: on
|
||||
# Windows `python3` commonly resolves to the Microsoft Store App Execution
|
||||
# Alias stub, which passes `command -v` but fails at runtime (exit 49), so
|
||||
# an availability-gated `elif` would pick python3, swallow its failure, and
|
||||
# never reach the grep/sed fallback -- leaving feature.json unreadable even
|
||||
# though it is valid (issue #3304).
|
||||
local _fd=''
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then
|
||||
_fd=''
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$_fd" ]] && command -v python3 >/dev/null 2>&1; then
|
||||
# Use Python so pretty-printed/multi-line JSON still parses correctly.
|
||||
if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then
|
||||
_fd=''
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$_fd" ]]; then
|
||||
# Last-resort single-line grep/sed fallback. The `|| true` guards against
|
||||
# grep returning 1 (no match) aborting under `set -e` / `pipefail`.
|
||||
_fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \
|
||||
| head -n 1 \
|
||||
| sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' )
|
||||
fi
|
||||
|
||||
printf '%s' "$_fd"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Persist a feature_directory value to .specify/feature.json.
|
||||
# Writes only when the file is missing or the value differs from what's stored.
|
||||
# Accepts the raw (possibly relative) path — callers should pass the original
|
||||
# user-supplied value, not the normalized absolute path.
|
||||
_persist_feature_json() {
|
||||
local repo_root="$1"
|
||||
local feature_dir_value="$2"
|
||||
local fj="$repo_root/.specify/feature.json"
|
||||
|
||||
# Strip repo_root prefix if the value is absolute and under repo_root
|
||||
if [[ "$feature_dir_value" == "$repo_root/"* ]]; then
|
||||
feature_dir_value="${feature_dir_value#"$repo_root/"}"
|
||||
fi
|
||||
|
||||
# Read current value (if any) and skip write when unchanged
|
||||
local current_val
|
||||
current_val=$(read_feature_json_feature_directory "$repo_root")
|
||||
if [[ "$current_val" == "$feature_dir_value" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Ensure .specify/ directory exists
|
||||
mkdir -p "$repo_root/.specify"
|
||||
|
||||
# Write feature.json — prefer jq for safe JSON, fall back to printf
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -cn --arg fd "$feature_dir_value" '{feature_directory:$fd}' > "$fj"
|
||||
else
|
||||
printf '{"feature_directory":"%s"}\n' "$(json_escape "$feature_dir_value")" > "$fj"
|
||||
fi
|
||||
}
|
||||
|
||||
get_feature_paths() {
|
||||
# Read-only callers (e.g. check-prerequisites.sh --paths-only) pass
|
||||
# --no-persist so pure path resolution never writes .specify/feature.json,
|
||||
# which would dirty the working tree or overwrite a pinned value (issue #3025).
|
||||
local no_persist=false
|
||||
if [[ "${1:-}" == "--no-persist" ]]; then
|
||||
no_persist=true
|
||||
shift
|
||||
fi
|
||||
|
||||
# Split decl/assignment so a SPECIFY_INIT_DIR validation failure in
|
||||
# get_repo_root propagates as a hard error instead of being masked by `local`.
|
||||
local repo_root
|
||||
repo_root=$(get_repo_root) || return 1
|
||||
local current_branch
|
||||
current_branch=$(get_current_branch)
|
||||
|
||||
# Resolve feature directory. Priority:
|
||||
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by specify command)
|
||||
# 3. Error — no feature context available
|
||||
local feature_dir
|
||||
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
|
||||
feature_dir="$SPECIFY_FEATURE_DIRECTORY"
|
||||
# Normalize relative paths to absolute under repo root
|
||||
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||
# Persist to feature.json so future sessions without the env var still
|
||||
# work — unless the caller opted out for read-only resolution (#3025).
|
||||
if [[ "$no_persist" != true ]]; then
|
||||
_persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY"
|
||||
fi
|
||||
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
|
||||
local _fd
|
||||
_fd=$(read_feature_json_feature_directory "$repo_root")
|
||||
if [[ -n "$_fd" ]]; then
|
||||
feature_dir="$_fd"
|
||||
# Normalize relative paths to absolute under repo root
|
||||
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||
else
|
||||
echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." >&2
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# When no branch context exists (no SPECIFY_FEATURE, feature resolved via
|
||||
# SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature
|
||||
# directory basename so CURRENT_BRANCH is a usable identifier rather than
|
||||
# an empty, misleading value (issue #3026).
|
||||
if [[ -z "$current_branch" ]]; then
|
||||
local feature_dir_trimmed="${feature_dir%/}"
|
||||
current_branch="${feature_dir_trimmed##*/}"
|
||||
fi
|
||||
|
||||
# Use printf '%q' to safely quote values, preventing shell injection
|
||||
# via crafted branch names or paths containing special characters
|
||||
printf 'REPO_ROOT=%q\n' "$repo_root"
|
||||
printf 'CURRENT_BRANCH=%q\n' "$current_branch"
|
||||
printf 'FEATURE_DIR=%q\n' "$feature_dir"
|
||||
printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md"
|
||||
printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md"
|
||||
printf 'TASKS=%q\n' "$feature_dir/tasks.md"
|
||||
printf 'RESEARCH=%q\n' "$feature_dir/research.md"
|
||||
printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md"
|
||||
printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md"
|
||||
printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts"
|
||||
}
|
||||
|
||||
# Check if jq is available for safe JSON construction
|
||||
has_jq() {
|
||||
command -v jq >/dev/null 2>&1
|
||||
}
|
||||
|
||||
get_invoke_separator() {
|
||||
local repo_root="${1:-$(get_repo_root)}"
|
||||
if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then
|
||||
printf '%s\n' "$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local integration_json="$repo_root/.specify/integration.json"
|
||||
local separator="."
|
||||
local parsed=0
|
||||
|
||||
if [[ -f "$integration_json" ]]; then
|
||||
# Try parsers in order (jq -> python3 -> awk), falling through on
|
||||
# failure. Selection is by *parse success*, not mere availability: on
|
||||
# Windows `python3` commonly resolves to the Microsoft Store App
|
||||
# Execution Alias stub, which passes `command -v` but fails at runtime
|
||||
# (exit 49). An availability-gated branch would pick python3, swallow
|
||||
# its failure, and — because this function historically had no text
|
||||
# fallback — silently return "." even for `-`-separator integrations
|
||||
# (e.g. forge, cline), yielding wrong command hints (issue #3304).
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
local jq_separator
|
||||
if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then
|
||||
case "$jq_separator" in
|
||||
"."|"-") separator="$jq_separator"; parsed=1 ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$parsed" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then
|
||||
local py_separator
|
||||
if py_separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as fh:
|
||||
state = json.load(fh)
|
||||
key = state.get("default_integration") or state.get("integration") or ""
|
||||
settings = state.get("integration_settings")
|
||||
separator = "."
|
||||
if isinstance(key, str) and isinstance(settings, dict):
|
||||
entry = settings.get(key)
|
||||
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
|
||||
separator = entry["invoke_separator"]
|
||||
print(separator)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
PY
|
||||
); then
|
||||
case "$py_separator" in
|
||||
"."|"-") separator="$py_separator"; parsed=1 ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$parsed" -eq 0 ]]; then
|
||||
# Last-resort text fallback for environments with neither jq nor a
|
||||
# working python3 (e.g. stock Windows + Git Bash). Reads the active
|
||||
# integration key (default_integration, else integration) and its
|
||||
# invoke_separator from within the integration_settings object.
|
||||
# Handles both pretty-printed (the written form) and compact JSON.
|
||||
# Accumulate all lines into one buffer in END rather than using
|
||||
# gawk-only whole-file slurp (RS="^$"), so this stays portable to
|
||||
# the BSD awk on macOS.
|
||||
local awk_separator
|
||||
awk_separator=$(awk '
|
||||
function keyval(d, name, v) {
|
||||
if (match(d, "\"" name "\"[ \t\r\n]*:[ \t\r\n]*\"[^\"]*\"")) {
|
||||
v=substr(d,RSTART,RLENGTH); sub(/^.*:[ \t\r\n]*"/,"",v); sub(/"$/,"",v); return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
{ doc = doc $0 "\n" }
|
||||
END {
|
||||
key=keyval(doc,"default_integration"); if (key=="") key=keyval(doc,"integration")
|
||||
sep="."
|
||||
if (key!="") {
|
||||
settings=doc
|
||||
if (match(doc, /"integration_settings"[ \t\r\n]*:[ \t\r\n]*[{]/)) {
|
||||
settings=substr(doc, RSTART+RLENGTH-1)
|
||||
}
|
||||
if (match(settings, "\"" key "\"[ \t\r\n]*:[ \t\r\n]*[{]")) {
|
||||
start=RSTART+RLENGTH-1
|
||||
depth=0
|
||||
obj=""
|
||||
for (i=start; i<=length(settings); i++) {
|
||||
c=substr(settings,i,1)
|
||||
obj=obj c
|
||||
if (c=="{") depth++
|
||||
else if (c=="}") { depth--; if (depth==0) break }
|
||||
}
|
||||
if (match(obj, /"invoke_separator"[ \t\r\n]*:[ \t\r\n]*"[-.]"/)) {
|
||||
tok=substr(obj,RSTART,RLENGTH); s=substr(tok,length(tok)-1,1)
|
||||
if (s=="." || s=="-") sep=s
|
||||
}
|
||||
}
|
||||
}
|
||||
print sep
|
||||
}
|
||||
' "$integration_json" 2>/dev/null)
|
||||
case "$awk_separator" in
|
||||
"."|"-") separator="$awk_separator" ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
|
||||
_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator"
|
||||
printf '%s\n' "$separator"
|
||||
}
|
||||
|
||||
format_speckit_command() {
|
||||
local command_name="$1"
|
||||
local repo_root="${2:-$(get_repo_root)}"
|
||||
local separator
|
||||
if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then
|
||||
separator="$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE"
|
||||
else
|
||||
separator=$(get_invoke_separator "$repo_root")
|
||||
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
|
||||
_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator"
|
||||
fi
|
||||
|
||||
command_name="${command_name#/}"
|
||||
command_name="${command_name#speckit.}"
|
||||
command_name="${command_name#speckit-}"
|
||||
command_name="${command_name//./$separator}"
|
||||
|
||||
printf '/speckit%s%s\n' "$separator" "$command_name"
|
||||
}
|
||||
|
||||
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
|
||||
# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259).
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
s="${s//$'\r'/\\r}"
|
||||
s="${s//$'\b'/\\b}"
|
||||
s="${s//$'\f'/\\f}"
|
||||
# Escape any remaining U+0001-U+001F control characters as \uXXXX.
|
||||
# (U+0000/NUL cannot appear in bash strings and is excluded.)
|
||||
# LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes,
|
||||
# so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact.
|
||||
local LC_ALL=C
|
||||
local i char code
|
||||
for (( i=0; i<${#s}; i++ )); do
|
||||
char="${s:$i:1}"
|
||||
printf -v code '%d' "'$char" 2>/dev/null || code=256
|
||||
if (( code >= 1 && code <= 31 )); then
|
||||
printf '\\u%04x' "$code"
|
||||
else
|
||||
printf '%s' "$char"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||
|
||||
_python3_command() {
|
||||
if command -v python3 >/dev/null 2>&1 &&
|
||||
python3 -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then
|
||||
printf '%s\n' "python3"
|
||||
elif command -v python >/dev/null 2>&1 &&
|
||||
python -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then
|
||||
printf '%s\n' "python"
|
||||
elif command -v py >/dev/null 2>&1 &&
|
||||
py -3 -c 'import sys' >/dev/null 2>&1; then
|
||||
printf '%s\n' "py -3"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
_sorted_extension_ids() {
|
||||
local ext_dir="$1"
|
||||
local python_spec
|
||||
if python_spec=$(_python3_command); then
|
||||
local -a python_cmd
|
||||
read -r -a python_cmd <<< "$python_spec"
|
||||
local py_stderr sorted_ids
|
||||
py_stderr=$(mktemp)
|
||||
if sorted_ids=$(SPECKIT_EXTENSIONS="$ext_dir" "${python_cmd[@]}" -c "
|
||||
import json, os, re, sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(os.environ['SPECKIT_EXTENSIONS'])
|
||||
registered = {}
|
||||
registry = root / '.registry'
|
||||
if os.path.lexists(registry):
|
||||
if not registry.is_file():
|
||||
print('registry_invalid: not a regular file', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
data = json.loads(registry.read_text(encoding='utf-8'))
|
||||
except Exception as exc:
|
||||
print('registry_invalid: ' + str(exc), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not isinstance(data, dict):
|
||||
print('registry_invalid: root must be a mapping', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raw_extensions = data.get('extensions', {})
|
||||
if not isinstance(raw_extensions, dict):
|
||||
print('registry_invalid: extensions must be a mapping', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
registered = raw_extensions
|
||||
|
||||
def priority(value):
|
||||
if isinstance(value, bool):
|
||||
return 10
|
||||
try:
|
||||
parsed = int(value)
|
||||
return parsed if parsed >= 1 else 10
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return 10
|
||||
|
||||
ranked = []
|
||||
for ext_id, meta in registered.items():
|
||||
if isinstance(ext_id, str) and re.fullmatch(r'[a-z0-9-]+', ext_id) and isinstance(meta, dict) and bool(meta.get('enabled', True)):
|
||||
ranked.append((priority(meta.get('priority')), ext_id))
|
||||
for path in root.iterdir():
|
||||
if path.is_dir() and re.fullmatch(r'[a-z0-9-]+', path.name) and path.name not in registered:
|
||||
ranked.append((10, path.name))
|
||||
for _, ext_id in sorted(ranked):
|
||||
print(ext_id)
|
||||
" 2>"$py_stderr"); then
|
||||
rm -f "$py_stderr"
|
||||
printf '%s\n' "$sorted_ids"
|
||||
return 0
|
||||
else
|
||||
echo "Error: invalid extension registry $ext_dir/.registry" >&2
|
||||
rm -f "$py_stderr"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -e "$ext_dir/.registry" ] || [ -L "$ext_dir/.registry" ]; then
|
||||
if [ ! -f "$ext_dir/.registry" ] || [ ! -r "$ext_dir/.registry" ]; then
|
||||
echo "Error: invalid extension registry $ext_dir/.registry" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Error: Python 3 is required to honor the extension registry" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
local ext extension_id
|
||||
for ext in "$ext_dir"/*/; do
|
||||
[ -d "$ext" ] || continue
|
||||
extension_id=$(basename "$ext")
|
||||
case "$extension_id" in *[!a-z0-9-]*) continue ;; esac
|
||||
printf '%s\n' "$extension_id"
|
||||
done
|
||||
}
|
||||
|
||||
# Resolve a template name to a file path using the priority stack:
|
||||
# 1. .specify/templates/overrides/
|
||||
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
|
||||
# 3. .specify/extensions/<ext-id>/templates/
|
||||
# 4. .specify/templates/ (core)
|
||||
resolve_template() {
|
||||
local template_name="$1"
|
||||
local repo_root="$2"
|
||||
local base="$repo_root/.specify/templates"
|
||||
|
||||
case "$template_name" in ""|*[!a-z0-9-]*) return 1 ;; esac
|
||||
|
||||
# Priority 1: Project overrides
|
||||
local override="$base/overrides/${template_name}.md"
|
||||
[ -f "$override" ] && echo "$override" && return 0
|
||||
|
||||
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||
local presets_dir="$repo_root/.specify/presets"
|
||||
if [ -d "$presets_dir" ]; then
|
||||
local registry_file="$presets_dir/.registry"
|
||||
local python_spec=""
|
||||
local -a python_cmd=()
|
||||
if python_spec=$(_python3_command); then
|
||||
read -r -a python_cmd <<< "$python_spec"
|
||||
fi
|
||||
if [ -f "$registry_file" ] && [ "${#python_cmd[@]}" -gt 0 ]; then
|
||||
# Read preset IDs sorted by priority (lower number = higher precedence).
|
||||
# The python3 call is wrapped in an if-condition so that set -e does not
|
||||
# abort the function when python3 exits non-zero (e.g. invalid JSON).
|
||||
local sorted_presets=""
|
||||
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" "${python_cmd[@]}" -c "
|
||||
import json, re, sys, os
|
||||
try:
|
||||
with open(os.environ['SPECKIT_REGISTRY'], encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
presets = data.get('presets', {})
|
||||
def priority(meta):
|
||||
if not isinstance(meta, dict) or isinstance(meta.get('priority'), bool):
|
||||
return 10
|
||||
try:
|
||||
value = int(meta.get('priority', 10))
|
||||
return value if value >= 1 else 10
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return 10
|
||||
for pid, meta in sorted(presets.items(), key=lambda x: (priority(x[1]), x[0])):
|
||||
if isinstance(meta, dict) and bool(meta.get('enabled', True)) and re.fullmatch(r'[a-z0-9-]+', pid):
|
||||
print(pid)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
" 2>/dev/null); then
|
||||
if [ -n "$sorted_presets" ]; then
|
||||
# python3 succeeded and returned preset IDs — search in priority order
|
||||
while IFS= read -r preset_id; do
|
||||
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
candidate="$presets_dir/$preset_id/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done <<< "$sorted_presets"
|
||||
fi
|
||||
# python3 succeeded but registry has no presets — nothing to search
|
||||
else
|
||||
# python3 failed (missing, or registry parse error) — fall back to unordered directory scan
|
||||
for preset in "$presets_dir"/*/; do
|
||||
[ -d "$preset" ] || continue
|
||||
local candidate="$preset/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
candidate="$preset/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done
|
||||
fi
|
||||
else
|
||||
# Fallback: alphabetical directory order (no python3 available)
|
||||
for preset in "$presets_dir"/*/; do
|
||||
[ -d "$preset" ] || continue
|
||||
local candidate="$preset/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
candidate="$preset/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# Priority 3: Extension-provided templates
|
||||
local ext_dir="$repo_root/.specify/extensions"
|
||||
if [ -d "$ext_dir" ]; then
|
||||
local sorted_extensions=""
|
||||
if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then
|
||||
return 2
|
||||
fi
|
||||
while IFS= read -r extension_id; do
|
||||
[ -n "$extension_id" ] || continue
|
||||
local ext="$ext_dir/$extension_id"
|
||||
local candidate="$ext/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] || candidate="$ext/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done <<< "$sorted_extensions"
|
||||
fi
|
||||
|
||||
# Priority 4: Core templates
|
||||
local core="$base/${template_name}.md"
|
||||
[ -f "$core" ] && echo "$core" && return 0
|
||||
|
||||
# Template not found in any location.
|
||||
# Return 1 so callers can distinguish "not found" from "found".
|
||||
# Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve a template name to composed content using composition strategies.
|
||||
# Reads strategy metadata from preset manifests and composes content
|
||||
# from multiple layers using prepend, append, or wrap strategies.
|
||||
#
|
||||
# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT")
|
||||
# Returns composed content string on stdout; exit code 1 if not found.
|
||||
resolve_template_content() {
|
||||
local template_name="$1"
|
||||
local repo_root="$2"
|
||||
local base="$repo_root/.specify/templates"
|
||||
|
||||
case "$template_name" in ""|*[!a-z0-9-]*) return 1 ;; esac
|
||||
|
||||
# Collect all layers (highest priority first)
|
||||
local -a layer_paths=()
|
||||
local -a layer_strategies=()
|
||||
|
||||
# Priority 1: Project overrides (always "replace")
|
||||
local override="$base/overrides/${template_name}.md"
|
||||
if [ -f "$override" ]; then
|
||||
if ! cat "$override"; then
|
||||
echo "Error: failed to read template layer $override" >&2
|
||||
return 2
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
local effective_base_found=false
|
||||
|
||||
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||
local presets_dir="$repo_root/.specify/presets"
|
||||
if [ -d "$presets_dir" ]; then
|
||||
local registry_file="$presets_dir/.registry"
|
||||
local sorted_presets=""
|
||||
local registry_parsed=false
|
||||
local python_spec=""
|
||||
local -a python_cmd=()
|
||||
if python_spec=$(_python3_command); then
|
||||
read -r -a python_cmd <<< "$python_spec"
|
||||
fi
|
||||
if [ -f "$registry_file" ] && [ "${#python_cmd[@]}" -gt 0 ]; then
|
||||
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" "${python_cmd[@]}" -c "
|
||||
import json, re, sys, os
|
||||
try:
|
||||
with open(os.environ['SPECKIT_REGISTRY'], encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
presets = data.get('presets', {})
|
||||
def priority(meta):
|
||||
if not isinstance(meta, dict) or isinstance(meta.get('priority'), bool):
|
||||
return 10
|
||||
try:
|
||||
value = int(meta.get('priority', 10))
|
||||
return value if value >= 1 else 10
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return 10
|
||||
for pid, meta in sorted(presets.items(), key=lambda x: (priority(x[1]), x[0])):
|
||||
if isinstance(meta, dict) and bool(meta.get('enabled', True)) and re.fullmatch(r'[a-z0-9-]+', pid):
|
||||
print(pid)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
" 2>/dev/null); then
|
||||
registry_parsed=true
|
||||
fi
|
||||
fi
|
||||
if [ "$registry_parsed" = false ]; then
|
||||
for preset in "$presets_dir"/*/; do
|
||||
[ -d "$preset" ] || continue
|
||||
local fallback_id
|
||||
fallback_id=$(basename "$preset")
|
||||
case "$fallback_id" in *[!a-z0-9-]*) continue ;; esac
|
||||
sorted_presets+="${sorted_presets:+$'\n'}$fallback_id"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "$sorted_presets" ]; then
|
||||
while IFS= read -r preset_id; do
|
||||
local strategy="replace"
|
||||
local manifest_file=""
|
||||
local manifest="$presets_dir/$preset_id/preset.yml"
|
||||
local manifest_declared=false
|
||||
if [ -f "$manifest" ]; then
|
||||
if [ "${#python_cmd[@]}" -eq 0 ]; then
|
||||
echo "Error: Python 3 and PyYAML are required to resolve preset template composition" >&2
|
||||
return 2
|
||||
fi
|
||||
local result
|
||||
local py_stderr
|
||||
local parse_status
|
||||
py_stderr=$(mktemp)
|
||||
if result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" "${python_cmd[@]}" -c "
|
||||
import sys, os
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print('yaml_missing', file=sys.stderr)
|
||||
sys.exit(2)
|
||||
try:
|
||||
with open(os.environ['SPECKIT_MANIFEST'], encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError('manifest root must be a mapping')
|
||||
if 'provides' not in data:
|
||||
raise ValueError('manifest missing provides section')
|
||||
provides = data['provides']
|
||||
if not isinstance(provides, dict):
|
||||
raise ValueError('manifest provides must be a mapping')
|
||||
if 'templates' not in provides:
|
||||
raise ValueError('manifest provides missing templates')
|
||||
templates = provides['templates']
|
||||
if not isinstance(templates, list):
|
||||
raise ValueError('manifest templates must be a list')
|
||||
if not templates:
|
||||
raise ValueError('manifest must provide at least one template')
|
||||
valid_types = ('template', 'command', 'script')
|
||||
valid_strategies = ('replace', 'prepend', 'append', 'wrap')
|
||||
for t in templates:
|
||||
if not isinstance(t, dict):
|
||||
raise ValueError('manifest template entries must be mappings')
|
||||
if 'type' not in t or 'name' not in t or 'file' not in t:
|
||||
raise ValueError('manifest template entry missing type, name, or file')
|
||||
for field in ('type', 'name', 'file'):
|
||||
if not isinstance(t[field], str):
|
||||
raise ValueError('manifest template ' + field + ' must be a string')
|
||||
if t['type'] not in valid_types:
|
||||
raise ValueError('invalid manifest template type')
|
||||
strategy = t.get('strategy', 'replace')
|
||||
if not isinstance(strategy, str):
|
||||
raise ValueError('manifest template strategy must be a string')
|
||||
strategy = strategy.lower()
|
||||
if strategy not in valid_strategies:
|
||||
raise ValueError('invalid manifest template strategy')
|
||||
if t['type'] == 'script' and strategy not in ('replace', 'wrap'):
|
||||
raise ValueError('invalid manifest script strategy')
|
||||
for t in templates:
|
||||
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
|
||||
file_value = t.get('file', '')
|
||||
strategy = t.get('strategy', 'replace')
|
||||
print('found\t' + strategy + '\t' + file_value)
|
||||
sys.exit(0)
|
||||
print('absent\treplace\t')
|
||||
except Exception as exc:
|
||||
print(f'manifest_invalid: {exc}', file=sys.stderr)
|
||||
sys.exit(3)
|
||||
" 2>"$py_stderr"); then
|
||||
parse_status=0
|
||||
else
|
||||
parse_status=$?
|
||||
fi
|
||||
if [ "$parse_status" -ne 0 ]; then
|
||||
if [ "$parse_status" -eq 2 ]; then
|
||||
echo "Error: PyYAML is required to resolve preset template composition" >&2
|
||||
else
|
||||
echo "Error: invalid preset manifest $manifest" >&2
|
||||
fi
|
||||
rm -f "$py_stderr"
|
||||
return 2
|
||||
fi
|
||||
if [ -n "$result" ]; then
|
||||
local declaration
|
||||
IFS=$'\t' read -r declaration strategy manifest_file <<< "$result"
|
||||
[ "$declaration" = "found" ] && manifest_declared=true
|
||||
strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
|
||||
fi
|
||||
rm -f "$py_stderr"
|
||||
fi
|
||||
|
||||
local candidate=""
|
||||
if [ -n "$manifest_file" ]; then
|
||||
case "$manifest_file" in
|
||||
/*|*../*|../*) manifest_file="" ;;
|
||||
esac
|
||||
fi
|
||||
if [ -n "$manifest_file" ]; then
|
||||
local mf="$presets_dir/$preset_id/$manifest_file"
|
||||
[ -f "$mf" ] && candidate="$mf"
|
||||
fi
|
||||
if [ -z "$candidate" ] && [ "$manifest_declared" = false ]; then
|
||||
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
|
||||
[ -f "$cf" ] && candidate="$cf"
|
||||
if [ -z "$candidate" ]; then
|
||||
cf="$presets_dir/$preset_id/${template_name}.md"
|
||||
[ -f "$cf" ] && candidate="$cf"
|
||||
fi
|
||||
fi
|
||||
if [ -n "$candidate" ]; then
|
||||
layer_paths+=("$candidate")
|
||||
layer_strategies+=("$strategy")
|
||||
if [ "$strategy" = "replace" ]; then
|
||||
effective_base_found=true
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done <<< "$sorted_presets"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Priority 3: Extension-provided templates (always "replace")
|
||||
local ext_dir="$repo_root/.specify/extensions"
|
||||
if [ "$effective_base_found" = false ] && [ -d "$ext_dir" ]; then
|
||||
local sorted_extensions=""
|
||||
if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then
|
||||
return 2
|
||||
fi
|
||||
while IFS= read -r extension_id; do
|
||||
[ -n "$extension_id" ] || continue
|
||||
local ext="$ext_dir/$extension_id"
|
||||
local candidate="$ext/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] || candidate="$ext/${template_name}.md"
|
||||
if [ -f "$candidate" ]; then
|
||||
layer_paths+=("$candidate")
|
||||
layer_strategies+=("replace")
|
||||
effective_base_found=true
|
||||
break
|
||||
fi
|
||||
done <<< "$sorted_extensions"
|
||||
fi
|
||||
|
||||
# Priority 4: Core templates (always "replace")
|
||||
local core="$base/${template_name}.md"
|
||||
if [ "$effective_base_found" = false ] && [ -f "$core" ]; then
|
||||
layer_paths+=("$core")
|
||||
layer_strategies+=("replace")
|
||||
fi
|
||||
|
||||
local count=${#layer_paths[@]}
|
||||
[ "$count" -eq 0 ] && return 1
|
||||
|
||||
# Check if any layer uses a non-replace strategy
|
||||
local has_composition=false
|
||||
for s in "${layer_strategies[@]}"; do
|
||||
[ "$s" != "replace" ] && has_composition=true && break
|
||||
done
|
||||
|
||||
# If the top (highest-priority) layer is replace, it wins entirely —
|
||||
# lower layers are irrelevant regardless of their strategies.
|
||||
if [ "${layer_strategies[0]}" = "replace" ]; then
|
||||
if ! cat "${layer_paths[0]}"; then
|
||||
echo "Error: failed to read template layer ${layer_paths[0]}" >&2
|
||||
return 2
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$has_composition" = false ]; then
|
||||
if ! cat "${layer_paths[0]}"; then
|
||||
echo "Error: failed to read template layer ${layer_paths[0]}" >&2
|
||||
return 2
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Find the effective base: scan from highest priority (index 0) downward
|
||||
# to find the nearest replace layer. Only compose layers above that base.
|
||||
local base_idx=-1
|
||||
local i
|
||||
for (( i=0; i<count; i++ )); do
|
||||
if [ "${layer_strategies[$i]}" = "replace" ]; then
|
||||
base_idx=$i
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $base_idx -lt 0 ]; then
|
||||
echo "Error: template '$template_name' has composing layers but no replace base" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
# Read the base content; compose layers above the base (higher priority)
|
||||
local content
|
||||
if ! content=$(cat "${layer_paths[$base_idx]}"; status=$?; printf x; exit "$status"); then
|
||||
echo "Error: failed to read template layer ${layer_paths[$base_idx]}" >&2
|
||||
return 2
|
||||
fi
|
||||
content="${content%x}"
|
||||
|
||||
for (( i=base_idx-1; i>=0; i-- )); do
|
||||
local path="${layer_paths[$i]}"
|
||||
local strat="${layer_strategies[$i]}"
|
||||
local layer_content
|
||||
# Preserve trailing newlines
|
||||
if ! layer_content=$(cat "$path"; status=$?; printf x; exit "$status"); then
|
||||
echo "Error: failed to read template layer $path" >&2
|
||||
return 2
|
||||
fi
|
||||
layer_content="${layer_content%x}"
|
||||
|
||||
case "$strat" in
|
||||
replace) content="$layer_content" ;;
|
||||
prepend)
|
||||
content=$(printf '%s\n\n%s' "$layer_content" "$content"; printf x)
|
||||
content="${content%x}"
|
||||
;;
|
||||
append)
|
||||
content=$(printf '%s\n\n%s' "$content" "$layer_content"; printf x)
|
||||
content="${content%x}"
|
||||
;;
|
||||
wrap)
|
||||
case "$layer_content" in
|
||||
*'{CORE_TEMPLATE}'*) ;;
|
||||
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 2 ;;
|
||||
esac
|
||||
# Consume the wrapper left to right instead of rewriting it in
|
||||
# place. Rewriting re-scanned the string just modified, so base
|
||||
# content holding a literal {CORE_TEMPLATE} reintroduced the
|
||||
# token every pass and the loop never terminated. Advancing over
|
||||
# ``rest`` bounds the work by the tokens in the original wrapper
|
||||
# and leaves inserted content untouched, matching the single-pass
|
||||
# semantics of .Replace()/.replace() in the PowerShell and Python
|
||||
# ports.
|
||||
local wrapped="" rest="$layer_content"
|
||||
while [[ "$rest" == *'{CORE_TEMPLATE}'* ]]; do
|
||||
wrapped="${wrapped}${rest%%\{CORE_TEMPLATE\}*}${content}"
|
||||
rest="${rest#*\{CORE_TEMPLATE\}}"
|
||||
done
|
||||
content="${wrapped}${rest}"
|
||||
;;
|
||||
*) echo "Error: unknown strategy '$strat'" >&2; return 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
printf '%s' "$content"
|
||||
return 0
|
||||
}
|
||||
424
.specify/scripts/bash/create-new-feature.sh
Executable file
424
.specify/scripts/bash/create-new-feature.sh
Executable file
|
|
@ -0,0 +1,424 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
JSON_MODE=false
|
||||
DRY_RUN=false
|
||||
ALLOW_EXISTING=false
|
||||
SHORT_NAME=""
|
||||
BRANCH_NUMBER=""
|
||||
USE_TIMESTAMP=false
|
||||
NUMBER_EXPLICIT=false
|
||||
ARGS=()
|
||||
i=1
|
||||
while [ $i -le $# ]; do
|
||||
arg="${!i}"
|
||||
case "$arg" in
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
;;
|
||||
--allow-existing-branch)
|
||||
ALLOW_EXISTING=true
|
||||
;;
|
||||
--short-name)
|
||||
if [ $((i + 1)) -gt $# ]; then
|
||||
echo 'Error: --short-name requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
i=$((i + 1))
|
||||
next_arg="${!i}"
|
||||
# Check if the next argument is another option (starts with --)
|
||||
if [[ "$next_arg" == --* ]]; then
|
||||
echo 'Error: --short-name requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
SHORT_NAME="$next_arg"
|
||||
;;
|
||||
--number)
|
||||
if [ $((i + 1)) -gt $# ]; then
|
||||
echo 'Error: --number requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
i=$((i + 1))
|
||||
next_arg="${!i}"
|
||||
if [[ "$next_arg" == --* ]]; then
|
||||
echo 'Error: --number requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
BRANCH_NUMBER="$next_arg"
|
||||
if [ -n "$BRANCH_NUMBER" ]; then
|
||||
NUMBER_EXPLICIT=true
|
||||
fi
|
||||
;;
|
||||
--timestamp)
|
||||
USE_TIMESTAMP=true
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --json Output in JSON format"
|
||||
echo " --dry-run Compute feature name and paths without creating directories or files"
|
||||
echo " --allow-existing-branch Reuse an existing feature directory if it already exists"
|
||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the feature"
|
||||
echo " --number N Prefer a feature number (auto-corrected if its specs prefix exists)"
|
||||
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
||||
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
||||
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
ARGS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
FEATURE_DESCRIPTION="${ARGS[*]}"
|
||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Trim whitespace and validate description is not empty (e.g., user passed only whitespace)
|
||||
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MAX_FEATURE_NUMBER=9223372036854775807
|
||||
MAX_BRANCH_LENGTH=244
|
||||
|
||||
is_feature_number_in_range() {
|
||||
local value="$1"
|
||||
local normalized="${value#"${value%%[!0]*}"}"
|
||||
[ -n "$normalized" ] || normalized=0
|
||||
[ ${#normalized} -lt ${#MAX_FEATURE_NUMBER} ] && return 0
|
||||
[ ${#normalized} -gt ${#MAX_FEATURE_NUMBER} ] && return 1
|
||||
# Equal-length digit strings must be compared without arithmetic overflow.
|
||||
# shellcheck disable=SC2071
|
||||
[[ "$normalized" < "$MAX_FEATURE_NUMBER" || "$normalized" == "$MAX_FEATURE_NUMBER" ]]
|
||||
}
|
||||
|
||||
# Function to get highest number from specs directory
|
||||
get_highest_from_specs() {
|
||||
local specs_dir="$1"
|
||||
local highest=0
|
||||
|
||||
if [ -d "$specs_dir" ]; then
|
||||
for dir in "$specs_dir"/*; do
|
||||
[ -d "$dir" ] || continue
|
||||
dirname=$(basename "$dir")
|
||||
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
|
||||
if is_feature_number_in_range "$number"; then
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Return success when a spec directory owns the given numeric prefix.
|
||||
spec_prefix_exists() {
|
||||
local specs_dir="$1"
|
||||
local feature_num="$2"
|
||||
|
||||
for spec_path in "$specs_dir/${feature_num}-"*; do
|
||||
[ -d "$spec_path" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Function to clean and format a branch name
|
||||
#
|
||||
# Three details keep this byte-identical to the Python and PowerShell twins:
|
||||
# * LC_ALL=C -- in a UTF-8 locale glibc resolves the a-z *range* through
|
||||
# collation, so [^a-z0-9] keeps accented lowercase letters that
|
||||
# re.sub(r"[^a-z0-9]", ...) and .NET's -replace both strip.
|
||||
# * `--*` instead of the GNU-only `\+`, which POSIX/BSD sed reads as a literal
|
||||
# '+', leaving repeated separators uncollapsed on macOS.
|
||||
# * printf instead of echo, so a name of "-n"/"-e"/"-E" is text, not options.
|
||||
clean_branch_name() {
|
||||
local name="$1"
|
||||
local -x LC_ALL=C
|
||||
printf '%s\n' "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||
}
|
||||
|
||||
# Fit a feature prefix and suffix within GitHub's branch-name limit.
|
||||
fit_branch_name() {
|
||||
local feature_num="$1"
|
||||
local branch_suffix="$2"
|
||||
local branch_name="${feature_num}-${branch_suffix}"
|
||||
|
||||
if [ ${#branch_name} -gt $MAX_BRANCH_LENGTH ]; then
|
||||
local prefix_length=$(( ${#feature_num} + 1 ))
|
||||
local max_suffix_length=$((MAX_BRANCH_LENGTH - prefix_length))
|
||||
local truncated_suffix
|
||||
truncated_suffix=$(printf '%s' "$branch_suffix" | cut -c "1-$max_suffix_length" | sed 's/-$//')
|
||||
branch_name="${feature_num}-${truncated_suffix}"
|
||||
fi
|
||||
|
||||
printf '%s' "$branch_name"
|
||||
}
|
||||
|
||||
# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote
|
||||
# so the persistence hints match the Python variant exactly (printf %q output
|
||||
# differs between bash versions and from shlex.quote for spaces/metachars).
|
||||
shell_quote() {
|
||||
local value="$1" LC_ALL=C
|
||||
if [[ "$value" =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then
|
||||
printf '%s' "$value"
|
||||
else
|
||||
local q="'\"'\"'"
|
||||
printf "'%s'" "${value//\'/$q}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Resolve repository root using common.sh functions which prioritize .specify
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
REPO_ROOT=$(get_repo_root) || exit 1
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
SPECS_DIR="$REPO_ROOT/specs"
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
mkdir -p "$SPECS_DIR"
|
||||
fi
|
||||
|
||||
# Function to generate branch name with stop word filtering and length filtering
|
||||
generate_branch_name() {
|
||||
local description="$1"
|
||||
|
||||
# Common stop words to filter out
|
||||
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
|
||||
|
||||
# Convert to lowercase and split into words. LC_ALL=C for the same
|
||||
# collation reason documented on clean_branch_name, and so the `grep -qw`
|
||||
# acronym probe below uses ASCII word boundaries like the Python twin's
|
||||
# (?<![0-9A-Za-z_]) lookarounds.
|
||||
local -x LC_ALL=C
|
||||
local clean_name=$(printf '%s' "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
|
||||
|
||||
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
|
||||
local meaningful_words=()
|
||||
for word in $clean_name; do
|
||||
# Skip empty words
|
||||
[ -z "$word" ] && continue
|
||||
|
||||
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
|
||||
if ! echo "$word" | grep -qiE "$stop_words"; then
|
||||
if [ ${#word} -ge 3 ]; then
|
||||
meaningful_words+=("$word")
|
||||
# Keep short words that appear as an uppercase acronym in the original.
|
||||
# Uppercase via tr and match with grep -w (both portable) rather than
|
||||
# bash's 4+ "^^" case expansion (breaks on macOS bash 3.2) and \b (non-POSIX).
|
||||
elif printf '%s' "$description" | grep -qw -- "$(printf '%s' "$word" | tr '[:lower:]' '[:upper:]')"; then
|
||||
meaningful_words+=("$word")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# If we have meaningful words, use first 3-4 of them
|
||||
if [ ${#meaningful_words[@]} -gt 0 ]; then
|
||||
local max_words=3
|
||||
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
|
||||
|
||||
local result=""
|
||||
local count=0
|
||||
for word in "${meaningful_words[@]}"; do
|
||||
if [ $count -ge $max_words ]; then break; fi
|
||||
if [ -n "$result" ]; then result="$result-"; fi
|
||||
result="$result$word"
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "$result"
|
||||
else
|
||||
# Fallback to original logic if no meaningful words found
|
||||
local cleaned=$(clean_branch_name "$description")
|
||||
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
|
||||
fi
|
||||
}
|
||||
|
||||
# Generate branch name
|
||||
if [ -n "$SHORT_NAME" ]; then
|
||||
# Use provided short name, just clean it up
|
||||
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
|
||||
else
|
||||
# Generate from description with smart filtering
|
||||
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
||||
fi
|
||||
|
||||
if [ -z "$BRANCH_SUFFIX" ]; then
|
||||
echo "[specify] Warning: Feature name is empty after removing unsupported characters. Use --short-name with ASCII letters or digits (for example, user-auth)." >&2
|
||||
fi
|
||||
|
||||
# Warn if --number and --timestamp are both specified
|
||||
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
|
||||
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
|
||||
BRANCH_NUMBER=""
|
||||
fi
|
||||
|
||||
# Determine branch prefix
|
||||
if [ "$USE_TIMESTAMP" = true ]; then
|
||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
else
|
||||
if [ -n "$BRANCH_NUMBER" ] && [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||
echo "Error: --number must be an unsigned integer, got '$BRANCH_NUMBER'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Bash arithmetic is signed 64-bit; reject digit strings that would wrap.
|
||||
if [ -n "$BRANCH_NUMBER" ] && ! is_feature_number_in_range "$BRANCH_NUMBER"; then
|
||||
echo "Error: --number must be between 0 and $MAX_FEATURE_NUMBER, got '$BRANCH_NUMBER'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine branch number from existing feature directories
|
||||
if [ -z "$BRANCH_NUMBER" ]; then
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
if [ "$HIGHEST" -eq "$MAX_FEATURE_NUMBER" ]; then
|
||||
echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2
|
||||
exit 1
|
||||
fi
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
fi
|
||||
|
||||
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||
|
||||
# Treat an explicit number as a preference when its prefix is already used
|
||||
# by a feature directory. Auto-detected numbers are already conflict-free.
|
||||
if [ "$NUMBER_EXPLICIT" = true ]; then
|
||||
SPEC_CONFLICT=false
|
||||
REQUESTED_BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
|
||||
REQUESTED_DIR="$SPECS_DIR/$REQUESTED_BRANCH_NAME"
|
||||
if [ "$ALLOW_EXISTING" != true ] || [ ! -d "$REQUESTED_DIR" ]; then
|
||||
spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" && SPEC_CONFLICT=true
|
||||
fi
|
||||
|
||||
if [ "$SPEC_CONFLICT" = true ]; then
|
||||
REQUESTED_NUM="$FEATURE_NUM"
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$HIGHEST
|
||||
while true; do
|
||||
if [ "$BRANCH_NUMBER" -eq "$MAX_FEATURE_NUMBER" ]; then
|
||||
echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2
|
||||
exit 1
|
||||
fi
|
||||
BRANCH_NUMBER=$((BRANCH_NUMBER + 1))
|
||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||
spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" || break
|
||||
done
|
||||
>&2 echo "[specify] Warning: --number $REQUESTED_NUM conflicts with an existing spec directory; using $FEATURE_NUM instead"
|
||||
fi
|
||||
fi
|
||||
|
||||
fi
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names
|
||||
# Validate and truncate if necessary
|
||||
ORIGINAL_BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
|
||||
if [ "$BRANCH_NAME" != "$ORIGINAL_BRANCH_NAME" ]; then
|
||||
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
||||
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
||||
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
||||
fi
|
||||
|
||||
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
if [ -d "$FEATURE_DIR" ] && [ "$ALLOW_EXISTING" != true ]; then
|
||||
if [ "$USE_TIMESTAMP" = true ]; then
|
||||
>&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Rerun to get a new timestamp or use a different --short-name."
|
||||
else
|
||||
>&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Please use a different feature name or specify a different number with --number."
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEEDS_SPEC=false
|
||||
SPEC_TEMPLATE_FOUND=false
|
||||
SPEC_TEMPLATE_CONTENT=""
|
||||
if [ ! -f "$SPEC_FILE" ]; then
|
||||
NEEDS_SPEC=true
|
||||
if SPEC_TEMPLATE_CONTENT=$(resolve_template_content "spec-template" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then
|
||||
SPEC_TEMPLATE_CONTENT="${SPEC_TEMPLATE_CONTENT%x}"
|
||||
SPEC_TEMPLATE_FOUND=true
|
||||
else
|
||||
resolve_status=$?
|
||||
if [ "$resolve_status" -ne 1 ]; then
|
||||
exit "$resolve_status"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
if [ "$NEEDS_SPEC" = true ]; then
|
||||
if [ "$SPEC_TEMPLATE_FOUND" = true ]; then
|
||||
printf '%s' "$SPEC_TEMPLATE_CONTENT" > "$SPEC_FILE"
|
||||
else
|
||||
echo "Warning: Spec template not found; created empty spec file" >&2
|
||||
touch "$SPEC_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Persist to .specify/feature.json so downstream commands can find the feature
|
||||
_persist_feature_json "$REPO_ROOT" "$FEATURE_DIR"
|
||||
|
||||
# Inform the user how to set feature state in their own shell
|
||||
printf '# To persist: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" >&2
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" >&2
|
||||
fi
|
||||
|
||||
if $JSON_MODE; then
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
jq -cn \
|
||||
--arg branch_name "$BRANCH_NAME" \
|
||||
--arg spec_file "$SPEC_FILE" \
|
||||
--arg feature_num "$FEATURE_NUM" \
|
||||
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}'
|
||||
else
|
||||
jq -cn \
|
||||
--arg branch_name "$BRANCH_NAME" \
|
||||
--arg spec_file "$SPEC_FILE" \
|
||||
--arg feature_num "$FEATURE_NUM" \
|
||||
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
|
||||
fi
|
||||
else
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
|
||||
else
|
||||
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "BRANCH_NAME: $BRANCH_NAME"
|
||||
echo "SPEC_FILE: $SPEC_FILE"
|
||||
echo "FEATURE_NUM: $FEATURE_NUM"
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
printf '# To persist in your shell: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")"
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")"
|
||||
fi
|
||||
fi
|
||||
57
.specify/scripts/bash/resolve-template.sh
Executable file
57
.specify/scripts/bash/resolve-template.sh
Executable file
|
|
@ -0,0 +1,57 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(CDPATH="" cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
JSON_MODE=false
|
||||
TEMPLATE_NAME=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--json) JSON_MODE=true ;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 <template-name> [--json]"
|
||||
exit 0
|
||||
;;
|
||||
-*)
|
||||
echo "ERROR: Unknown option '$arg'" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
if [[ -n "$TEMPLATE_NAME" ]]; then
|
||||
echo "ERROR: Unexpected argument '$arg'" >&2
|
||||
exit 1
|
||||
fi
|
||||
TEMPLATE_NAME="$arg"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$TEMPLATE_NAME" ]]; then
|
||||
echo "ERROR: Template name is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_ROOT=$(get_repo_root)
|
||||
if TEMPLATE_CONTENT=$(resolve_template_content "$TEMPLATE_NAME" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then
|
||||
TEMPLATE_CONTENT="${TEMPLATE_CONTENT%x}"
|
||||
else
|
||||
echo "ERROR: Could not resolve required $TEMPLATE_NAME from the template override stack for $REPO_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if $JSON_MODE; then
|
||||
if has_jq; then
|
||||
jq -cn \
|
||||
--arg template_name "$TEMPLATE_NAME" \
|
||||
--arg template_content "$TEMPLATE_CONTENT" \
|
||||
'{TEMPLATE_NAME:$template_name,TEMPLATE_CONTENT:$template_content}'
|
||||
else
|
||||
printf '{"TEMPLATE_NAME":"%s","TEMPLATE_CONTENT":"%s"}\n' \
|
||||
"$(json_escape "$TEMPLATE_NAME")" "$(json_escape "$TEMPLATE_CONTENT")"
|
||||
fi
|
||||
else
|
||||
printf '%s' "$TEMPLATE_CONTENT"
|
||||
fi
|
||||
85
.specify/scripts/bash/setup-plan.sh
Executable file
85
.specify/scripts/bash/setup-plan.sh
Executable file
|
|
@ -0,0 +1,85 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
# Parse command line arguments
|
||||
JSON_MODE=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--json]"
|
||||
echo " --json Output results in JSON format"
|
||||
echo " --help Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown option '$arg'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Get script directory and load common functions
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get all paths and variables from common functions
|
||||
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
|
||||
# Ensure the feature directory exists
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
# Copy plan template if plan doesn't already exist
|
||||
if [[ -f "$IMPL_PLAN" ]]; then
|
||||
if $JSON_MODE; then
|
||||
echo "Plan already exists at $IMPL_PLAN, skipping template copy" >&2
|
||||
else
|
||||
echo "Plan already exists at $IMPL_PLAN, skipping template copy"
|
||||
fi
|
||||
else
|
||||
if resolve_template_content "plan-template" "$REPO_ROOT" > "$IMPL_PLAN"; then
|
||||
if $JSON_MODE; then
|
||||
echo "Copied plan template to $IMPL_PLAN" >&2
|
||||
else
|
||||
echo "Copied plan template to $IMPL_PLAN"
|
||||
fi
|
||||
else
|
||||
resolve_status=$?
|
||||
rm -f "$IMPL_PLAN"
|
||||
if [ "$resolve_status" -ne 1 ]; then
|
||||
exit "$resolve_status"
|
||||
fi
|
||||
if $JSON_MODE; then
|
||||
echo "Warning: Plan template not found" >&2
|
||||
else
|
||||
echo "Warning: Plan template not found"
|
||||
fi
|
||||
touch "$IMPL_PLAN"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Output results
|
||||
if $JSON_MODE; then
|
||||
if has_jq; then
|
||||
jq -cn \
|
||||
--arg feature_spec "$FEATURE_SPEC" \
|
||||
--arg impl_plan "$IMPL_PLAN" \
|
||||
--arg feature_dir "$FEATURE_DIR" \
|
||||
--arg branch "$CURRENT_BRANCH" \
|
||||
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,FEATURE_DIR:$feature_dir,BRANCH:$branch}'
|
||||
else
|
||||
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","FEATURE_DIR":"%s","BRANCH":"%s"}\n' \
|
||||
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")"
|
||||
fi
|
||||
else
|
||||
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||
echo "FEATURE_DIR: $FEATURE_DIR"
|
||||
echo "BRANCH: $CURRENT_BRANCH"
|
||||
fi
|
||||
94
.specify/scripts/bash/setup-tasks.sh
Executable file
94
.specify/scripts/bash/setup-tasks.sh
Executable file
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
# Parse command line arguments
|
||||
JSON_MODE=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--json) JSON_MODE=true ;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--json]"
|
||||
echo " --json Output results in JSON format"
|
||||
echo " --help Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Source common functions
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get feature paths
|
||||
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
|
||||
# Validate required files
|
||||
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit-plan first to create the implementation plan." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$FEATURE_SPEC" ]]; then
|
||||
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit-specify first to create the feature structure." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build available docs list
|
||||
docs=()
|
||||
[[ -f "$RESEARCH" ]] && docs+=("research.md")
|
||||
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
|
||||
if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then
|
||||
docs+=("contracts/")
|
||||
fi
|
||||
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
|
||||
|
||||
# Resolve tasks template through override stack
|
||||
TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true
|
||||
if TASKS_TEMPLATE_CONTENT=$(resolve_template_content "tasks-template" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then
|
||||
TASKS_TEMPLATE_CONTENT="${TASKS_TEMPLATE_CONTENT%x}"
|
||||
else
|
||||
echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2
|
||||
echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Output results
|
||||
if $JSON_MODE; then
|
||||
if has_jq; then
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
else
|
||||
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
|
||||
fi
|
||||
jq -cn \
|
||||
--arg feature_dir "$FEATURE_DIR" \
|
||||
--argjson docs "$json_docs" \
|
||||
--arg tasks_template "${TASKS_TEMPLATE:-}" \
|
||||
--arg tasks_template_content "$TASKS_TEMPLATE_CONTENT" \
|
||||
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template,TASKS_TEMPLATE_CONTENT:$tasks_template_content}'
|
||||
else
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
else
|
||||
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
|
||||
json_docs="[${json_docs%,}]"
|
||||
fi
|
||||
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s","TASKS_TEMPLATE_CONTENT":"%s"}\n' \
|
||||
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")" "$(json_escape "$TASKS_TEMPLATE_CONTENT")"
|
||||
fi
|
||||
else
|
||||
echo "FEATURE_DIR: $FEATURE_DIR"
|
||||
echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}"
|
||||
echo "AVAILABLE_DOCS:"
|
||||
check_file "$RESEARCH" "research.md"
|
||||
check_file "$DATA_MODEL" "data-model.md"
|
||||
check_dir "$CONTRACTS_DIR" "contracts/"
|
||||
check_file "$QUICKSTART" "quickstart.md"
|
||||
fi
|
||||
45
.specify/templates/checklist-template.md
Normal file
45
.specify/templates/checklist-template.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# [CHECKLIST TYPE] Checklist: [FEATURE NAME]
|
||||
|
||||
**Purpose**: [Brief description of what this checklist covers]
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md or relevant documentation]
|
||||
|
||||
**Note**: This custom checklist is generated by the `/speckit-checklist` command based on feature context and requirements.
|
||||
**Review Ownership**: This checklist is a reviewer-owned requirements-quality review artifact. Mark an item `[x]` only when the reviewer determines the requirements-quality criterion is satisfied.
|
||||
**Marker Semantics**: `[x]` means the criterion has been reviewed and satisfied for requirements quality. It does not mean implementation work is complete.
|
||||
|
||||
<!--
|
||||
============================================================================
|
||||
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
|
||||
|
||||
The /speckit-checklist command MUST replace these with actual items based on:
|
||||
- User's specific checklist request
|
||||
- Feature requirements from spec.md
|
||||
- Technical context from plan.md
|
||||
- Implementation details from tasks.md
|
||||
|
||||
DO NOT keep these sample items in the generated checklist file.
|
||||
============================================================================
|
||||
-->
|
||||
|
||||
## [Category 1]
|
||||
|
||||
- [ ] CHK001 First checklist item with clear action
|
||||
- [ ] CHK002 Second checklist item
|
||||
- [ ] CHK003 Third checklist item
|
||||
|
||||
## [Category 2]
|
||||
|
||||
- [ ] CHK004 Another category item
|
||||
- [ ] CHK005 Item with specific criteria
|
||||
- [ ] CHK006 Final item in this category
|
||||
|
||||
## Notes
|
||||
|
||||
- Mark items `[x]` only after review confirms the requirement-quality criterion is satisfied
|
||||
- Leave items unchecked when they still require clarification, correction, or reviewer evaluation
|
||||
- `/speckit-implement` reads checklist checkbox state as a gate and must not modify markers
|
||||
- `checklists/requirements.md` has a separate built-in lifecycle maintained by `/speckit-specify` and `/speckit-clarify`
|
||||
- Add comments or findings inline
|
||||
- Link to relevant resources or documentation
|
||||
- Items are numbered sequentially for easy reference
|
||||
50
.specify/templates/constitution-template.md
Normal file
50
.specify/templates/constitution-template.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# [PROJECT_NAME] Constitution
|
||||
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
|
||||
|
||||
## Core Principles
|
||||
|
||||
### [PRINCIPLE_1_NAME]
|
||||
<!-- Example: I. Library-First -->
|
||||
[PRINCIPLE_1_DESCRIPTION]
|
||||
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
|
||||
|
||||
### [PRINCIPLE_2_NAME]
|
||||
<!-- Example: II. CLI Interface -->
|
||||
[PRINCIPLE_2_DESCRIPTION]
|
||||
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
|
||||
|
||||
### [PRINCIPLE_3_NAME]
|
||||
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
||||
[PRINCIPLE_3_DESCRIPTION]
|
||||
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
|
||||
|
||||
### [PRINCIPLE_4_NAME]
|
||||
<!-- Example: IV. Integration Testing -->
|
||||
[PRINCIPLE_4_DESCRIPTION]
|
||||
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
||||
|
||||
### [PRINCIPLE_5_NAME]
|
||||
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
||||
[PRINCIPLE_5_DESCRIPTION]
|
||||
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
|
||||
|
||||
## [SECTION_2_NAME]
|
||||
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
|
||||
|
||||
[SECTION_2_CONTENT]
|
||||
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
|
||||
|
||||
## [SECTION_3_NAME]
|
||||
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
|
||||
|
||||
[SECTION_3_CONTENT]
|
||||
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
|
||||
|
||||
## Governance
|
||||
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
||||
|
||||
[GOVERNANCE_RULES]
|
||||
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
|
||||
|
||||
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
|
||||
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
|
||||
114
.specify/templates/plan-template.md
Normal file
114
.specify/templates/plan-template.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# Implementation Plan: [FEATURE]
|
||||
|
||||
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
||||
|
||||
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit-plan` command; its definition describes the execution workflow.
|
||||
|
||||
## Summary
|
||||
|
||||
[Extract from feature spec: primary requirement + technical approach from research]
|
||||
|
||||
## Technical Context
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the content in this section with the technical details
|
||||
for the project. The structure here is presented in advisory capacity to guide
|
||||
the iteration process.
|
||||
-->
|
||||
|
||||
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
|
||||
|
||||
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
|
||||
|
||||
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
|
||||
**Testing**: [which integration tests prove the feature, and which unit tests, if any, the
|
||||
two-tier rule calls for; e.g. `make test`, `make test-integration`, or NEEDS CLARIFICATION]
|
||||
|
||||
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
|
||||
|
||||
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
|
||||
|
||||
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
|
||||
|
||||
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
|
||||
|
||||
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
[Gates determined based on constitution file]
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/[###-feature]/
|
||||
├── plan.md # This file (/speckit-plan command output)
|
||||
├── research.md # Phase 0 output (/speckit-plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit-plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit-plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit-plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
|
||||
for this feature. Delete unused options and expand the chosen structure with
|
||||
real paths (e.g., apps/admin, packages/something). The delivered plan must
|
||||
not include Option labels.
|
||||
-->
|
||||
|
||||
```text
|
||||
# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT)
|
||||
src/
|
||||
├── models/
|
||||
├── services/
|
||||
├── cli/
|
||||
└── lib/
|
||||
|
||||
tests/
|
||||
├── contract/
|
||||
├── integration/
|
||||
└── unit/
|
||||
|
||||
# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected)
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── models/
|
||||
│ ├── services/
|
||||
│ └── api/
|
||||
└── tests/
|
||||
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ ├── pages/
|
||||
│ └── services/
|
||||
└── tests/
|
||||
|
||||
# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
||||
api/
|
||||
└── [same as backend above]
|
||||
|
||||
ios/ or android/
|
||||
└── [platform-specific structure: feature modules, UI flows, platform tests]
|
||||
```
|
||||
|
||||
**Structure Decision**: [Document the selected structure and reference the real
|
||||
directories captured above]
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||
131
.specify/templates/spec-template.md
Normal file
131
.specify/templates/spec-template.md
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
# Feature Specification: [FEATURE NAME]
|
||||
|
||||
**Feature Branch**: `[###-feature-name]`
|
||||
|
||||
**Created**: [DATE]
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "$ARGUMENTS"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
<!--
|
||||
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||
|
||||
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||
Think of each story as a standalone slice of functionality that can be:
|
||||
- Developed independently
|
||||
- Tested independently
|
||||
- Deployed independently
|
||||
- Demonstrated to users independently
|
||||
-->
|
||||
|
||||
### User Story 1 - [Brief Title] (Priority: P1)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
2. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - [Brief Title] (Priority: P2)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - [Brief Title] (Priority: P3)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
[Add more user stories as needed, each with an assigned priority]
|
||||
|
||||
### Edge Cases
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right edge cases.
|
||||
-->
|
||||
|
||||
- What happens when [boundary condition]?
|
||||
- How does system handle [error scenario]?
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right functional requirements.
|
||||
-->
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
|
||||
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
|
||||
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
|
||||
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
|
||||
- **FR-005**: System MUST [behavior, e.g., "log all security events"]
|
||||
|
||||
*Example of marking unclear requirements:*
|
||||
|
||||
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
|
||||
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **[Entity 1]**: [What it represents, key attributes without implementation]
|
||||
- **[Entity 2]**: [What it represents, relationships to other entities]
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Define measurable success criteria.
|
||||
These must be technology-agnostic and measurable.
|
||||
-->
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"]
|
||||
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
|
||||
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"]
|
||||
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]
|
||||
|
||||
## Assumptions
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right assumptions based on reasonable defaults
|
||||
chosen when the feature description did not specify certain details.
|
||||
-->
|
||||
|
||||
- [Assumption about target users, e.g., "Users have stable internet connectivity"]
|
||||
- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"]
|
||||
- [Assumption about data/environment, e.g., "Existing authentication system will be reused"]
|
||||
- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"]
|
||||
253
.specify/templates/tasks-template.md
Normal file
253
.specify/templates/tasks-template.md
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
---
|
||||
|
||||
description: "Task list template for feature implementation"
|
||||
---
|
||||
|
||||
# Tasks: [FEATURE NAME]
|
||||
|
||||
**Input**: Design documents from `/specs/[###-feature-name]/`
|
||||
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: Two tiers, per the constitution's Principle VI and AGENTS.md "Testing". Each story's
|
||||
integration test, driving the project as a whole over real files and sockets, is the primary proof
|
||||
and comes with the story. Unit test tasks appear only for serialization boundaries, external
|
||||
formats and facts, and non-obvious algorithms; never for simple logic or for what the story's
|
||||
integration test already proves.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
## Path Conventions
|
||||
|
||||
- **Single project**: `src/`, `tests/` at repository root
|
||||
- **Web app**: `backend/src/`, `frontend/src/`
|
||||
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
|
||||
- Paths shown below assume single project - adjust based on plan.md structure
|
||||
|
||||
<!--
|
||||
============================================================================
|
||||
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
|
||||
|
||||
The /speckit-tasks command MUST replace these with actual tasks based on:
|
||||
- User stories from spec.md (with their priorities P1, P2, P3...)
|
||||
- Feature requirements from plan.md
|
||||
- Entities from data-model.md
|
||||
- Endpoints from contracts/
|
||||
|
||||
Tasks MUST be organized by user story so each story can be:
|
||||
- Implemented independently
|
||||
- Tested independently
|
||||
- Delivered as an MVP increment
|
||||
|
||||
DO NOT keep these sample tasks in the generated tasks.md file.
|
||||
============================================================================
|
||||
-->
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Project initialization and basic structure
|
||||
|
||||
- [ ] T001 Create project structure per implementation plan
|
||||
- [ ] T002 Initialize [language] project with [framework] dependencies
|
||||
- [ ] T003 [P] Configure linting and formatting tools
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
Examples of foundational tasks (adjust based on your project):
|
||||
|
||||
- [ ] T004 Setup database schema and migrations framework
|
||||
- [ ] T005 [P] Implement authentication/authorization framework
|
||||
- [ ] T006 [P] Setup API routing and middleware structure
|
||||
- [ ] T007 Create base models/entities that all stories depend on
|
||||
- [ ] T008 Configure error handling and logging infrastructure
|
||||
- [ ] T009 Setup environment configuration management
|
||||
|
||||
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: [Brief description of what this story delivers]
|
||||
|
||||
**Independent Test**: [How to verify this story works on its own]
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [ ] T010 [US1] Integration test for [user journey, including the failure it recovers from] in
|
||||
crates/[crate]/tests/[name].rs
|
||||
- [ ] T011 [P] [US1] Unit test for [wire format or external fact] in crates/[crate]/src/[file].rs
|
||||
(only when the rules call for one)
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py
|
||||
- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py
|
||||
- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013)
|
||||
- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py
|
||||
- [ ] T016 [US1] Add validation and error handling
|
||||
- [ ] T017 [US1] Add logging for user story 1 operations
|
||||
|
||||
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - [Title] (Priority: P2)
|
||||
|
||||
**Goal**: [Brief description of what this story delivers]
|
||||
|
||||
**Independent Test**: [How to verify this story works on its own]
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [ ] T018 [US2] Integration test for [user journey] in crates/[crate]/tests/[name].rs
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py
|
||||
- [ ] T021 [US2] Implement [Service] in src/services/[service].py
|
||||
- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py
|
||||
- [ ] T023 [US2] Integrate with User Story 1 components (if needed)
|
||||
|
||||
**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - [Title] (Priority: P3)
|
||||
|
||||
**Goal**: [Brief description of what this story delivers]
|
||||
|
||||
**Independent Test**: [How to verify this story works on its own]
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [ ] T024 [US3] Integration test for [user journey] in crates/[crate]/tests/[name].rs
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py
|
||||
- [ ] T027 [US3] Implement [Service] in src/services/[service].py
|
||||
- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py
|
||||
|
||||
**Checkpoint**: All user stories should now be independently functional
|
||||
|
||||
---
|
||||
|
||||
[Add more user story phases as needed, following the same pattern]
|
||||
|
||||
---
|
||||
|
||||
## Phase N: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Improvements that affect multiple user stories
|
||||
|
||||
- [ ] TXXX [P] Documentation updates in docs/
|
||||
- [ ] TXXX Code cleanup and refactoring
|
||||
- [ ] TXXX Performance optimization across all stories
|
||||
- [ ] TXXX Prune tests that break the testing rules in the suites this feature touched
|
||||
- [ ] TXXX Security hardening
|
||||
- [ ] TXXX Run quickstart.md validation
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies - can start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
|
||||
- **User Stories (Phase 3+)**: All depend on Foundational phase completion
|
||||
- User stories can then proceed in parallel (if staffed)
|
||||
- Or sequentially in priority order (P1 → P2 → P3)
|
||||
- **Polish (Final Phase)**: Depends on all desired user stories being complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
|
||||
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable
|
||||
- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- A story's integration test fails without the story's change and passes with it
|
||||
- Models before services
|
||||
- Services before endpoints
|
||||
- Core implementation before integration
|
||||
- Story complete before moving to next priority
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- All Setup tasks marked [P] can run in parallel
|
||||
- All Foundational tasks marked [P] can run in parallel (within Phase 2)
|
||||
- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows)
|
||||
- Models within a story marked [P] can run in parallel
|
||||
- Different user stories can be worked on in parallel by different team members
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch the story's tests together:
|
||||
Task: "Integration test for [user journey] in crates/[crate]/tests/[name].rs"
|
||||
Task: "Unit test for [wire format] in crates/[crate]/src/[file].rs"
|
||||
|
||||
# Launch all models for User Story 1 together:
|
||||
Task: "Create [Entity1] model in src/models/[entity1].py"
|
||||
Task: "Create [Entity2] model in src/models/[entity2].py"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup
|
||||
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
|
||||
3. Complete Phase 3: User Story 1
|
||||
4. **STOP and VALIDATE**: Test User Story 1 independently
|
||||
5. Deploy/demo if ready
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Setup + Foundational → Foundation ready
|
||||
2. Add User Story 1 → Test independently → Deploy/Demo (MVP!)
|
||||
3. Add User Story 2 → Test independently → Deploy/Demo
|
||||
4. Add User Story 3 → Test independently → Deploy/Demo
|
||||
5. Each story adds value without breaking previous stories
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
With multiple developers:
|
||||
|
||||
1. Team completes Setup + Foundational together
|
||||
2. Once Foundational is done:
|
||||
- Developer A: User Story 1
|
||||
- Developer B: User Story 2
|
||||
- Developer C: User Story 3
|
||||
3. Stories complete and integrate independently
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- Each user story should be independently completable and testable
|
||||
- Verify each new test fails without the change it covers
|
||||
- Commit after each task or logical group
|
||||
- Stop at any checkpoint to validate story independently
|
||||
- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence
|
||||
74
.specify/workflows/speckit/workflow.yml
Normal file
74
.specify/workflows/speckit/workflow.yml
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "speckit"
|
||||
name: "Full SDD Cycle"
|
||||
version: "1.0.1"
|
||||
author: "GitHub"
|
||||
description: "Runs specify → plan → tasks → implement with review gates"
|
||||
|
||||
requires:
|
||||
# 0.8.5 is the first release with engine-side resolution of the
|
||||
# ``integration: "auto"`` default. Older versions would treat "auto"
|
||||
# as a literal integration key and fail at dispatch.
|
||||
speckit_version: ">=0.8.5"
|
||||
integrations:
|
||||
# The four commands below (specify, plan, tasks, implement) are core
|
||||
# spec-kit commands provided by every integration. The list here is an
|
||||
# advisory, non-exhaustive compatibility hint following the documented
|
||||
# ``any: [...]`` schema -- it is NOT a closed set. The workflow runs
|
||||
# against any integration the project was initialized with, including
|
||||
# ones not listed below, as long as that integration provides the four
|
||||
# core commands referenced in ``steps``.
|
||||
any:
|
||||
- "alquimia"
|
||||
- "claude"
|
||||
- "copilot"
|
||||
- "gemini"
|
||||
- "opencode"
|
||||
|
||||
inputs:
|
||||
spec:
|
||||
type: string
|
||||
required: true
|
||||
prompt: "Describe what you want to build"
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)"
|
||||
|
||||
steps:
|
||||
- id: specify
|
||||
command: speckit.specify
|
||||
integration: "{{ inputs.integration }}"
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
|
||||
- id: review-spec
|
||||
type: gate
|
||||
message: "Review the generated spec before planning."
|
||||
options: [approve, reject]
|
||||
on_reject: abort
|
||||
|
||||
- id: plan
|
||||
command: speckit.plan
|
||||
integration: "{{ inputs.integration }}"
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
|
||||
- id: review-plan
|
||||
type: gate
|
||||
message: "Review the plan before generating tasks."
|
||||
options: [approve, reject]
|
||||
on_reject: abort
|
||||
|
||||
- id: tasks
|
||||
command: speckit.tasks
|
||||
integration: "{{ inputs.integration }}"
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
|
||||
- id: implement
|
||||
command: speckit.implement
|
||||
integration: "{{ inputs.integration }}"
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
13
.specify/workflows/workflow-registry.json
Normal file
13
.specify/workflows/workflow-registry.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"schema_version": "1.0",
|
||||
"workflows": {
|
||||
"speckit": {
|
||||
"name": "Full SDD Cycle",
|
||||
"version": "1.0.1",
|
||||
"description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates",
|
||||
"source": "bundled",
|
||||
"installed_at": "2026-09-20T14:37:20.807096+00:00",
|
||||
"updated_at": "2026-09-20T14:37:20.807103+00:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
296
AGENTS.md
Normal file
296
AGENTS.md
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
# Midi Harbor — Agent & Contributor Guide
|
||||
|
||||
Midi Harbor manages virtual MIDI ports, physical MIDI hardware, RTP-MIDI network sessions, and
|
||||
Bluetooth LE MIDI links on macOS, Linux and Windows, with connection resilience as its defining
|
||||
value.
|
||||
|
||||
Governance lives in `.specify/memory/constitution.md` and takes precedence over this file where
|
||||
the two disagree. Specifications live in `specs/`.
|
||||
|
||||
---
|
||||
|
||||
## Code style
|
||||
|
||||
- Write pragmatic, systems-oriented Rust focused on correctness and clarity.
|
||||
- Favor explicit control flow and simple data structures over abstraction. Traits exist only where
|
||||
a real seam is needed (instrument drivers, transfer functions), not for hypothetical testability.
|
||||
- Short receiver names (`self` for most types). Internal helpers are private and idiomatic
|
||||
snake_case; exported names are clear and descriptive.
|
||||
- Return `Result` when the caller can act on the failure. Otherwise log and continue safely. Errors
|
||||
are lowercase, no trailing punctuation, and carry context about the attempted operation.
|
||||
- Library code never panics: no `unwrap`, `expect`, `panic!`, or indexing that can go out of
|
||||
bounds. Return errors or saturate deliberately. Tests may use `unwrap` and `assert_eq`.
|
||||
- Log operational detail at debug, lifecycle events (start/stop of sessions, driver open/close) at
|
||||
info, and failures at error. No exclamation marks in log lines.
|
||||
- Comments are functional and intent-focused, written for someone maintaining or debugging the
|
||||
system. The name of the element being commented is the first word, the comment is a complete
|
||||
sentence ending in a period.
|
||||
- Exported types and functions have short `///` doc comments describing responsibility.
|
||||
- Functions with multiple logical steps use short section comments to label each phase ("Validate
|
||||
input.", "Fit the response curve.").
|
||||
- No conversational language, jokes, or speculative notes. The code reads without comments;
|
||||
comments add clarity where reasoning is not obvious.
|
||||
- Spell out arithmetic constants so numbers are not magic (for example `16.0 + 219.0 * v` for
|
||||
video-range encoding).
|
||||
|
||||
---
|
||||
|
||||
## Where traits are warranted in this project
|
||||
|
||||
The style rule above says traits exist only for real seams. In Midi Harbor there are exactly four,
|
||||
and they are genuine — each has two or more independent implementations that must coexist in
|
||||
shipped code, not merely in tests:
|
||||
|
||||
| Seam | Implementations |
|
||||
|---|---|
|
||||
| Platform MIDI | CoreMIDI (macOS), ALSA sequencer (Linux), WinMM with Windows MIDI Services (Windows) |
|
||||
| Platform Bluetooth | CoreBluetooth (macOS), BlueZ (Linux), WinRT through btleplug, central only (Windows) |
|
||||
| System events | IOKit power + `SCNetworkReachability` (macOS), logind D-Bus + netlink (Linux), power manager callbacks (Windows) |
|
||||
| Service manager | launchd (macOS), systemd user units (Linux), Task Scheduler with the daemon's own supervisor (Windows); the App Store build has none, its app supervising a bundled helper instead |
|
||||
|
||||
In-memory fakes for these seams exist because the seam already exists, not the other way round.
|
||||
Do not introduce a trait anywhere else to make something mockable — if a type is hard to test,
|
||||
restructure it into pure functions over plain data instead.
|
||||
|
||||
---
|
||||
|
||||
## Real-time discipline
|
||||
|
||||
Constitution Principle III is non-negotiable and is the rule most easily broken by accident.
|
||||
|
||||
Inside a CoreMIDI read callback, an ALSA sequencer read path, or any packet-dispatch hot path:
|
||||
|
||||
- No allocation or deallocation. No `Vec::push`, no `String`, no `Box`, no `format!`.
|
||||
- No mutex, no `RwLock`, no channel that can block.
|
||||
- No I/O and no `tracing` macro. Increment an atomic counter; a normal task reads it and logs.
|
||||
- No `unwrap`, per the style rule above, and no arithmetic that can panic — saturate deliberately.
|
||||
|
||||
Cross the boundary with `rtrb` single-producer/single-consumer ring buffers carrying fixed-size
|
||||
`Copy` events. System-exclusive payloads travel as handles into a pre-allocated pool, never
|
||||
inline. Buffers are sized at link setup; overflow increments `messages_dropped` and is never
|
||||
handled by growing a buffer.
|
||||
|
||||
A change touching the data path states in its description how this section is upheld.
|
||||
|
||||
---
|
||||
|
||||
## Unsafe code
|
||||
|
||||
Confined to the platform FFI crates. Every `unsafe` block carries a `// SAFETY:` comment stating
|
||||
the invariant being upheld and why it holds here. No `unsafe` in core logic, protocol crates, the
|
||||
CLI, or the GUI.
|
||||
|
||||
---
|
||||
|
||||
## Errors and logging
|
||||
|
||||
Error enums are closed and machine-readable — `FailureReason` in particular maps onto IPC error
|
||||
codes and CLI exit codes, so adding a variant is a contract change. Never widen an error into a
|
||||
`String` at a boundary a client switches on.
|
||||
|
||||
```rust
|
||||
// Log lifecycle at info.
|
||||
info!(endpoint = %id, "network session connected");
|
||||
// Log failure with the attempted operation as context.
|
||||
error!(endpoint = %id, error = %err, "failed to bind control port");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project layout
|
||||
|
||||
Single binary `midi-harbor`, one cargo workspace. The GUI is an optional feature; disabling it
|
||||
must drop the entire libcosmic dependency tree, so no non-GUI crate may depend on it even
|
||||
transitively.
|
||||
|
||||
```
|
||||
proto/
|
||||
midiharbor/v1/harbor.proto the daemon contract; generated from, never hand-edited
|
||||
crates/
|
||||
core/ domain types, state machines, routing, config (no platform; its own file only)
|
||||
rtpmidi/ AppleMIDI session control + RFC 6295 journal (pure, no I/O)
|
||||
blemidi/ BLE MIDI packet codec (pure, no I/O)
|
||||
platform/ the four seams above, plus in-memory fakes
|
||||
ipc/ generated gRPC client and server, plus status mapping
|
||||
service/ launchd / systemd / Task Scheduler installation, and the supervisor
|
||||
daemon/ the engine that owns all state
|
||||
cli/ clap subcommands over the gRPC contract
|
||||
gui/ libcosmic views — optional feature, thin
|
||||
src/main.rs argument dispatch only
|
||||
```
|
||||
|
||||
### The daemon contract
|
||||
|
||||
Clients reach the daemon over **gRPC on a Unix domain socket** — never TCP, so the control plane is
|
||||
not reachable from the network. Windows uses a named pipe that refuses remote clients instead, with
|
||||
a random name the daemon records in a file where the socket would be, so a file permission still
|
||||
decides who can reach it (R-087). `proto/midiharbor/v1/harbor.proto` is the contract; changing it is
|
||||
a contract change whether or not a Rust signature moves. Version lives in the package name, so a
|
||||
breaking change means `midiharbor.v2`, never a reinterpreted field. Never reuse a field number;
|
||||
reserve it.
|
||||
|
||||
Two streams — `MonitorEndpoint` and `WatchTraffic` — are **lossy by contract**. HTTP/2 flow control
|
||||
would otherwise let a stalled GUI apply backpressure toward the MIDI data path. Drop the update,
|
||||
count it, report the count on the next message. Never await capacity on those two.
|
||||
|
||||
### Discovery uses two stacks on purpose
|
||||
|
||||
Browsing goes through `mdns-sd`; advertising goes through the platform responder: through
|
||||
`zeroconf` on macOS and Linux, and through the DNS Client service's DNS-SD functions on Windows.
|
||||
That is not an oversight. `mdns-sd`'s responder announces once at registration and then stops
|
||||
answering queries from other machines, so a service registered through it is invisible to every
|
||||
peer — including Apple's own browser — while looking perfectly healthy locally. `zeroconf`'s
|
||||
browser, meanwhile, returned `0.0.0.0` for most entries. Each is used where it works.
|
||||
|
||||
Do not consolidate onto one stack without re-running the two-machine test in R-022. A single
|
||||
machine cannot tell the difference.
|
||||
|
||||
### The configuration file
|
||||
|
||||
One YAML document per user, in the platform's standard config directory. Use `serde_yaml_ng`;
|
||||
`serde_yaml` and `serde_yml` are both deprecated.
|
||||
|
||||
People edit this file by hand, which constrains its shape: the endpoint kind is flattened rather
|
||||
than nested, routes name their endpoints instead of referencing UUIDs, and identifiers are
|
||||
optional on read so a config can be written from scratch with none. Renaming an endpoint must
|
||||
rewrite every route that names it, in the same operation — that is what keeps connections intact
|
||||
across a rename. Writes are atomic (temp file, flush, rename); an unparseable file is moved aside
|
||||
with a timestamp and never deleted.
|
||||
|
||||
Keep `core/`, `rtpmidi/` and `blemidi/` free of platform code and of MIDI, network and radio I/O.
|
||||
They are where the correctness lives and they must be testable on a machine with no MIDI, no
|
||||
network, and no radio. The one exception is the configuration file: `core/` reads it, writes it
|
||||
atomically and moves an unreadable one aside with `std::fs`, and resolves where it lives, as
|
||||
plan.md places it. Any machine can test that. Nothing else in these crates touches the
|
||||
filesystem.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Few tests, each worth its place, in two tiers. More tests are not better: never pad coverage, and
|
||||
prune tests that break these rules when touching a suite.
|
||||
|
||||
### The two tiers
|
||||
|
||||
- **Unit tests** live in `#[cfg(test)] mod tests` beside the code and run with `make test`
|
||||
(`cargo test --workspace --lib --bins`). They are small and fast, and mainly cover
|
||||
serialization boundaries.
|
||||
- **Integration tests** are the primary safety net. They live in each crate's `tests/` directory
|
||||
and the root `tests/`, and run with `make test-integration`
|
||||
(`cargo test --workspace --test '*'`). They drive the project as a whole: a real daemon over real files, real sockets on loopback, a
|
||||
real gRPC client, real RTP-MIDI and BLE MIDI bytes. Every resilience behaviour in Constitution
|
||||
Principle I is proved here, by inducing the failure and asserting recovery.
|
||||
- **Live tests** are integration tests that need something a CI runner lacks: CoreMIDI, the ALSA
|
||||
sequencer, WinMM and Windows MIDI Services, a Bluetooth radio, rtpmidid or Apple's session
|
||||
peer. They are `#[ignore = "needs ..."]`, naming what they need, and run with `make test-live`
|
||||
on a machine that has it.
|
||||
|
||||
### What deserves a unit test
|
||||
|
||||
- Serialization and parsing: configuration YAML, the proto mapping, RTP-MIDI packets and the
|
||||
recovery journal, BLE MIDI and UMP codecs. Encode, decode and compare, with the invalid input
|
||||
in the same test. Wire formats keep their proptest round-trips; parsers keep their fuzz targets.
|
||||
- Invariants of external formats: what launchd, systemd, Task Scheduler, ALSA, WinMM or Windows
|
||||
MIDI Services write or report, against fixtures shaped exactly as they produce them.
|
||||
- Contracts with an external specification: RFC 6295, Apple's session protocol, the BLE MIDI
|
||||
specification, the gRPC contract's error codes. The specification's own bytes or numbers may be
|
||||
the assertion. Truncated or older input must not panic or misalign.
|
||||
- Facts about the outside world the code depends on: an exact spelling a tool prints, a
|
||||
protocol quirk, a platform limit. These stay even when the test is four lines.
|
||||
- Real algorithmic logic with a non-obvious result: clock synchronisation, routing resolution,
|
||||
backoff, note tracking. One representative test per axis, never an exhaustive fan-out.
|
||||
|
||||
### What must not be unit tested
|
||||
|
||||
- Simple logic: trivial predicates, a helper's body restated, a `Display` string nobody parses.
|
||||
- Anything an integration test already proves through the daemon, the CLI or a real socket.
|
||||
- A lone rejection. A validation test earns its place only by pinning the accepted boundary case
|
||||
beside the rejected one.
|
||||
- This codebase's own collaborators through a fake. Fakes belong only at the four platform seams,
|
||||
where the real thing is hardware or the operating system.
|
||||
|
||||
For a borderline case, ask whether the test encodes a fact from outside this codebase. If it
|
||||
pins a real spelling, format or quirk, keep it. If it only restates this codebase's own code,
|
||||
delete it: a bug there fails loudly downstream. A throwaway test to check behaviour while
|
||||
working is fine, but it is deleted before the work is done.
|
||||
|
||||
### Mechanics
|
||||
|
||||
- Plain `assert!`, `assert_eq!` and `assert_ne!`, each with a message that is a sentence giving
|
||||
the reason: `assert_eq!(sent, 1, "a ready peer must not be invited twice")`. Setup that
|
||||
invalidates the rest of the test uses `expect("...")` with the same kind of message.
|
||||
- Table-driven wherever more than one input shape exists: an array of structs or tuples with a
|
||||
`name`, the inputs, the wanted result and, where it helps, a `why`. The assert message names
|
||||
the case.
|
||||
- Every test has a `///` comment stating the invariant it locks and why, naming the real-world
|
||||
source where there is one: the RFC section, the tool and version, the regression it pins.
|
||||
Arithmetic behind an expected number is spelled out in the comment.
|
||||
- Helpers are small, local to the file, and build fixtures laid out exactly as the real system
|
||||
writes them. Real captured output goes in a `testdata/` directory beside the test, with its
|
||||
provenance noted; synthetic samples are labelled synthetic.
|
||||
- Re-read from the source of truth before asserting: reload the configuration from disk after a
|
||||
write rather than trusting the daemon's memory of it.
|
||||
- Time is injected, never read from the clock in logic under test. No `sleep` to advance a state
|
||||
machine.
|
||||
- Parsers treat peer and network input as hostile: malformed input produces an error, never a
|
||||
panic, an unbounded allocation, or an out-of-bounds access.
|
||||
|
||||
### Real dependencies, never substitutes
|
||||
|
||||
Use the real thing, then the real thing isolated, and a fake only as the last resort. Files are
|
||||
real files in a temporary directory; sockets are real sockets on loopback; a protocol peer is a
|
||||
real in-process peer speaking the real wire format. The in-memory platform fakes exist because
|
||||
the platform seams exist: they stand in for MIDI hardware, the Bluetooth radio, power events and
|
||||
the service manager, which no CI runner has. No mocking library, and no trait added only to make
|
||||
something testable.
|
||||
|
||||
---
|
||||
|
||||
## Quality gates
|
||||
|
||||
```bash
|
||||
cargo fmt --all --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
make test # unit tests
|
||||
make test-integration # integration tests
|
||||
cargo build --no-default-features # the headless build
|
||||
! cargo tree --no-default-features | grep -qi cosmic # must not pull in libcosmic
|
||||
```
|
||||
|
||||
All six pass on macOS, Linux and Windows before merge.
|
||||
|
||||
Windows is cross-compiled rather than built on the Windows machine, and its tests run there:
|
||||
|
||||
```bash
|
||||
cargo clippy --target x86_64-pc-windows-gnu --workspace --all-targets -- -D warnings
|
||||
cargo build --target x86_64-pc-windows-gnu --no-default-features
|
||||
scripts/windows-test.sh --workspace --exclude midi-harbor-gui # builds test binaries, runs them on Windows
|
||||
```
|
||||
|
||||
The script takes the machine from `MH_WINDOWS_HOST` (`user@host`), which needs only OpenSSH
|
||||
server. Research R-089 describes what it does, including why the tree is mirrored on the Windows
|
||||
machine.
|
||||
|
||||
The virtual port tests in `crates/platform/tests/windows_midi.rs` cannot run through the script:
|
||||
Windows MIDI Services never answers a virtual device created from an SSH session (R-093). Copy the
|
||||
test binary over and start it from the desktop session, as a scheduled task with an interactive
|
||||
logon. Before Windows' late-2026 update the service also stops answering once a port closes, so
|
||||
there each test runs alone, with the Windows MIDI Service restarted before it.
|
||||
|
||||
The fuzz targets under `fuzz/` are their own workspace, because libFuzzer needs a nightly
|
||||
compiler, so none of the gates above builds them. Run them after changing a parser, and at
|
||||
least build them after changing any API they call:
|
||||
|
||||
```bash
|
||||
cd fuzz
|
||||
cargo +nightly fuzz build
|
||||
cargo +nightly fuzz run blemidi_codec -- -max_total_time=300 # likewise rtpmidi_packet, rtpmidi_journal
|
||||
```
|
||||
|
||||
Leave `-rss_limit_mb` at its default: libFuzzer itself settles near 480 MB on these targets, so a
|
||||
lower limit reports an out-of-memory that no input caused.
|
||||
|
||||
`--workspace` is not decoration. Without it, cargo lints only the root package and the other
|
||||
crates as plain libraries, so no test module in any crate is ever linted — a dead import in a
|
||||
test passes unnoticed.
|
||||
8408
Cargo.lock
generated
Normal file
8408
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
140
Cargo.toml
Normal file
140
Cargo.toml
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
[package]
|
||||
name = "midi-harbor"
|
||||
description = "Resilient MIDI connectivity manager for macOS, Linux and Windows"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "midi-harbor"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["gui"]
|
||||
# gui is the only gate on libcosmic. Disabling it must drop the entire graphical
|
||||
# dependency tree, which is why the GUI lives in its own optional crate (FR-039b).
|
||||
gui = ["dep:midi-harbor-gui"]
|
||||
|
||||
[dependencies]
|
||||
clap.workspace = true
|
||||
midi-harbor-cli.workspace = true
|
||||
midi-harbor-core.workspace = true
|
||||
midi-harbor-daemon.workspace = true
|
||||
jiff.workspace = true
|
||||
midi-harbor-gui = { workspace = true, optional = true }
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
midi-harbor-platform.workspace = true
|
||||
midi-harbor-service.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
midi-harbor-rtpmidi.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
embed-resource = "3"
|
||||
|
||||
[workspace]
|
||||
members = ["crates/*"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
# The release version is in VERSION. The crates are never published, so theirs stays 0.0.0.
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
rustix = { version = "1.1", default-features = false, features = ["std", "process"] }
|
||||
midi-harbor-blemidi = { path = "crates/blemidi" }
|
||||
midi-harbor-cli = { path = "crates/cli" }
|
||||
midi-harbor-core = { path = "crates/core" }
|
||||
midi-harbor-daemon = { path = "crates/daemon" }
|
||||
midi-harbor-gui = { path = "crates/gui" }
|
||||
midi-harbor-ipc = { path = "crates/ipc" }
|
||||
midi-harbor-platform = { path = "crates/platform" }
|
||||
midi-harbor-rtpmidi = { path = "crates/rtpmidi" }
|
||||
midi-harbor-service = { path = "crates/service" }
|
||||
|
||||
bytes = "1"
|
||||
clap = { version = "4.6", features = ["derive"] }
|
||||
directories = "6.0"
|
||||
gethostname = "1.1"
|
||||
if-addrs = "0.15"
|
||||
jiff = { version = "0.2", features = ["serde"] }
|
||||
mdns-sd = "0.21"
|
||||
rand = "0.10"
|
||||
rtrb = "0.4"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
socket2 = "0.6"
|
||||
prost = "0.14"
|
||||
prost-types = "0.14"
|
||||
protoc-bin-vendored = "3.2"
|
||||
tonic = "0.14"
|
||||
tonic-prost = "0.14"
|
||||
tonic-prost-build = "0.14"
|
||||
tower = "0.5"
|
||||
hyper-util = "0.1"
|
||||
thiserror = "2.0"
|
||||
tokio = { version = "1.53", features = ["rt-multi-thread", "macros", "net", "sync", "time", "io-util", "signal"] }
|
||||
tokio-stream = { version = "0.1", features = ["net"] }
|
||||
tokio-util = { version = "0.7", features = ["codec"] }
|
||||
serde_yaml_ng = "0.10"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
zeroconf = "0.18"
|
||||
# The WinRT side of Windows: Windows MIDI Services' API, through generated bindings in the
|
||||
# platform crate. The versions btleplug already uses.
|
||||
windows = { version = "0.62", features = ["Foundation", "Data_Json"] }
|
||||
windows-collections = "0.3"
|
||||
windows-core = "0.62"
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Media_Audio",
|
||||
"Win32_Media_Multimedia",
|
||||
"Win32_NetworkManagement_Dns",
|
||||
"Win32_NetworkManagement_IpHelper",
|
||||
"Win32_Networking_WinSock",
|
||||
"Win32_Security",
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Power",
|
||||
"Win32_System_SystemInformation",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_WindowsProgramming",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Console",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_Registry",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
uuid = { version = "1.26", features = ["v4", "v5", "serde"] }
|
||||
|
||||
proptest = "1.11"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
# Library code never panics, per AGENTS.md. Tests opt out with #[allow].
|
||||
unwrap_used = "deny"
|
||||
expect_used = "deny"
|
||||
panic = "deny"
|
||||
indexing_slicing = "deny"
|
||||
integer_division = "warn"
|
||||
|
||||
# btleplug 0.13.2 panics on macOS when CoreBluetooth rediscovers a peripheral's services with no
|
||||
# request pending, which a BlueZ peripheral triggers (research R-058). The fix is proposed
|
||||
# upstream as deviceplug/btleplug#479; drop this once a release includes it.
|
||||
[patch.crates-io]
|
||||
btleplug = { git = "https://github.com/grmrgecko/btleplug", rev = "3a9da4bd65697593d127c124cf332b835d240802" }
|
||||
|
||||
# Dependencies build without debug info in dev and test builds. Each of the workspace's test
|
||||
# binaries otherwise links libcosmic's debug info, which made relinking after a one-line change
|
||||
# take about two minutes on the development Mac instead of about ten seconds. The workspace's own
|
||||
# crates keep full debug info, so backtraces and debugging in this code are unchanged.
|
||||
[profile.dev.package."*"]
|
||||
debug = false
|
||||
176
Icon.svg
Normal file
176
Icon.svg
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" viewBox="0 0 512 512" width="100%" height="100%" id="midi-harbor-icon">
|
||||
<defs>
|
||||
<!-- Background Gradient -->
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0a101d" />
|
||||
<stop offset="50%" stop-color="#07172b" />
|
||||
<stop offset="100%" stop-color="#020813" />
|
||||
</linearGradient>
|
||||
<!-- Ring Glow Gradient -->
|
||||
<linearGradient id="ringGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00f2fe" />
|
||||
<stop offset="50%" stop-color="#0066ff" />
|
||||
<stop offset="100%" stop-color="#7b2cbf" />
|
||||
</linearGradient>
|
||||
<!-- Anchor Metal Gradient -->
|
||||
<linearGradient id="anchorGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="40%" stop-color="#e0e8f5" />
|
||||
<stop offset="70%" stop-color="#94a3b8" />
|
||||
<stop offset="100%" stop-color="#475569" />
|
||||
</linearGradient>
|
||||
<!-- Cable Outer Glow -->
|
||||
<radialGradient id="centerGlow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#00f2fe" stop-opacity="0.25" />
|
||||
<stop offset="100%" stop-color="#00f2fe" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<!-- Drop Shadow Filters -->
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="12" result="blur" />
|
||||
<feOffset in="blur" dx="0" dy="8" result="offset" />
|
||||
<feFlood flood-color="#000000" flood-opacity="0.6" result="color" />
|
||||
<feComposite in="color" in2="offset" operator="in" result="shadow" />
|
||||
<feMerge>
|
||||
<feMergeNode in="shadow" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id="metalShadow" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="15" result="blur" />
|
||||
<feOffset in="blur" dx="0" dy="10" result="offset" />
|
||||
<feFlood flood-color="#000000" flood-opacity="0.7" result="color" />
|
||||
<feComposite in="color" in2="offset" operator="in" result="shadow" />
|
||||
<feMerge>
|
||||
<feMergeNode in="shadow" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id="glow" filterUnits="userSpaceOnUse" x="0" y="0" width="512" height="512">
|
||||
<feGaussianBlur stdDeviation="8" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
<filter id="anchorGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="8" result="blur" />
|
||||
<feOffset in="blur" dx="0" dy="4" result="offset" />
|
||||
<feFlood flood-color="#00f2fe" flood-opacity="0.4" result="color" />
|
||||
<feComposite in="color" in2="offset" operator="in" result="shadow" />
|
||||
<feMerge>
|
||||
<feMergeNode in="shadow" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style>
|
||||
.bg-ring { fill: none; stroke: url(#ringGrad); stroke-width: 3; opacity: 0.3; }
|
||||
.data-stream { fill: none; stroke: #00f2fe; stroke-width: 2.5; stroke-dasharray: 6 8; opacity: 0.8; }
|
||||
.anchor-body { fill: url(#anchorGrad); }
|
||||
.midi-pin { fill: #07172b; stroke: #00f2fe; stroke-width: 1.5; }
|
||||
.connection-node { fill: #00f2fe; filter: url(#glow); }
|
||||
.status-pulse { fill: #10b981; filter: url(#glow); }
|
||||
</style>
|
||||
</defs>
|
||||
<g id="layer-background" inkscape:label="Background" inkscape:groupmode="layer">
|
||||
<g id="background-tile" inkscape:label="Tile and border">
|
||||
<!-- App Icon Background -->
|
||||
<rect width="512" height="512" rx="112" fill="url(#bgGrad)" id="tile" />
|
||||
<!-- Outer Border Highlight -->
|
||||
<rect x="2" y="2" width="508" height="508" rx="110" fill="none" stroke="#ffffff" stroke-width="1.5" stroke-opacity="0.1" id="border-highlight" />
|
||||
</g>
|
||||
<g id="ambient-glow" inkscape:label="Ambient glow">
|
||||
<!-- Center Ambient Glow -->
|
||||
<circle cx="256" cy="256" r="200" fill="url(#centerGlow)" id="center-glow" />
|
||||
</g>
|
||||
</g>
|
||||
<g id="layer-topology" inkscape:label="Topology" inkscape:groupmode="layer">
|
||||
<!-- Network & Hardware Topology Mesh (Harbor Node Web) -->
|
||||
<g opacity="0.4" id="topology-mesh" inkscape:label="Harbor mesh">
|
||||
<g id="orbital-rings" inkscape:label="Orbital rings">
|
||||
<!-- Orbital Rings -->
|
||||
<circle cx="256" cy="256" r="180" class="bg-ring" stroke-dasharray="12 12" id="outer-orbit" />
|
||||
<circle cx="256" cy="256" r="130" class="bg-ring" id="inner-orbit" />
|
||||
</g>
|
||||
<g id="connection-lines" inkscape:label="Connection lines">
|
||||
<!-- Connection Lines (USB, Network, BLE, Virtual) -->
|
||||
<path d="M 100 180 L 170 210 M 412 180 L 342 210 M 120 340 L 180 310 M 392 340 L 332 310" stroke="#00f2fe" stroke-width="2" opacity="0.5" stroke-dasharray="4 4" id="diagonal-links" />
|
||||
<path d="M 256 70 L 256 120 M 256 390 L 256 440" stroke="#00f2fe" stroke-width="2" opacity="0.5" stroke-dasharray="4 4" id="vertical-links" />
|
||||
</g>
|
||||
<g id="peripheral-ports" inkscape:label="Peripheral ports">
|
||||
<!-- Peripheral Nodes -->
|
||||
<!-- Hardware (USB) -->
|
||||
<circle cx="100" cy="180" r="8" fill="#07172b" stroke="#00f2fe" stroke-width="2" id="hardware-port" />
|
||||
<!-- Network (RTP) -->
|
||||
<circle cx="412" cy="180" r="8" fill="#07172b" stroke="#00f2fe" stroke-width="2" id="network-port" />
|
||||
<!-- Bluetooth LE -->
|
||||
<circle cx="120" cy="340" r="8" fill="#07172b" stroke="#00f2fe" stroke-width="2" id="bluetooth-port" />
|
||||
<!-- Virtual Port -->
|
||||
<circle cx="392" cy="340" r="8" fill="#07172b" stroke="#00f2fe" stroke-width="2" id="virtual-port" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="layer-cables" inkscape:label="Connection cables" inkscape:groupmode="layer">
|
||||
<!-- Auto-Repairing Connection Swirls (MIDI Cable + Harbor Anchor Motif) -->
|
||||
<g filter="url(#shadow)" id="connection-cables" inkscape:label="Cable strands">
|
||||
<g id="cyan-cable" inkscape:label="Hardware and virtual">
|
||||
<!-- Left Cable Strand (Hardware/Virtual) -->
|
||||
<path d="M 100 180 C 140 240, 180 340, 256 380" fill="none" stroke="#00f2fe" stroke-width="5" stroke-linecap="round" opacity="0.8" filter="url(#glow)" id="cyan-strand" />
|
||||
</g>
|
||||
<g id="violet-cable" inkscape:label="Network and RTP-MIDI">
|
||||
<!-- Right Cable Strand (Network/RTP) -->
|
||||
<path d="M 412 180 C 372 240, 332 340, 256 380" fill="none" stroke="#7b2cbf" stroke-width="5" stroke-linecap="round" opacity="0.8" id="violet-strand" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="layer-anchor" inkscape:label="MIDI anchor" inkscape:groupmode="layer">
|
||||
<!-- CENTRAL ANCHOR & MIDI DIN CONNECTOR HYBRID -->
|
||||
<g filter="url(#shadow)" id="anchor-assembly" inkscape:label="Anchor assembly">
|
||||
<g id="metal-silhouette" inkscape:label="Single metal silhouette">
|
||||
<!-- MIDI socket note head with one lower anchor-fluke flag. -->
|
||||
<path d="M 141.000 310.000 L 181.000 310.000 L 168.000 322.000 C 172.000 350.000 198.000 363.000 242.000 365.000 L 242.000 202.000 L 165.000 202.000 C 163.874 202.000 162.836 201.628 162.000 201.000 L 162.000 203.000 C 162.000 204.657 160.657 206.000 159.000 206.000 L 153.000 206.000 C 151.343 206.000 150.000 204.657 150.000 203.000 L 150.000 179.000 C 150.000 177.343 151.343 176.000 153.000 176.000 L 159.000 176.000 C 160.657 176.000 162.000 177.343 162.000 179.000 L 162.000 181.000 C 162.836 180.372 163.874 180.000 165.000 180.000 L 242.000 180.000 L 242.000 170.926 C 222.320 164.935 208.000 146.640 208.000 125.000 C 208.000 98.490 229.490 77.000 256.000 77.000 C 282.510 77.000 304.000 98.490 304.000 125.000 C 304.000 146.640 289.680 164.935 270.000 170.926 L 270.000 180.000 L 347.000 180.000 C 348.126 180.000 349.164 180.372 350.000 181.000 L 350.000 179.000 C 350.000 177.343 351.343 176.000 353.000 176.000 L 359.000 176.000 C 360.657 176.000 362.000 177.343 362.000 179.000 L 362.000 203.000 C 362.000 204.657 360.657 206.000 359.000 206.000 L 353.000 206.000 C 351.343 206.000 350.000 204.657 350.000 203.000 L 350.000 201.000 C 349.164 201.628 348.126 202.000 347.000 202.000 L 270.000 202.000 L 270.000 404.000 C 270.000 407.314 267.314 410.000 264.000 410.000 L 248.000 410.000 C 247.127 410.000 246.298 409.814 245.550 409.479 C 198.464 404.741 150.000 379.287 150.000 320.000 Z M 292.000 125.000 C 292.000 105.118 275.882 89.000 256.000 89.000 C 236.118 89.000 220.000 105.118 220.000 125.000 C 220.000 144.882 236.118 161.000 256.000 161.000 C 275.882 161.000 292.000 144.882 292.000 125.000 Z" class="anchor-body" id="anchor-silhouette" filter="url(#metalShadow)" />
|
||||
</g>
|
||||
<g id="midi-socket" inkscape:label="MIDI socket">
|
||||
<g id="socket-rim" inkscape:label="Socket interior and rim">
|
||||
<!-- MIDI socket interior and rim. -->
|
||||
<circle cx="256" cy="125" r="36" fill="#07172b" id="socket-interior" />
|
||||
<circle cx="256" cy="125" r="35" fill="none" stroke="#00f2fe" stroke-width="2" opacity="0.6" id="socket-outline" />
|
||||
</g>
|
||||
<!-- MIDI 5-PIN Configuration Inside the Anchor Shackling Ring -->
|
||||
<!-- 180-degree arc standard MIDI layout -->
|
||||
<g filter="url(#anchorGlow)" id="midi-pins" inkscape:label="Five MIDI pins">
|
||||
<!-- Pin 1 (Sound / Control) -->
|
||||
<circle cx="230" cy="133" r="4.5" class="midi-pin" />
|
||||
<!-- Pin 2 (Shield/Ground) -->
|
||||
<circle cx="240" cy="113" r="4.5" class="midi-pin" />
|
||||
<!-- Pin 3 (Master Center) -->
|
||||
<circle cx="256" cy="105" r="5" fill="#00f2fe" filter="url(#glow)" />
|
||||
<!-- Pin 4 (Data +) -->
|
||||
<circle cx="272" cy="113" r="4.5" class="midi-pin" />
|
||||
<!-- Pin 5 (Data -) -->
|
||||
<circle cx="282" cy="133" r="4.5" class="midi-pin" />
|
||||
</g>
|
||||
</g>
|
||||
<g id="heartbeat" inkscape:label="Heartbeat">
|
||||
<!-- Center Daemon "Alive" Heartbeat Core -->
|
||||
<circle cx="256" cy="191" r="6" fill="#10b981" filter="url(#glow)" id="heartbeat-light" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="layer-wireless" inkscape:label="Wireless signal" inkscape:groupmode="layer">
|
||||
<g id="wireless-waves" inkscape:label="Signal arcs">
|
||||
<!-- Bluetooth & Network Wireless Waves Overlaying the Anchor Stock -->
|
||||
<path d="M 236 70 C 248 62, 264 62, 276 70" fill="none" stroke="#00f2fe" stroke-width="3" stroke-linecap="round" opacity="0.9" filter="url(#glow)" id="inner-wave" />
|
||||
<path d="M 224 56 C 244 42, 268 42, 288 56" fill="none" stroke="#00f2fe" stroke-width="3" stroke-linecap="round" opacity="0.5" id="outer-wave" />
|
||||
</g>
|
||||
</g>
|
||||
<g id="layer-indicators" inkscape:label="Status lights" inkscape:groupmode="layer">
|
||||
<g id="link-status" inkscape:label="Active links">
|
||||
<!-- Status / Health Indicators (Daemon Persistence Nodes) -->
|
||||
<!-- Active Link (Green) -->
|
||||
<circle cx="170" cy="210" r="4" class="status-pulse" id="left-link-status" />
|
||||
<circle cx="342" cy="210" r="4" class="status-pulse" id="right-link-status" />
|
||||
</g>
|
||||
<g id="data-particles" inkscape:label="Data particles">
|
||||
<!-- Subtle Data Flow Particles -->
|
||||
<circle cx="256" cy="260" r="2.5" fill="#00f2fe" opacity="0.9" id="upper-particle" />
|
||||
<circle cx="256" cy="310" r="2.5" fill="#00f2fe" opacity="0.9" id="lower-particle" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
21
LICENSE.txt
Normal file
21
LICENSE.txt
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 James Coleman
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
92
Makefile
Normal file
92
Makefile
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Tests, and release builds. Release builds run inside the image packaging/cross/Dockerfile makes,
|
||||
# against the Linux sysroots packaging/sysroot/build.sh makes; see packaging/README.md.
|
||||
|
||||
CROSS_IMAGE := midi-harbor-cross:latest
|
||||
SYSROOT := $(CURDIR)/target/sysroot
|
||||
# The last file packaging/sysroot/build.sh makes, so a build it did not finish counts as missing.
|
||||
SYSROOT_DONE := $(SYSROOT)/linux_arm64/lib
|
||||
# Downloaded crates, kept between runs inside target/ so nothing else in the tree changes.
|
||||
CARGO_CACHE := $(CURDIR)/target/release-cargo-home
|
||||
# The release build's own target directory, a Docker volume: building through a bind mount of the
|
||||
# working tree's target/ stalled for good on Docker Desktop. Only dist/ comes back to the tree.
|
||||
TARGET_VOLUME := midi-harbor-release-target
|
||||
MIDI_HARBOR_MAINTAINER ?= $(shell git config user.name) <$(shell git config user.email)>
|
||||
# The version every build step uses; a release is the commit tagged with it.
|
||||
VERSION := $(shell cat VERSION)
|
||||
|
||||
# GoReleaser runs one build at a time: its Rust builder reads each binary from target/, which
|
||||
# the full and headless builds of one target share. Cargo still builds each in parallel.
|
||||
# Docker creates a missing volume owned by root, so the volume is handed to the build's user
|
||||
# first; otherwise a release after make clean cannot write to target/.
|
||||
GORELEASER = mkdir -p $(CARGO_CACHE) && docker run --rm \
|
||||
--user 0:0 \
|
||||
--entrypoint chown \
|
||||
-v $(TARGET_VOLUME):/target \
|
||||
$(CROSS_IMAGE) \
|
||||
$(shell id -u):$(shell id -g) /target && docker run --rm \
|
||||
--user $(shell id -u):$(shell id -g) \
|
||||
-e CARGO_HOME=/cargo-home \
|
||||
-e HOME=/tmp \
|
||||
-e MIDI_HARBOR_MAINTAINER="$(MIDI_HARBOR_MAINTAINER)" \
|
||||
-e MIDI_HARBOR_VERSION="$(VERSION)" \
|
||||
-v $(CURDIR):/src \
|
||||
-v $(SYSROOT):/sysroot:ro \
|
||||
-v $(CARGO_CACHE):/cargo-home \
|
||||
-v $(TARGET_VOLUME):/src/target \
|
||||
-w /src \
|
||||
$(1) \
|
||||
$(CROSS_IMAGE)
|
||||
|
||||
.PHONY: default
|
||||
default: test
|
||||
|
||||
# Unit tests, beside the code.
|
||||
.PHONY: test
|
||||
test:
|
||||
cargo test --workspace --lib --bins
|
||||
|
||||
# Integration tests: the daemon, CLI and protocols as a whole, over real files and sockets.
|
||||
.PHONY: test-integration
|
||||
test-integration:
|
||||
cargo test --workspace --test '*'
|
||||
|
||||
# Tests that need CoreMIDI, ALSA, WinMM, a Bluetooth radio or a session peer on this machine.
|
||||
.PHONY: test-live
|
||||
test-live:
|
||||
cargo test --workspace --test '*' -- --ignored --test-threads 1
|
||||
|
||||
.PHONY: build-sysroot
|
||||
build-sysroot:
|
||||
packaging/sysroot/build.sh
|
||||
|
||||
# A release builds the sysroots when they are missing. Docker would otherwise mount an empty
|
||||
# directory in their place, and pkg-config would fail on the first Linux library it looks for.
|
||||
$(SYSROOT_DONE):
|
||||
packaging/sysroot/build.sh
|
||||
|
||||
.PHONY: build-docker-image
|
||||
build-docker-image:
|
||||
docker build -t $(CROSS_IMAGE) packaging/cross
|
||||
|
||||
# Builds every artifact into dist/ without publishing.
|
||||
.PHONY: snapshot
|
||||
snapshot: | $(SYSROOT_DONE)
|
||||
$(call GORELEASER,) release --clean --parallelism 1 --snapshot
|
||||
|
||||
# Publishes the commit tagged v$(VERSION). .release-env holds GITHUB_TOKEN. GoReleaser takes the
|
||||
# version from the tag, so a tag that disagrees with VERSION is refused.
|
||||
.PHONY: release
|
||||
release: | $(SYSROOT_DONE)
|
||||
@test "$$(git rev-parse -q --verify 'refs/tags/v$(VERSION)^{commit}')" = "$$(git rev-parse HEAD)" \
|
||||
|| { echo "HEAD is not tagged v$(VERSION); tag it and push the tag: git tag v$(VERSION) && git push origin v$(VERSION)" >&2; exit 1; }
|
||||
$(call GORELEASER,--env-file .release-env) release --clean --parallelism 1
|
||||
|
||||
# Removes every build output, the Linux sysroots under target/ included, which the next release
|
||||
# builds again.
|
||||
.PHONY: clean
|
||||
clean:
|
||||
cargo clean
|
||||
cargo clean --manifest-path fuzz/Cargo.toml
|
||||
cargo clean --manifest-path scripts/midi2-bindings/Cargo.toml
|
||||
-docker volume rm -f $(TARGET_VOLUME)
|
||||
rm -rf dist
|
||||
134
README.md
Normal file
134
README.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# midi-harbor
|
||||
|
||||
Midi Harbor manages MIDI connections on macOS, Linux and Windows: virtual ports other
|
||||
applications can use, attached MIDI hardware, RTP-MIDI network sessions to other computers, and
|
||||
Bluetooth LE MIDI devices. A daemon owns every connection and repairs it on its own. A network
|
||||
session that drops is reconnected, hardware that is unplugged and plugged back in picks up where
|
||||
it left off, and notes sounding when a link went away are released rather than left ringing.
|
||||
|
||||
The command line and the graphical interface are both clients of the daemon, so closing either
|
||||
changes nothing.
|
||||
|
||||
## Install
|
||||
|
||||
Download a package or archive from the releases, or build this project. `midi-harbor-headless`
|
||||
leaves out the graphical interface, for machines without a display.
|
||||
|
||||
```bash
|
||||
sudo apt install ./midi-harbor_<version>_<arch>.deb # Debian and Ubuntu
|
||||
sudo dnf install ./midi-harbor-<version>-1.<arch>.rpm # Fedora and RHEL
|
||||
```
|
||||
|
||||
The release builds need glibc 2.35 or newer, so RHEL 9 and its rebuilds must build from source.
|
||||
|
||||
The Windows executable is not signed, so it warns the first time it runs;
|
||||
[Installation](docs/installation.md#installing-a-package) says how to let it run.
|
||||
|
||||
On Windows, virtual ports need Windows MIDI Services. Windows 11 includes it from its late-2026
|
||||
update; before that, install Microsoft's
|
||||
[Windows MIDI Services SDK Runtime and Tools](https://github.com/microsoft/MIDI/releases).
|
||||
|
||||
## Building
|
||||
|
||||
Building needs Rust 1.96 or newer. On Linux it also needs the ALSA, D-Bus and Avahi development
|
||||
files and libclang, and for the graphical interface the xkbcommon and Wayland development files.
|
||||
|
||||
On Debian and Ubuntu:
|
||||
|
||||
```bash
|
||||
sudo apt install build-essential pkg-config libasound2-dev libdbus-1-dev \
|
||||
libavahi-client-dev libclang-dev
|
||||
sudo apt install libxkbcommon-dev libwayland-dev # for the graphical interface
|
||||
```
|
||||
|
||||
On Fedora and RHEL (enable CodeReady Builder first on RHEL and its rebuilds):
|
||||
|
||||
```bash
|
||||
sudo dnf install gcc pkgconf-pkg-config alsa-lib-devel dbus-devel avahi-devel clang-devel
|
||||
sudo dnf install libxkbcommon-devel wayland-devel # for the graphical interface
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
cargo build --release # with the graphical interface
|
||||
cargo build --release --no-default-features # without it
|
||||
```
|
||||
|
||||
Windows builds are cross-compiled with mingw-w64:
|
||||
|
||||
```bash
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
```
|
||||
|
||||
Release packages for every platform are built with GoReleaser in Docker, through `make snapshot`
|
||||
and `make release`.
|
||||
|
||||
## Running as a service
|
||||
|
||||
The daemon registers itself with launchd, a systemd user unit, or Task Scheduler, and runs at
|
||||
every login:
|
||||
|
||||
```bash
|
||||
midi-harbor service install --start
|
||||
midi-harbor service status
|
||||
```
|
||||
|
||||
## Running from the command line
|
||||
|
||||
To try things out, or to see more of what the daemon is doing, run it in the foreground:
|
||||
|
||||
```bash
|
||||
midi-harbor daemon -vv
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
The setup lives in one YAML file per user, which the daemon writes as things change.
|
||||
`midi-harbor config path` prints where it is. It can be written by hand; only names and kinds
|
||||
are required:
|
||||
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: Sequencer Bus
|
||||
kind: virtual_port
|
||||
- name: Studio
|
||||
kind: network_port
|
||||
routes:
|
||||
- from: Sequencer Bus
|
||||
to: Studio
|
||||
```
|
||||
|
||||
Run `midi-harbor config reload` after editing it while the daemon runs.
|
||||
|
||||
## Usage
|
||||
|
||||
Every command has help:
|
||||
|
||||
```bash
|
||||
midi-harbor --help
|
||||
```
|
||||
|
||||
Run with no command, `midi-harbor` opens the graphical interface.
|
||||
|
||||
A basic setup that sends a local virtual port to another computer:
|
||||
|
||||
```bash
|
||||
midi-harbor port create "Sequencer Bus"
|
||||
midi-harbor network create "Studio"
|
||||
midi-harbor network discover
|
||||
midi-harbor network connect "Studio" "Stage Mac"
|
||||
midi-harbor route create "Sequencer Bus" "Studio"
|
||||
```
|
||||
|
||||
MIDI played into "Sequencer Bus" now reaches "Stage Mac", and keeps reaching it across sleep,
|
||||
network changes, and either machine restarting.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
make test # unit tests
|
||||
make test-integration # the daemon, CLI and protocols over real files and sockets
|
||||
make test-live # tests needing real MIDI, a Bluetooth radio or a session peer
|
||||
```
|
||||
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.1.0
|
||||
18
build.rs
Normal file
18
build.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
//! Embeds the icon in the Windows executable. Explorer, the Start menu and shortcuts show it, and
|
||||
//! so does the taskbar, since the window sets no icon of its own.
|
||||
//!
|
||||
//! The resource compiler is required rather than optional, so a release build without one fails
|
||||
//! instead of shipping an executable with the generic icon. Cross-compiling uses mingw-w64's
|
||||
//! `windres`, and a Windows build with MSVC uses the Windows SDK's `rc.exe`.
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("cargo:rerun-if-changed=packaging/windows/midi-harbor.rc");
|
||||
println!("cargo:rerun-if-changed=packaging/windows/midi-harbor.ico");
|
||||
if std::env::var("CARGO_CFG_TARGET_OS")? != "windows" {
|
||||
return Ok(());
|
||||
}
|
||||
embed_resource::compile("packaging/windows/midi-harbor.rc", embed_resource::NONE)
|
||||
.manifest_required()
|
||||
.map_err(|err| format!("could not embed the Windows icon: {err}"))?;
|
||||
Ok(())
|
||||
}
|
||||
5
clippy.toml
Normal file
5
clippy.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Library code never panics, but tests may assert freely (AGENTS.md).
|
||||
allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
18
crates/blemidi/Cargo.toml
Normal file
18
crates/blemidi/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "midi-harbor-blemidi"
|
||||
description = "Bluetooth LE MIDI packet codec"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
midi-harbor-core.workspace = true
|
||||
thiserror.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
563
crates/blemidi/src/codec.rs
Normal file
563
crates/blemidi/src/codec.rs
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
//! The Bluetooth LE MIDI packet format.
|
||||
//!
|
||||
//! BLE carries MIDI in short attribute writes rather than a byte stream, so the framing is its
|
||||
//! own. Every packet opens with a header byte, each message inside carries a thirteen-bit
|
||||
//! millisecond timestamp, and a system-exclusive dump is spread across as many packets as it
|
||||
//! takes. None of that survives being treated as plain MIDI bytes: a timestamp byte and a status
|
||||
//! byte are both `1xxxxxxx`, so a parser that does not track position turns timing information
|
||||
//! into notes.
|
||||
//!
|
||||
//! The encoder is deliberately stricter than the decoder. It never uses running status, because
|
||||
//! the byte it saves is worth less than being understood by every receiver, while the decoder
|
||||
//! accepts running status because devices in the field send it.
|
||||
//!
|
||||
//! Nothing here allocates, locks, or can panic, because both directions run on the data path.
|
||||
|
||||
use midi_harbor_core::midi::MidiMessage;
|
||||
use midi_harbor_core::stream::SysExEnd;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// The GATT service every BLE MIDI device carries.
|
||||
pub const SERVICE_UUID: Uuid = Uuid::from_u128(0x03B8_0E5A_EDE8_4B33_A751_6CE3_4EC4_C700);
|
||||
|
||||
/// The characteristic MIDI travels over, in both directions.
|
||||
pub const CHARACTERISTIC_UUID: Uuid = Uuid::from_u128(0x7772_E5DB_3868_4112_A1A9_F266_9D10_6BF3);
|
||||
|
||||
/// How many milliseconds the thirteen-bit timestamp counts before returning to zero.
|
||||
pub const TIMESTAMP_PERIOD: u64 = 8192;
|
||||
|
||||
/// The largest packet this encoder will build.
|
||||
///
|
||||
/// An attribute write cannot exceed the negotiated MTU, and no BLE link negotiates beyond this.
|
||||
pub const MAX_PACKET: usize = 512;
|
||||
|
||||
/// The smallest packet worth building: a header, a timestamp and a three-byte message.
|
||||
pub const MIN_PACKET: usize = 5;
|
||||
|
||||
/// Why a packet could not be read.
|
||||
///
|
||||
/// Peripheral input is hostile input, so every one of these is a refusal rather than a guess.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum DecodeError {
|
||||
/// The write carried no bytes at all.
|
||||
#[error("packet carried no header byte")]
|
||||
Empty,
|
||||
/// The first byte was a data byte, so the packet is not BLE MIDI framing.
|
||||
#[error("packet opened with {0:#04x} rather than a header byte")]
|
||||
NotAHeader(u8),
|
||||
/// The packet stopped part-way through a message.
|
||||
#[error("packet ended part-way through a message")]
|
||||
Truncated,
|
||||
/// A data byte arrived with no status for it to inherit.
|
||||
#[error("data byte {0:#04x} arrived with no status to inherit")]
|
||||
Orphaned(u8),
|
||||
}
|
||||
|
||||
/// Why a system-exclusive message could not be packed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum EncodeError {
|
||||
/// The payload was not a whole system-exclusive message.
|
||||
#[error("payload is not framed by {:#04x} and {:#04x}", SYSEX_START, SYSEX_END)]
|
||||
NotFramed,
|
||||
}
|
||||
|
||||
/// The byte that opens a system-exclusive message.
|
||||
const SYSEX_START: u8 = 0xF0;
|
||||
|
||||
/// The byte that closes a system-exclusive message.
|
||||
const SYSEX_END: u8 = 0xF7;
|
||||
|
||||
/// The lowest status byte that is a real-time message.
|
||||
const FIRST_REALTIME: u8 = 0xF8;
|
||||
|
||||
/// One thing read out of a packet, with the time the sender stamped on it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Event<'a> {
|
||||
/// A complete channel, system common or real-time message.
|
||||
Message {
|
||||
/// Milliseconds since this decoder started, with the counter's turnover already undone.
|
||||
at: u64,
|
||||
/// The message.
|
||||
message: MidiMessage,
|
||||
},
|
||||
/// Part or all of a system-exclusive message.
|
||||
SysEx {
|
||||
/// Milliseconds since this decoder started, with the counter's turnover already undone.
|
||||
at: u64,
|
||||
/// The bytes, including `0xF0` on the first run and `0xF7` on the last.
|
||||
bytes: &'a [u8],
|
||||
/// Whether more is to come.
|
||||
end: SysExEnd,
|
||||
},
|
||||
}
|
||||
|
||||
/// Reads packets from one peripheral, carrying the state that spans them.
|
||||
///
|
||||
/// One decoder belongs to one link. The timestamp counter's turnover and an unfinished dump both
|
||||
/// continue across packets, so a decoder shared between two devices would splice one device's
|
||||
/// dump onto the other's and read both clocks as one.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Decoder {
|
||||
/// The last thirteen-bit timestamp seen, which is how the counter's turnover is noticed.
|
||||
last_ticks: Option<u16>,
|
||||
/// Milliseconds already accounted for by earlier turnovers.
|
||||
epoch: u64,
|
||||
/// Whether a dump begun in an earlier packet is still open.
|
||||
in_sysex: bool,
|
||||
}
|
||||
|
||||
impl Decoder {
|
||||
/// Creates a decoder with no packet history.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Reads one packet, handing each message to `emit` in the order it appeared.
|
||||
///
|
||||
/// Emits through a closure rather than returning a collection because this runs on the data
|
||||
/// path, where a returned `Vec` would mean allocating per notification. Whatever was read
|
||||
/// before a malformed byte has already been emitted when the error is returned, because a
|
||||
/// packet that goes wrong half-way through still delivered the notes in its first half.
|
||||
pub fn decode<'a>(
|
||||
&mut self,
|
||||
packet: &'a [u8],
|
||||
emit: &mut impl FnMut(Event<'a>),
|
||||
) -> Result<(), DecodeError> {
|
||||
// Read the header, which carries the high six bits every timestamp in this packet shares.
|
||||
let Some(header) = packet.first().copied() else {
|
||||
return Err(DecodeError::Empty);
|
||||
};
|
||||
if header < 0x80 {
|
||||
return Err(DecodeError::NotAHeader(header));
|
||||
}
|
||||
let mut clock = PacketClock {
|
||||
high: header & 0x3F,
|
||||
low: None,
|
||||
};
|
||||
let mut running: Option<u8> = None;
|
||||
let mut index = 1;
|
||||
|
||||
while index < packet.len() {
|
||||
// A dump carries on until something ends it, whether it began in this packet, in an
|
||||
// earlier one, or before the real-time message that just interrupted it. Its payload
|
||||
// bytes carry no timestamps, so they are read here rather than below.
|
||||
if self.in_sysex {
|
||||
let resume = self.sysex_run(packet, index, false, self.now(), &mut clock, emit);
|
||||
if resume > index {
|
||||
index = resume;
|
||||
continue;
|
||||
}
|
||||
if self.in_sysex {
|
||||
// The packet ended on a timestamp byte with nothing behind it.
|
||||
return Err(DecodeError::Truncated);
|
||||
}
|
||||
// The dump was abandoned, and the byte it stopped on times what follows.
|
||||
}
|
||||
|
||||
let Some(byte) = packet.get(index).copied() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// A byte with the high bit set here is a timestamp, not a status: the status byte it
|
||||
// introduces is the one after it.
|
||||
let at = if byte >= 0x80 {
|
||||
index = index.saturating_add(1);
|
||||
let ticks = clock.tick(byte);
|
||||
self.advance(ticks)
|
||||
} else {
|
||||
self.now()
|
||||
};
|
||||
|
||||
let Some(status) = packet.get(index).copied() else {
|
||||
return Err(DecodeError::Truncated);
|
||||
};
|
||||
|
||||
// Begin a dump, which may well outlast this packet. The loop above reads it, because
|
||||
// what follows the opening byte is the same continuation it already handles.
|
||||
if status == SYSEX_START {
|
||||
// System common clears running status, and this is the loudest case of it.
|
||||
running = None;
|
||||
self.in_sysex = true;
|
||||
let resume = self.sysex_run(packet, index, true, at, &mut clock, emit);
|
||||
if resume <= index {
|
||||
return Ok(());
|
||||
}
|
||||
index = resume;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Real-time bytes belong to no other message, so they never touch running status.
|
||||
if status >= FIRST_REALTIME {
|
||||
emit(Event::Message {
|
||||
at,
|
||||
message: MidiMessage::System { status },
|
||||
});
|
||||
index = index.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A terminator with nothing open frames nothing, so passing it on would only confuse
|
||||
// a receiver.
|
||||
if status == SYSEX_END {
|
||||
index = index.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(rest) = packet.get(index..) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some((message, consumed)) = MidiMessage::parse(rest, running) else {
|
||||
return Err(if status < 0x80 && running.is_none() {
|
||||
DecodeError::Orphaned(status)
|
||||
} else {
|
||||
DecodeError::Truncated
|
||||
});
|
||||
};
|
||||
if status >= 0x80 {
|
||||
running = (message.status() < 0xF0).then_some(message.status());
|
||||
}
|
||||
emit(Event::Message { at, message });
|
||||
if consumed == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
index = index.saturating_add(consumed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emits one run of system-exclusive bytes, returning where parsing resumes.
|
||||
///
|
||||
/// `start` points either at the `0xF0` that opens a dump, which `opens` says, or at the first
|
||||
/// byte of one already in progress. The caller says which because the byte cannot: a
|
||||
/// continuation may begin with a timestamp, and a timestamp of 112 is `0xF0`. Unlike a plain
|
||||
/// MIDI stream, the terminator does not follow the payload
|
||||
/// directly — a timestamp byte sits between them — so it is emitted as a run of its own to
|
||||
/// keep the framing bytes travelling with the message.
|
||||
fn sysex_run<'a>(
|
||||
&mut self,
|
||||
packet: &'a [u8],
|
||||
start: usize,
|
||||
opens: bool,
|
||||
at: u64,
|
||||
clock: &mut PacketClock,
|
||||
emit: &mut impl FnMut(Event<'a>),
|
||||
) -> usize {
|
||||
// Find where the payload stops, which is the first byte carrying a high bit.
|
||||
let mut cursor = if opens {
|
||||
start.saturating_add(1)
|
||||
} else {
|
||||
start
|
||||
};
|
||||
while packet.get(cursor).is_some_and(|byte| *byte < 0x80) {
|
||||
cursor = cursor.saturating_add(1);
|
||||
}
|
||||
|
||||
// Nothing follows, which for a dump of any size is the ordinary case rather than a fault.
|
||||
if cursor >= packet.len() {
|
||||
emit_run(packet, start, cursor, at, SysExEnd::Open, emit);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
// The byte is a timestamp; the one after it says what interrupted the payload.
|
||||
let terminator = cursor.saturating_add(1);
|
||||
match packet.get(terminator).copied() {
|
||||
Some(SYSEX_END) => {
|
||||
emit_run(packet, start, cursor, at, SysExEnd::Open, emit);
|
||||
let ends_at = self.stamp(packet, cursor, clock, at);
|
||||
emit_run(
|
||||
packet,
|
||||
terminator,
|
||||
terminator.saturating_add(1),
|
||||
ends_at,
|
||||
SysExEnd::Complete,
|
||||
emit,
|
||||
);
|
||||
self.in_sysex = false;
|
||||
terminator.saturating_add(1)
|
||||
}
|
||||
// A real-time message may interrupt a dump without ending it.
|
||||
Some(status) if status >= FIRST_REALTIME => {
|
||||
emit_run(packet, start, cursor, at, SysExEnd::Open, emit);
|
||||
let sent_at = self.stamp(packet, cursor, clock, at);
|
||||
emit(Event::Message {
|
||||
at: sent_at,
|
||||
message: MidiMessage::System { status },
|
||||
});
|
||||
terminator.saturating_add(1)
|
||||
}
|
||||
// Anything else means the sender moved on and this dump will never be completed.
|
||||
Some(_) => {
|
||||
emit_run(packet, start, cursor, at, SysExEnd::Abandoned, emit);
|
||||
self.in_sysex = false;
|
||||
cursor
|
||||
}
|
||||
// The packet ended on a dangling timestamp byte. Resuming on it lets the caller
|
||||
// report the truncation rather than inventing a message to blame it on.
|
||||
None => {
|
||||
emit_run(packet, start, cursor, at, SysExEnd::Open, emit);
|
||||
cursor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances the clock over the timestamp byte at `index`, keeping `fallback` if it is not one.
|
||||
fn stamp(
|
||||
&mut self,
|
||||
packet: &[u8],
|
||||
index: usize,
|
||||
clock: &mut PacketClock,
|
||||
fallback: u64,
|
||||
) -> u64 {
|
||||
match packet.get(index).copied() {
|
||||
Some(byte) if byte >= 0x80 => {
|
||||
let ticks = clock.tick(byte);
|
||||
self.advance(ticks)
|
||||
}
|
||||
_ => fallback,
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns a thirteen-bit packet timestamp into a millisecond count that only moves forward.
|
||||
///
|
||||
/// The counter returns to zero every eight seconds, so a timestamp lower than the one before
|
||||
/// it is a turnover rather than time running backwards.
|
||||
fn advance(&mut self, ticks: u16) -> u64 {
|
||||
if self.last_ticks.is_some_and(|last| ticks < last) {
|
||||
self.epoch = self.epoch.saturating_add(TIMESTAMP_PERIOD);
|
||||
}
|
||||
self.last_ticks = Some(ticks);
|
||||
self.epoch.saturating_add(u64::from(ticks))
|
||||
}
|
||||
|
||||
/// The time the last timestamp named, for bytes that carry none of their own.
|
||||
fn now(&self) -> u64 {
|
||||
self.epoch
|
||||
.saturating_add(u64::from(self.last_ticks.unwrap_or(0)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The timestamp bits one packet carries: six opened by its header, seven per message.
|
||||
///
|
||||
/// The low seven bits count to 128 milliseconds, which a single packet can outlast, so the high
|
||||
/// bits carry rather than staying fixed for the whole packet.
|
||||
struct PacketClock {
|
||||
/// The high six bits, as opened by the header and carried since.
|
||||
high: u8,
|
||||
/// The low seven bits most recently read, which is how a carry is noticed.
|
||||
low: Option<u8>,
|
||||
}
|
||||
|
||||
impl PacketClock {
|
||||
/// Reads one timestamp byte, returning the thirteen-bit value it completes.
|
||||
fn tick(&mut self, byte: u8) -> u16 {
|
||||
let next = byte & 0x7F;
|
||||
if self.low.is_some_and(|previous| next < previous) {
|
||||
self.high = self.high.wrapping_add(1) & 0x3F;
|
||||
}
|
||||
self.low = Some(next);
|
||||
(u16::from(self.high) << 7) | u16::from(next)
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits one slice of system-exclusive bytes, skipping runs that say nothing.
|
||||
///
|
||||
/// An empty run still has to be reported when it ends a message, because that is how a consumer
|
||||
/// learns to stop waiting or to discard what it has.
|
||||
fn emit_run<'a>(
|
||||
packet: &'a [u8],
|
||||
start: usize,
|
||||
end: usize,
|
||||
at: u64,
|
||||
kind: SysExEnd,
|
||||
emit: &mut impl FnMut(Event<'a>),
|
||||
) {
|
||||
let bytes = packet.get(start..end).unwrap_or_default();
|
||||
if bytes.is_empty() && kind == SysExEnd::Open {
|
||||
return;
|
||||
}
|
||||
emit(Event::SysEx {
|
||||
at,
|
||||
bytes,
|
||||
end: kind,
|
||||
});
|
||||
}
|
||||
|
||||
/// Packs MIDI into packets no larger than the link will carry.
|
||||
///
|
||||
/// One encoder belongs to one link, because the packet being built and the timestamp bits in its
|
||||
/// header are per-link state.
|
||||
#[derive(Debug)]
|
||||
pub struct Encoder {
|
||||
/// The packet being built, header byte included.
|
||||
buffer: [u8; MAX_PACKET],
|
||||
/// How much of the buffer is in use.
|
||||
used: usize,
|
||||
/// The largest packet this link will carry.
|
||||
capacity: usize,
|
||||
/// The seven timestamp bits most recently written into the open packet.
|
||||
low: Option<u8>,
|
||||
}
|
||||
|
||||
impl Encoder {
|
||||
/// Creates an encoder for a link that will carry `capacity` bytes per write.
|
||||
///
|
||||
/// The value is clamped rather than rejected: a link that negotiates an unusable MTU should
|
||||
/// send small packets, not refuse to send.
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
buffer: [0; MAX_PACKET],
|
||||
used: 0,
|
||||
capacity: capacity.clamp(MIN_PACKET, MAX_PACKET),
|
||||
low: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds one message, emitting a packet whenever one fills.
|
||||
///
|
||||
/// `at` is a millisecond count of the caller's choosing; only its position within the
|
||||
/// thirteen-bit cycle travels over the link.
|
||||
pub fn push(&mut self, at: u64, message: &MidiMessage, emit: &mut impl FnMut(&[u8])) {
|
||||
let mut encoded = [0_u8; 3];
|
||||
let len = message.encode(&mut encoded);
|
||||
let Some(bytes) = encoded.get(..len) else {
|
||||
return;
|
||||
};
|
||||
if bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start a packet whose header can carry this timestamp, flushing one that cannot.
|
||||
self.open_for(at, bytes.len().saturating_add(1), emit);
|
||||
self.write_timestamp(at);
|
||||
for byte in bytes {
|
||||
self.write(*byte);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds one whole system-exclusive message, spreading it over as many packets as it needs.
|
||||
///
|
||||
/// The payload is the complete message, both framing bytes included, because a dump that is
|
||||
/// only half a message has no legitimate place on the wire.
|
||||
pub fn push_sysex(
|
||||
&mut self,
|
||||
at: u64,
|
||||
payload: &[u8],
|
||||
emit: &mut impl FnMut(&[u8]),
|
||||
) -> Result<(), EncodeError> {
|
||||
// Validate the framing.
|
||||
if payload.first().copied() != Some(SYSEX_START)
|
||||
|| payload.last().copied() != Some(SYSEX_END)
|
||||
|| payload.len() < 2
|
||||
{
|
||||
return Err(EncodeError::NotFramed);
|
||||
}
|
||||
let Some(body) = payload.get(1..payload.len().saturating_sub(1)) else {
|
||||
return Err(EncodeError::NotFramed);
|
||||
};
|
||||
|
||||
// Open the dump, which needs room for a timestamp and the start byte.
|
||||
self.open_for(at, 2, emit);
|
||||
self.write_timestamp(at);
|
||||
self.write(SYSEX_START);
|
||||
|
||||
// Payload bytes carry no timestamps, so a continuation packet is a header and then data.
|
||||
for byte in body {
|
||||
if self.used >= self.capacity {
|
||||
self.flush(emit);
|
||||
self.write_header(at);
|
||||
}
|
||||
self.write(*byte);
|
||||
}
|
||||
|
||||
// Close it, which needs room for a timestamp and the terminator together.
|
||||
if self.used.saturating_add(2) > self.capacity {
|
||||
self.flush(emit);
|
||||
self.write_header(at);
|
||||
}
|
||||
self.write_timestamp(at);
|
||||
self.write(SYSEX_END);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emits the packet being built, if there is one.
|
||||
///
|
||||
/// A packet is never held back waiting for company: latency is the whole point of this
|
||||
/// transport, so the caller flushes as soon as it has nothing more to add.
|
||||
pub fn flush(&mut self, emit: &mut impl FnMut(&[u8])) {
|
||||
if self.used == 0 {
|
||||
return;
|
||||
}
|
||||
if let Some(packet) = self.buffer.get(..self.used) {
|
||||
emit(packet);
|
||||
}
|
||||
self.used = 0;
|
||||
self.low = None;
|
||||
}
|
||||
|
||||
/// Makes room for `needed` bytes at timestamp `at`, flushing the open packet if it cannot.
|
||||
///
|
||||
/// A packet's header fixes the high six bits of every timestamp in it, so a message that has
|
||||
/// moved past that window needs a packet of its own even when the old one has space.
|
||||
fn open_for(&mut self, at: u64, needed: usize, emit: &mut impl FnMut(&[u8])) {
|
||||
let ticks = ticks_of(at);
|
||||
let high = high_bits(ticks);
|
||||
let low = low_bits(ticks);
|
||||
|
||||
let header_fits = self
|
||||
.buffer
|
||||
.first()
|
||||
.is_some_and(|byte| *byte == (0x80 | high));
|
||||
// A timestamp lower than the last one in this packet would read as the counter turning
|
||||
// over, which would put the message eight seconds into the future.
|
||||
let ordered = self.low.is_none_or(|previous| low >= previous);
|
||||
|
||||
if self.used > 0 && (!header_fits || !ordered) {
|
||||
self.flush(emit);
|
||||
}
|
||||
if self.used > 0 && self.used.saturating_add(needed) > self.capacity {
|
||||
self.flush(emit);
|
||||
}
|
||||
if self.used == 0 {
|
||||
self.write_header(at);
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the header byte that opens a packet.
|
||||
fn write_header(&mut self, at: u64) {
|
||||
self.write(0x80 | high_bits(ticks_of(at)));
|
||||
self.low = None;
|
||||
}
|
||||
|
||||
/// Writes the timestamp byte that precedes a message.
|
||||
fn write_timestamp(&mut self, at: u64) {
|
||||
let low = low_bits(ticks_of(at));
|
||||
self.write(0x80 | low);
|
||||
self.low = Some(low);
|
||||
}
|
||||
|
||||
/// Appends one byte, dropping it rather than overrunning the buffer.
|
||||
///
|
||||
/// Callers reserve space before writing, so a drop here means a bug above rather than a full
|
||||
/// link, and losing the byte is still better than a panic on the data path.
|
||||
fn write(&mut self, byte: u8) {
|
||||
if let Some(slot) = self.buffer.get_mut(self.used) {
|
||||
*slot = byte;
|
||||
self.used = self.used.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The position of a millisecond count within the thirteen-bit timestamp cycle.
|
||||
fn ticks_of(at: u64) -> u16 {
|
||||
(at % TIMESTAMP_PERIOD) as u16
|
||||
}
|
||||
|
||||
/// The six timestamp bits carried in a packet's header byte.
|
||||
fn high_bits(ticks: u16) -> u8 {
|
||||
((ticks >> 7) & 0x3F) as u8
|
||||
}
|
||||
|
||||
/// The seven timestamp bits carried in a message's timestamp byte.
|
||||
fn low_bits(ticks: u16) -> u8 {
|
||||
(ticks & 0x7F) as u8
|
||||
}
|
||||
11
crates/blemidi/src/lib.rs
Normal file
11
crates/blemidi/src/lib.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
//! Bluetooth LE MIDI packet codec.
|
||||
//!
|
||||
//! Pure framing, with no radio and no platform behind it, so every rule in the format is testable
|
||||
//! on a machine with no Bluetooth at all.
|
||||
|
||||
pub mod codec;
|
||||
|
||||
pub use codec::{
|
||||
CHARACTERISTIC_UUID, DecodeError, Decoder, EncodeError, Encoder, Event, MAX_PACKET, MIN_PACKET,
|
||||
SERVICE_UUID, TIMESTAMP_PERIOD,
|
||||
};
|
||||
381
crates/blemidi/tests/codec.rs
Normal file
381
crates/blemidi/tests/codec.rs
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
//! The three parts of BLE MIDI framing that are known to break in the field.
|
||||
//!
|
||||
//! A thirteen-bit millisecond counter turns over every eight seconds, running status may be sent
|
||||
//! by a device but must not be assumed to survive a packet boundary, and a dump larger than the
|
||||
//! MTU is spread over packets that carry no timestamps of their own. Each is a place where a
|
||||
//! plausible-looking parser produces plausible-looking nonsense, so each gets a test that would
|
||||
//! fail rather than merely producing different notes.
|
||||
//!
|
||||
//! Byte layouts follow the BLE-MIDI 1.0 specification (MMA/AMEI, 2015): a header byte
|
||||
//! `10hhhhhh` carrying the high six timestamp bits, then a timestamp byte `1lllllll` carrying the
|
||||
//! low seven before each message, so a timestamp in milliseconds is `high * 128 + low`.
|
||||
|
||||
#![allow(
|
||||
clippy::expect_used,
|
||||
clippy::indexing_slicing,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)]
|
||||
|
||||
use midi_harbor_blemidi::codec::{
|
||||
DecodeError, Decoder, EncodeError, Encoder, Event, MAX_PACKET, MIN_PACKET, TIMESTAMP_PERIOD,
|
||||
};
|
||||
use midi_harbor_core::midi::{Channel, MidiMessage};
|
||||
use midi_harbor_core::stream::SysExEnd;
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// What a decode produced, copied so system-exclusive runs outlive the packet they came from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum Read {
|
||||
Message(u64, MidiMessage),
|
||||
SysEx(u64, Vec<u8>, SysExEnd),
|
||||
}
|
||||
|
||||
/// Reads one packet, returning everything it carried and any complaint about how it ended.
|
||||
fn decode(decoder: &mut Decoder, packet: &[u8]) -> (Vec<Read>, Result<(), DecodeError>) {
|
||||
let mut out = Vec::new();
|
||||
let result = decoder.decode(packet, &mut |event| match event {
|
||||
Event::Message { at, message } => out.push(Read::Message(at, message)),
|
||||
Event::SysEx { at, bytes, end } => out.push(Read::SysEx(at, bytes.to_vec(), end)),
|
||||
});
|
||||
(out, result)
|
||||
}
|
||||
|
||||
/// Encodes a run of timestamped messages into whole packets.
|
||||
fn encode(capacity: usize, messages: &[(u64, MidiMessage)]) -> Vec<Vec<u8>> {
|
||||
let mut encoder = Encoder::new(capacity);
|
||||
let mut packets = Vec::new();
|
||||
for (at, message) in messages {
|
||||
encoder.push(*at, message, &mut |packet| packets.push(packet.to_vec()));
|
||||
}
|
||||
encoder.flush(&mut |packet| packets.push(packet.to_vec()));
|
||||
packets
|
||||
}
|
||||
|
||||
/// Reads a whole run of packets through one decoder, as a link would.
|
||||
fn decode_all(packets: &[Vec<u8>]) -> Vec<Read> {
|
||||
let mut decoder = Decoder::new();
|
||||
let mut out = Vec::new();
|
||||
for packet in packets {
|
||||
let (read, result) = decode(&mut decoder, packet);
|
||||
assert_eq!(
|
||||
result,
|
||||
Ok(()),
|
||||
"packet {packet:02x?} came from the encoder, so it must decode"
|
||||
);
|
||||
out.extend(read);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn channel(index: u8) -> Channel {
|
||||
Channel::new(index % 16).expect("index is masked into range")
|
||||
}
|
||||
|
||||
fn note_on(note: u8) -> MidiMessage {
|
||||
MidiMessage::NoteOn {
|
||||
channel: channel(0),
|
||||
note,
|
||||
velocity: 100,
|
||||
}
|
||||
}
|
||||
|
||||
prop_compose! {
|
||||
/// Any message the encoder can carry, which is every channel message plus real time.
|
||||
fn any_message()(
|
||||
kind in 0_u8..8,
|
||||
channel_index in 0_u8..16,
|
||||
first in 0_u8..128,
|
||||
second in 0_u8..128,
|
||||
realtime in 0xF8_u8..=0xFF,
|
||||
) -> MidiMessage {
|
||||
let channel = channel(channel_index);
|
||||
match kind {
|
||||
0 => MidiMessage::NoteOff { channel, note: first, velocity: second },
|
||||
1 => MidiMessage::NoteOn { channel, note: first, velocity: second },
|
||||
2 => MidiMessage::PolyAftertouch { channel, note: first, pressure: second },
|
||||
3 => MidiMessage::ControlChange { channel, controller: first, value: second },
|
||||
4 => MidiMessage::ProgramChange { channel, program: first },
|
||||
5 => MidiMessage::ChannelAftertouch { channel, pressure: first },
|
||||
6 => MidiMessage::PitchBend {
|
||||
channel,
|
||||
value: (u16::from(second) << 7) | u16::from(first),
|
||||
},
|
||||
_ => MidiMessage::System { status: realtime },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// Whatever goes in comes out, in order, however the packets happen to divide, and every
|
||||
/// packet stands alone.
|
||||
///
|
||||
/// Packets are separate attribute writes and any one can be lost, so the encoder never uses
|
||||
/// running status: byte 2 of every packet, after the header and the first timestamp, must be
|
||||
/// a status byte. A receiver that saw only that packet can then still read it.
|
||||
#[test]
|
||||
fn every_message_survives_the_round_trip(
|
||||
messages in prop::collection::vec(any_message(), 1..40),
|
||||
capacity in MIN_PACKET..=64_usize,
|
||||
) {
|
||||
// Messages are spaced a millisecond apart so the run crosses packet boundaries on
|
||||
// timestamp changes as well as on capacity.
|
||||
let timed: Vec<_> = messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(step, message)| (step as u64, *message))
|
||||
.collect();
|
||||
|
||||
let packets = encode(capacity, &timed);
|
||||
for packet in &packets {
|
||||
prop_assert!(
|
||||
packet.len() <= capacity,
|
||||
"packet {:02x?} is longer than the {} bytes the link can write", packet, capacity
|
||||
);
|
||||
prop_assert!(
|
||||
packet[2] & 0x80 == 0x80,
|
||||
"packet {:02x?} opens on a data byte, relying on status from an earlier packet",
|
||||
packet
|
||||
);
|
||||
}
|
||||
|
||||
let read = decode_all(&packets);
|
||||
let recovered: Vec<_> = read
|
||||
.iter()
|
||||
.map(|entry| match entry {
|
||||
Read::Message(at, message) => (*at, *message),
|
||||
Read::SysEx(..) => panic!("no dump was sent, so none can be read"),
|
||||
})
|
||||
.collect();
|
||||
prop_assert_eq!(recovered, timed, "the decoder must return exactly what was encoded");
|
||||
}
|
||||
|
||||
/// Timing survives the thirteen-bit counter returning to zero, however far into the cycle a
|
||||
/// run starts.
|
||||
///
|
||||
/// The counter holds `2^13 = 8192` milliseconds, so it turns over every 8.192 seconds. Four
|
||||
/// notes a second apart span three seconds, and whether the counter turns over part-way
|
||||
/// through depends entirely on where the run started.
|
||||
#[test]
|
||||
fn timestamps_stay_ordered_across_the_turnover(start in 0_u64..TIMESTAMP_PERIOD) {
|
||||
let timed: Vec<_> = (0..4)
|
||||
.map(|step| (start + step * 1000, note_on(60)))
|
||||
.collect();
|
||||
|
||||
let read = decode_all(&encode(MAX_PACKET, &timed));
|
||||
let times: Vec<u64> = read
|
||||
.iter()
|
||||
.map(|entry| match entry {
|
||||
Read::Message(at, _) => *at,
|
||||
Read::SysEx(at, ..) => *at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// The decoder counts from its own zero rather than the sender's, so what must hold is the
|
||||
// spacing, not the absolute value.
|
||||
prop_assert_eq!(times.len(), timed.len(), "every note sent must be read back");
|
||||
for pair in times.windows(2) {
|
||||
prop_assert_eq!(
|
||||
pair[1] - pair[0], 1000,
|
||||
"a one-second gap was lost across the turnover: {:?}", times
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A dump of any size arrives byte-identical however many packets it took.
|
||||
///
|
||||
/// Continuation packets carry a header and then data with no timestamp bytes, so a decoder
|
||||
/// that expected timestamps in them would eat every other byte of the dump.
|
||||
#[test]
|
||||
fn a_dump_split_across_packets_is_rejoined_byte_for_byte(
|
||||
body in prop::collection::vec(0_u8..128, 0..600),
|
||||
capacity in MIN_PACKET..=64_usize,
|
||||
) {
|
||||
let mut payload = vec![0xF0];
|
||||
payload.extend_from_slice(&body);
|
||||
payload.push(0xF7);
|
||||
|
||||
let mut encoder = Encoder::new(capacity);
|
||||
let mut packets = Vec::new();
|
||||
encoder
|
||||
.push_sysex(1234, &payload, &mut |packet| packets.push(packet.to_vec()))
|
||||
.expect("the payload is framed by F0 and F7, so the encoder must accept it");
|
||||
encoder.flush(&mut |packet| packets.push(packet.to_vec()));
|
||||
|
||||
prop_assert!(
|
||||
packets.iter().all(|packet| packet.len() <= capacity),
|
||||
"a dump packet is longer than the link can write"
|
||||
);
|
||||
|
||||
let mut rejoined = Vec::new();
|
||||
let mut ended = None;
|
||||
for entry in decode_all(&packets) {
|
||||
match entry {
|
||||
Read::SysEx(_, bytes, end) => {
|
||||
rejoined.extend_from_slice(&bytes);
|
||||
if end != SysExEnd::Open {
|
||||
ended = Some(end);
|
||||
}
|
||||
}
|
||||
Read::Message(_, message) => panic!("a dump alone produced {message:?}"),
|
||||
}
|
||||
}
|
||||
prop_assert_eq!(ended, Some(SysExEnd::Complete), "the dump must be read as complete");
|
||||
prop_assert_eq!(rejoined, payload, "the dump must be rejoined byte for byte");
|
||||
}
|
||||
}
|
||||
|
||||
/// Packets as devices write them decode as the BLE-MIDI 1.0 specification frames them, and the
|
||||
/// malformed ones are refused rather than guessed at.
|
||||
///
|
||||
/// Running status is accepted inside a packet, because devices send it, but not across a packet
|
||||
/// boundary, because a lost packet between the two would attach the data to the wrong status. A
|
||||
/// real-time byte may interrupt a dump with its own timestamp byte before it, which is the shape a
|
||||
/// naive parser mistakes for the terminator. A dump interrupted by a channel message was
|
||||
/// abandoned by its sender and must say so, or a receiver acts on the fragment. Timestamps in the
|
||||
/// wants are `high * 128 + low` from the header and timestamp bytes: `0x82, 0xC0` is
|
||||
/// `2 * 128 + 64 = 320`, and a timestamp byte of `0xF0` is `0x70 = 112`.
|
||||
#[test]
|
||||
fn device_packets_decode_as_the_specification_frames_them() {
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
packets: &'static [&'static [u8]],
|
||||
want: Vec<Read>,
|
||||
want_last: Result<(), DecodeError>,
|
||||
}
|
||||
let cases = [
|
||||
Case {
|
||||
name: "running status inside one packet",
|
||||
packets: &[&[0x82, 0xC0, 0x90, 60, 100, 62, 100]],
|
||||
want: vec![
|
||||
Read::Message(320, note_on(60)),
|
||||
Read::Message(320, note_on(62)),
|
||||
],
|
||||
want_last: Ok(()),
|
||||
},
|
||||
Case {
|
||||
name: "running status carried across a packet boundary",
|
||||
packets: &[&[0x80, 0x80, 0x90, 60, 100], &[0x80, 0x80, 62, 100]],
|
||||
want: vec![Read::Message(0, note_on(60))],
|
||||
want_last: Err(DecodeError::Orphaned(62)),
|
||||
},
|
||||
Case {
|
||||
name: "a clock inside a dump",
|
||||
packets: &[&[
|
||||
0x80, 0x80, 0xF0, 0x43, 0x10, 0x81, 0xF8, 0x44, 0x45, 0x81, 0xF7,
|
||||
]],
|
||||
want: vec![
|
||||
Read::SysEx(0, vec![0xF0, 0x43, 0x10], SysExEnd::Open),
|
||||
Read::Message(1, MidiMessage::System { status: 0xF8 }),
|
||||
Read::SysEx(1, vec![0x44, 0x45], SysExEnd::Open),
|
||||
Read::SysEx(1, vec![0xF7], SysExEnd::Complete),
|
||||
],
|
||||
want_last: Ok(()),
|
||||
},
|
||||
Case {
|
||||
name: "a dump abandoned for a note",
|
||||
packets: &[&[0x80, 0x80, 0xF0, 0x43, 0x10, 0x81, 0x90, 60, 100]],
|
||||
want: vec![
|
||||
Read::SysEx(0, vec![0xF0, 0x43, 0x10], SysExEnd::Abandoned),
|
||||
Read::Message(1, note_on(60)),
|
||||
],
|
||||
want_last: Ok(()),
|
||||
},
|
||||
Case {
|
||||
name: "a continuation whose timestamp byte reads as 0xF0",
|
||||
packets: &[&[0x80, 0x80, 0xF0, 0x43], &[0x80, 0xF0, 0xF7]],
|
||||
want: vec![
|
||||
Read::SysEx(0, vec![0xF0, 0x43], SysExEnd::Open),
|
||||
Read::SysEx(112, vec![0xF7], SysExEnd::Complete),
|
||||
],
|
||||
want_last: Ok(()),
|
||||
},
|
||||
Case {
|
||||
name: "an empty write",
|
||||
packets: &[&[]],
|
||||
want: vec![],
|
||||
want_last: Err(DecodeError::Empty),
|
||||
},
|
||||
Case {
|
||||
name: "a write opening on a data byte",
|
||||
packets: &[&[0x00, 0x90, 60, 100]],
|
||||
want: vec![],
|
||||
want_last: Err(DecodeError::NotAHeader(0x00)),
|
||||
},
|
||||
Case {
|
||||
name: "a timestamp with no message after it",
|
||||
packets: &[&[0x80, 0x80]],
|
||||
want: vec![],
|
||||
want_last: Err(DecodeError::Truncated),
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
let mut decoder = Decoder::new();
|
||||
let mut read = Vec::new();
|
||||
let mut last = Ok(());
|
||||
for packet in case.packets {
|
||||
let (events, result) = decode(&mut decoder, packet);
|
||||
read.extend(events);
|
||||
last = result;
|
||||
}
|
||||
assert_eq!(
|
||||
read, case.want,
|
||||
"{}: the decoder must read exactly these events",
|
||||
case.name
|
||||
);
|
||||
assert_eq!(
|
||||
last, case.want_last,
|
||||
"{}: the last packet must end with this result",
|
||||
case.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The encoder sends a dump only when it is a whole message, framed by `F0` and `F7`.
|
||||
///
|
||||
/// Half a dump on the wire is worse than none: the receiver waits for a terminator that is never
|
||||
/// coming and swallows everything after it. The smallest framed dump, `F0 F7`, is the accepted
|
||||
/// boundary, and goes out as header, timestamp, `F0`, timestamp, `F7`, with every timestamp zero.
|
||||
#[test]
|
||||
fn only_a_framed_dump_is_sent() {
|
||||
let cases = [
|
||||
(
|
||||
"the smallest framed dump",
|
||||
vec![0xF0, 0xF7],
|
||||
Ok(()),
|
||||
vec![vec![0x80, 0x80, 0xF0, 0x80, 0xF7]],
|
||||
),
|
||||
(
|
||||
"a dump with its terminator missing",
|
||||
vec![0xF0, 0x43, 0x10],
|
||||
Err(EncodeError::NotFramed),
|
||||
vec![],
|
||||
),
|
||||
(
|
||||
"a dump with its opening byte missing",
|
||||
vec![0x43, 0x10, 0xF7],
|
||||
Err(EncodeError::NotFramed),
|
||||
vec![],
|
||||
),
|
||||
(
|
||||
"an opening byte alone",
|
||||
vec![0xF0],
|
||||
Err(EncodeError::NotFramed),
|
||||
vec![],
|
||||
),
|
||||
];
|
||||
|
||||
for (name, payload, want, want_packets) in cases {
|
||||
let mut encoder = Encoder::new(MAX_PACKET);
|
||||
let mut packets = Vec::new();
|
||||
let result = encoder.push_sysex(0, &payload, &mut |packet| packets.push(packet.to_vec()));
|
||||
encoder.flush(&mut |packet| packets.push(packet.to_vec()));
|
||||
|
||||
assert_eq!(result, want, "{name}: the framing decides acceptance");
|
||||
assert_eq!(
|
||||
packets, want_packets,
|
||||
"{name}: only an accepted dump may reach the wire"
|
||||
);
|
||||
}
|
||||
}
|
||||
27
crates/cli/Cargo.toml
Normal file
27
crates/cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[package]
|
||||
name = "midi-harbor-cli"
|
||||
description = "Command-line interface over the daemon IPC contract"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio-stream.workspace = true
|
||||
tonic.workspace = true
|
||||
serde.workspace = true
|
||||
midi-harbor-service.workspace = true
|
||||
midi-harbor-core.workspace = true
|
||||
midi-harbor-ipc.workspace = true
|
||||
midi-harbor-platform.workspace = true
|
||||
prost-types.workspace = true
|
||||
clap.workspace = true
|
||||
jiff.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
78
crates/cli/src/client.rs
Normal file
78
crates/cli/src/client.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
//! Connecting to the daemon.
|
||||
|
||||
use midi_harbor_core::paths::Paths;
|
||||
use midi_harbor_ipc::pb::{GetServerInfoRequest, ServerInfo};
|
||||
use midi_harbor_ipc::transport::{self, TransportError};
|
||||
use midi_harbor_ipc::{HarborClient, check_compatibility};
|
||||
use std::path::PathBuf;
|
||||
use tonic::transport::Channel;
|
||||
|
||||
/// What the user should be told when the daemon cannot be used.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ClientError {
|
||||
/// No daemon is listening.
|
||||
///
|
||||
/// Reported with the two ways to fix it, because "connection refused" sends a user looking at
|
||||
/// permissions when the answer is that nothing is running.
|
||||
#[error(
|
||||
"the daemon is not running\n\
|
||||
run 'midi-harbor service install --start' to install it and start it at login,\n\
|
||||
or 'midi-harbor daemon' to run it in the foreground"
|
||||
)]
|
||||
NotRunning,
|
||||
/// The client and daemon cannot talk to each other.
|
||||
#[error("{0}")]
|
||||
Version(String),
|
||||
/// The socket could not be reached.
|
||||
#[error("{0}")]
|
||||
Transport(#[from] TransportError),
|
||||
/// The daemon refused the first call.
|
||||
#[error("the daemon did not respond: {0}")]
|
||||
Rpc(#[from] tonic::Status),
|
||||
}
|
||||
|
||||
/// A connected client, already checked for version compatibility.
|
||||
pub struct Client {
|
||||
inner: HarborClient<Channel>,
|
||||
server: ServerInfo,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Connects to the daemon and verifies that both sides speak the same contract.
|
||||
///
|
||||
/// The version check happens before anything else, so a mismatch is reported as a mismatch
|
||||
/// rather than surfacing later as a call that mysteriously does not exist.
|
||||
pub async fn connect(socket: Option<PathBuf>) -> Result<Self, ClientError> {
|
||||
let socket = match socket {
|
||||
Some(path) => path,
|
||||
None => Paths::resolve()
|
||||
.map_err(|_| ClientError::NotRunning)?
|
||||
.socket_file(),
|
||||
};
|
||||
|
||||
if !transport::probe(&socket).await {
|
||||
return Err(ClientError::NotRunning);
|
||||
}
|
||||
let channel = transport::connect(&socket).await?;
|
||||
let mut inner = HarborClient::new(channel);
|
||||
|
||||
let server = inner
|
||||
.get_server_info(GetServerInfoRequest {})
|
||||
.await?
|
||||
.into_inner();
|
||||
if let Err(mismatch) = check_compatibility(&server) {
|
||||
return Err(ClientError::Version(mismatch.guidance()));
|
||||
}
|
||||
Ok(Self { inner, server })
|
||||
}
|
||||
|
||||
/// Returns the generated client for making calls.
|
||||
pub fn rpc(&mut self) -> &mut HarborClient<Channel> {
|
||||
&mut self.inner
|
||||
}
|
||||
|
||||
/// Returns what the daemon reported about itself.
|
||||
pub fn server(&self) -> &ServerInfo {
|
||||
&self.server
|
||||
}
|
||||
}
|
||||
688
crates/cli/src/commands.rs
Normal file
688
crates/cli/src/commands.rs
Normal file
|
|
@ -0,0 +1,688 @@
|
|||
//! The command tree.
|
||||
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Midi Harbor's command-line interface.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "midi-harbor",
|
||||
version = midi_harbor_core::VERSION,
|
||||
about = "Resilient MIDI connectivity for macOS, Linux and Windows",
|
||||
disable_help_subcommand = true
|
||||
)]
|
||||
pub struct Cli {
|
||||
/// Emit machine-readable output on stdout.
|
||||
#[arg(long, global = true)]
|
||||
pub json: bool,
|
||||
|
||||
/// Use a specific daemon socket instead of the standard location.
|
||||
#[arg(long, global = true, value_name = "PATH")]
|
||||
pub socket: Option<PathBuf>,
|
||||
|
||||
/// Raise log verbosity. Repeat for more.
|
||||
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
||||
pub verbose: u8,
|
||||
|
||||
/// Suppress output that is not an error.
|
||||
#[arg(long, global = true, conflicts_with = "verbose")]
|
||||
pub quiet: bool,
|
||||
|
||||
/// The action to perform. Omitted, the graphical interface starts.
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
}
|
||||
|
||||
/// What the user asked for.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Command {
|
||||
/// Run the engine in the foreground, registering nothing with the system.
|
||||
Daemon {
|
||||
/// Write the log to this file instead of standard error, rolling it over as it grows.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
log_file: Option<std::path::PathBuf>,
|
||||
|
||||
/// Run the engine as a child and start it again whenever it fails.
|
||||
///
|
||||
/// For service managers that do not restart a failed program themselves, which on the
|
||||
/// supported platforms means Task Scheduler.
|
||||
#[arg(long, hide = true)]
|
||||
supervise: bool,
|
||||
},
|
||||
|
||||
/// Open the graphical interface.
|
||||
Gui,
|
||||
|
||||
/// Install and control the background service.
|
||||
#[command(subcommand)]
|
||||
Service(ServiceCommand),
|
||||
|
||||
/// Manage virtual MIDI ports.
|
||||
#[command(subcommand)]
|
||||
Port(PortCommand),
|
||||
|
||||
/// Manage network ports: MIDI over the network, which Apple calls network sessions.
|
||||
///
|
||||
/// `session` is accepted as well, the name these commands had before.
|
||||
#[command(subcommand, alias = "session")]
|
||||
Network(NetworkCommand),
|
||||
|
||||
/// Manage MIDI connections between endpoints.
|
||||
#[command(subcommand)]
|
||||
Route(RouteCommand),
|
||||
|
||||
/// Inspect attached MIDI hardware.
|
||||
#[command(subcommand)]
|
||||
Device(DeviceCommand),
|
||||
|
||||
/// Connect Bluetooth MIDI devices, and offer this machine as one.
|
||||
#[command(subcommand)]
|
||||
Bluetooth(BluetoothCommand),
|
||||
|
||||
/// Show endpoints and their connection health.
|
||||
Status {
|
||||
/// Redraw as state changes.
|
||||
#[arg(long)]
|
||||
watch: bool,
|
||||
},
|
||||
|
||||
/// Show what has happened to connections.
|
||||
Events {
|
||||
/// Show only events newer than this identifier.
|
||||
#[arg(long)]
|
||||
since: Option<u64>,
|
||||
/// Limit how many are shown.
|
||||
#[arg(long, default_value_t = 50)]
|
||||
limit: u32,
|
||||
/// Keep watching for new events instead of exiting.
|
||||
#[arg(long)]
|
||||
follow: bool,
|
||||
},
|
||||
|
||||
/// Watch the MIDI passing through an endpoint.
|
||||
Monitor {
|
||||
/// The endpoint to watch, by name or identifier.
|
||||
endpoint: String,
|
||||
/// Show raw bytes as well as the decoded message.
|
||||
#[arg(long)]
|
||||
raw: bool,
|
||||
},
|
||||
|
||||
/// Clear the warning that the MIDI service stopped, for every window and `status`, once it
|
||||
/// has been seen.
|
||||
DismissWarning,
|
||||
|
||||
/// Send one note out of an endpoint, to test what listens there.
|
||||
///
|
||||
/// The daemon sends the note-off itself once the note has sounded for its length.
|
||||
SendNote {
|
||||
/// The endpoint to send out of, by name or identifier.
|
||||
endpoint: String,
|
||||
/// The note, 0 to 127; 60 is middle C.
|
||||
#[arg(long, default_value_t = 60)]
|
||||
note: u32,
|
||||
/// The channel, 1 to 16.
|
||||
#[arg(long, default_value_t = 1)]
|
||||
channel: u32,
|
||||
/// The velocity, 1 to 127.
|
||||
#[arg(long, default_value_t = 100)]
|
||||
velocity: u32,
|
||||
/// How long the note sounds, in milliseconds, up to 10000.
|
||||
#[arg(long, default_value_t = 500)]
|
||||
length: u32,
|
||||
},
|
||||
|
||||
/// Write a diagnostic report for a bug report.
|
||||
#[command(subcommand)]
|
||||
Diagnostics(DiagnosticsCommand),
|
||||
|
||||
/// Show which capabilities are available on this machine.
|
||||
Capabilities,
|
||||
|
||||
/// Inspect the configuration file.
|
||||
#[command(subcommand)]
|
||||
Config(ConfigCommand),
|
||||
}
|
||||
|
||||
/// Service installation and control.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum ServiceCommand {
|
||||
/// Register the daemon to start at login.
|
||||
Install {
|
||||
/// Start it immediately as well.
|
||||
#[arg(long)]
|
||||
start: bool,
|
||||
},
|
||||
/// Deregister the daemon, leaving configuration untouched.
|
||||
Uninstall,
|
||||
/// Start the installed service.
|
||||
Start,
|
||||
/// Stop the installed service.
|
||||
Stop,
|
||||
/// Report whether it is installed, running, and whether its registration is stale.
|
||||
Status,
|
||||
}
|
||||
|
||||
/// Virtual port management.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum PortCommand {
|
||||
/// List virtual ports.
|
||||
List,
|
||||
/// Create a virtual port other applications can see.
|
||||
///
|
||||
/// It has MIDI In connectors, which other applications send to, and MIDI Out connectors,
|
||||
/// which they receive from, one of each unless more are asked for. Several of one kind show
|
||||
/// to applications as numbered ports, "Keys 1" and "Keys 2".
|
||||
Create {
|
||||
/// The name other applications will show.
|
||||
name: String,
|
||||
/// MIDI In connectors, one to sixteen.
|
||||
#[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u8).range(1..=16))]
|
||||
inputs: u8,
|
||||
/// MIDI Out connectors, one to sixteen.
|
||||
#[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u8).range(1..=16))]
|
||||
outputs: u8,
|
||||
/// No longer used: every virtual port has a MIDI In and a MIDI Out. Accepted so
|
||||
/// scripts written before connectors keep running.
|
||||
#[arg(long, value_enum, hide = true)]
|
||||
direction: Option<DirectionArg>,
|
||||
},
|
||||
/// Change how many MIDI In and MIDI Out connectors a port has.
|
||||
///
|
||||
/// Routes on connectors it no longer has are removed, and the port is reopened, so
|
||||
/// applications using it may need to choose it again.
|
||||
Connectors {
|
||||
/// The port to change, by name or identifier.
|
||||
target: String,
|
||||
/// MIDI In connectors, one to sixteen.
|
||||
#[arg(long, value_parser = clap::value_parser!(u8).range(1..=16))]
|
||||
inputs: u8,
|
||||
/// MIDI Out connectors, one to sixteen.
|
||||
#[arg(long, value_parser = clap::value_parser!(u8).range(1..=16))]
|
||||
outputs: u8,
|
||||
},
|
||||
/// Rename a port, rewriting the routes that name it.
|
||||
Rename {
|
||||
/// The port to rename, by name or identifier.
|
||||
target: String,
|
||||
/// The new name.
|
||||
new_name: String,
|
||||
/// Confirm that connected applications may need to reselect the port.
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Delete a port, silencing it first.
|
||||
Delete {
|
||||
/// The port to delete, by name or identifier.
|
||||
target: String,
|
||||
/// Confirm the deletion. Without it, nothing is deleted.
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Switch a port on.
|
||||
Enable {
|
||||
/// The port to enable, by name or identifier.
|
||||
target: String,
|
||||
},
|
||||
/// Switch a port off without deleting its configuration.
|
||||
Disable {
|
||||
/// The port to disable, by name or identifier.
|
||||
target: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Connections between endpoints.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum RouteCommand {
|
||||
/// List connections.
|
||||
List {
|
||||
/// Show only connections whose endpoints are missing.
|
||||
#[arg(long)]
|
||||
broken: bool,
|
||||
},
|
||||
/// Connect one endpoint to another.
|
||||
Create {
|
||||
/// Where MIDI comes from, by name.
|
||||
from: String,
|
||||
/// Where MIDI goes, by name.
|
||||
to: String,
|
||||
/// Which of the source's MIDI In connectors, for a virtual port with several.
|
||||
#[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u8).range(1..=16))]
|
||||
from_connector: u8,
|
||||
/// Which of the destination's MIDI Out connectors, for a virtual port with several.
|
||||
#[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u8).range(1..=16))]
|
||||
to_connector: u8,
|
||||
/// Carry MIDI back as well, from TO to FROM, as one route.
|
||||
#[arg(long)]
|
||||
both_ways: bool,
|
||||
},
|
||||
/// Change a route's ends, connectors, or whether it carries MIDI both ways.
|
||||
///
|
||||
/// Only what is given changes. Its identifier changes when its ends do.
|
||||
Edit {
|
||||
/// The route's identifier, from `route list`.
|
||||
id: String,
|
||||
/// Where MIDI comes from, by name.
|
||||
#[arg(long)]
|
||||
from: Option<String>,
|
||||
/// Where MIDI goes, by name.
|
||||
#[arg(long)]
|
||||
to: Option<String>,
|
||||
/// Which of the source's MIDI In connectors.
|
||||
#[arg(long, value_parser = clap::value_parser!(u8).range(1..=16))]
|
||||
from_connector: Option<u8>,
|
||||
/// Which of the destination's MIDI Out connectors.
|
||||
#[arg(long, value_parser = clap::value_parser!(u8).range(1..=16))]
|
||||
to_connector: Option<u8>,
|
||||
/// Carry MIDI back as well.
|
||||
#[arg(long, conflicts_with = "one_way")]
|
||||
both_ways: bool,
|
||||
/// Carry MIDI one way only.
|
||||
#[arg(long)]
|
||||
one_way: bool,
|
||||
},
|
||||
/// Remove a connection.
|
||||
Delete {
|
||||
/// The connection's identifier, from `route list`.
|
||||
id: String,
|
||||
},
|
||||
/// Switch a connection on.
|
||||
Enable {
|
||||
/// The connection's identifier.
|
||||
id: String,
|
||||
},
|
||||
/// Switch a connection off without removing it.
|
||||
Disable {
|
||||
/// The connection's identifier.
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Attached MIDI hardware.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum DeviceCommand {
|
||||
/// List attached hardware, and the ports the system or other applications provide.
|
||||
List {
|
||||
/// Include hardware that is remembered but not plugged in.
|
||||
#[arg(long)]
|
||||
all: bool,
|
||||
},
|
||||
/// Say which of two identical devices a remembered entry means.
|
||||
///
|
||||
/// Hardware that is identical in every way but where it is plugged in cannot be told apart,
|
||||
/// so the entry is left unbound rather than guessing. Naming the one that was meant binds it.
|
||||
Resolve {
|
||||
/// The remembered entry, by name or identifier.
|
||||
device: String,
|
||||
/// The attached hardware to bind it to, by name or identifier. Omitted, the candidates
|
||||
/// are listed.
|
||||
chosen: Option<String>,
|
||||
},
|
||||
/// Forget remembered hardware and everything configured about it.
|
||||
///
|
||||
/// Hardware that is still attached comes straight back as something never seen before, which
|
||||
/// is how to start over with a device whose settings have gone wrong.
|
||||
Forget {
|
||||
/// The device, by name or identifier.
|
||||
device: String,
|
||||
},
|
||||
/// Switch a device on.
|
||||
Enable {
|
||||
/// The device to enable, by name or identifier.
|
||||
target: String,
|
||||
},
|
||||
/// Switch a device off without forgetting it.
|
||||
Disable {
|
||||
/// The device to disable, by name or identifier.
|
||||
target: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Bluetooth LE MIDI, in both directions.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum BluetoothCommand {
|
||||
/// Look for devices in range, and list what was found.
|
||||
///
|
||||
/// Scanning costs power on every device in range, so it stops on its own.
|
||||
Scan {
|
||||
/// How long to look, in seconds.
|
||||
#[arg(long, default_value_t = 10)]
|
||||
seconds: u32,
|
||||
},
|
||||
/// List what the radio can currently hear, without starting a new scan.
|
||||
List,
|
||||
/// Connect a device, and remember it so it reconnects by itself next time.
|
||||
Connect {
|
||||
/// The device, by the address shown in a scan.
|
||||
address: String,
|
||||
},
|
||||
/// Close a link without forgetting the device.
|
||||
Disconnect {
|
||||
/// The device, by name or identifier.
|
||||
device: String,
|
||||
},
|
||||
/// Forget a device, so it stops reconnecting when it comes back into range.
|
||||
Forget {
|
||||
/// The device, by name or identifier.
|
||||
device: String,
|
||||
},
|
||||
/// Offer this machine to phones and tablets as a Bluetooth MIDI device.
|
||||
Advertise {
|
||||
/// Stop advertising instead of starting.
|
||||
#[arg(long)]
|
||||
off: bool,
|
||||
/// The name other devices will see. Defaults to this machine's name.
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
},
|
||||
/// Switch a Bluetooth device on.
|
||||
Enable {
|
||||
/// The device to enable, by name or identifier.
|
||||
target: String,
|
||||
},
|
||||
/// Switch a Bluetooth device off without forgetting it.
|
||||
Disable {
|
||||
/// The device to disable, by name or identifier.
|
||||
target: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Network port management.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum NetworkCommand {
|
||||
/// List configured network ports.
|
||||
List,
|
||||
/// Create a network port other machines can connect to.
|
||||
Create {
|
||||
/// The name to show in listings.
|
||||
name: String,
|
||||
/// The UDP port to listen on. Omitted, the system chooses.
|
||||
#[arg(long, default_value_t = 0)]
|
||||
port: u16,
|
||||
/// How to treat invitations from other machines. Omitted, the configuration's default
|
||||
/// is used, which is to prompt unless changed.
|
||||
#[arg(long, value_enum)]
|
||||
policy: Option<PolicyArg>,
|
||||
/// Do not show it to other applications on this computer as a MIDI port of its name.
|
||||
#[arg(long)]
|
||||
no_automatic_port: bool,
|
||||
/// The name other machines see it by. Omitted, they see NAME.
|
||||
#[arg(long)]
|
||||
bonjour_name: Option<String>,
|
||||
},
|
||||
/// Change a network port's settings. What is not given stays as it is.
|
||||
Edit {
|
||||
/// The network port to change, by name or identifier.
|
||||
#[arg(value_name = "NETWORK_PORT")]
|
||||
session: String,
|
||||
/// The name other machines see it by. Machines already connected keep the old one.
|
||||
#[arg(long)]
|
||||
bonjour_name: Option<String>,
|
||||
/// The UDP port to listen on, even, or 0 to let the system choose. The network port
|
||||
/// restarts on it, and machines that connected to it need the new port.
|
||||
#[arg(long)]
|
||||
udp_port: Option<u16>,
|
||||
/// How to treat invitations from other machines.
|
||||
#[arg(long, value_enum)]
|
||||
policy: Option<PolicyArg>,
|
||||
/// Whether other applications on this computer see it as a MIDI port of its name.
|
||||
#[arg(long, value_enum)]
|
||||
automatic_port: Option<Switch>,
|
||||
},
|
||||
/// Show the machines advertising themselves on this network.
|
||||
Discover,
|
||||
/// Connect a network port to a machine, by discovered name or by address.
|
||||
///
|
||||
/// An address may be given as HOST or HOST:PORT. A bare host uses the standard RTP-MIDI
|
||||
/// port, 5004. Connecting by address needs no discovery at all, which matters on a network
|
||||
/// that filters multicast.
|
||||
Connect {
|
||||
/// The network port to connect, by name or identifier.
|
||||
#[arg(value_name = "NETWORK_PORT")]
|
||||
session: String,
|
||||
/// The peer: a discovered name, or an address such as 192.0.2.3 or 192.0.2.3:5004.
|
||||
#[arg(value_name = "PEER_OR_ADDRESS")]
|
||||
peer: String,
|
||||
/// Carry this machine beside those already connected, instead of in place of the peer.
|
||||
/// It is remembered and reconnected as the peer is, until it is disconnected.
|
||||
#[arg(long)]
|
||||
alongside: bool,
|
||||
},
|
||||
/// Disconnect a network port, leaving it listening, or one machine from it.
|
||||
Disconnect {
|
||||
/// The network port to disconnect, by name or identifier.
|
||||
#[arg(value_name = "NETWORK_PORT")]
|
||||
session: String,
|
||||
/// Disconnect only this machine, by the address `network machines` shows.
|
||||
#[arg(long)]
|
||||
machine: Option<String>,
|
||||
},
|
||||
/// Delete a network port, stopping what it carried first.
|
||||
Delete {
|
||||
/// The network port, by name or identifier.
|
||||
#[arg(value_name = "NETWORK_PORT")]
|
||||
session: String,
|
||||
/// Confirm the deletion. Without it, nothing is deleted.
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Show the machines taking part in a network port.
|
||||
Machines {
|
||||
/// The network port, by name or identifier.
|
||||
#[arg(value_name = "NETWORK_PORT")]
|
||||
session: String,
|
||||
},
|
||||
/// Show the machines waiting to be let in.
|
||||
Invitations,
|
||||
/// Answer a machine that asked to connect.
|
||||
Respond {
|
||||
/// The invitation, as shown by `network invitations`.
|
||||
invitation: String,
|
||||
/// Let the machine in.
|
||||
#[arg(long, conflicts_with = "refuse")]
|
||||
accept: bool,
|
||||
/// Turn the machine away.
|
||||
#[arg(long)]
|
||||
refuse: bool,
|
||||
/// Remember the machine, so it is never asked about again.
|
||||
#[arg(long)]
|
||||
always: bool,
|
||||
},
|
||||
/// Manage the machines this one knows about.
|
||||
#[command(subcommand)]
|
||||
Peer(PeerCommand),
|
||||
/// Change how a network port treats invitations from other machines.
|
||||
Policy {
|
||||
/// The network port to change, by name or identifier.
|
||||
#[arg(value_name = "NETWORK_PORT")]
|
||||
session: String,
|
||||
/// How to treat invitations.
|
||||
#[arg(value_enum)]
|
||||
policy: PolicyArg,
|
||||
},
|
||||
/// Switch a network port on.
|
||||
Enable {
|
||||
/// The network port to enable, by name or identifier.
|
||||
target: String,
|
||||
},
|
||||
/// Switch a network port off without forgetting it.
|
||||
Disable {
|
||||
/// The network port to disable, by name or identifier.
|
||||
target: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Managing the machines this one knows about.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum PeerCommand {
|
||||
/// Remember a machine by address, so it is let in without being asked about.
|
||||
Add {
|
||||
/// The address, as HOST or HOST:PORT. A bare host uses the standard port, 5004.
|
||||
address: String,
|
||||
/// What to call it. Omitted, the address is used.
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
/// Remember it without letting it in unasked; its invitations are still asked about.
|
||||
#[arg(long)]
|
||||
no_trust: bool,
|
||||
},
|
||||
/// Switch whether a known machine is let in without being asked about.
|
||||
Trust {
|
||||
/// The machine, by name, address or identifier.
|
||||
peer: String,
|
||||
/// On lets it in without asking; off asks about its invitations again.
|
||||
#[arg(value_enum)]
|
||||
state: Switch,
|
||||
},
|
||||
/// Forget a machine, so invitations from it are asked about again.
|
||||
Remove {
|
||||
/// The machine, by name, address or identifier.
|
||||
peer: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// How a network port treats invitations from other machines.
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub enum PolicyArg {
|
||||
/// Ask before accepting.
|
||||
Prompt,
|
||||
/// Accept without asking from machines already trusted.
|
||||
Known,
|
||||
/// Accept from anyone.
|
||||
All,
|
||||
/// Refuse everything.
|
||||
Reject,
|
||||
}
|
||||
|
||||
impl PolicyArg {
|
||||
/// Returns the wire value for this policy.
|
||||
pub fn to_proto(self) -> i32 {
|
||||
let mapped = match self {
|
||||
Self::Prompt => midi_harbor_ipc::pb::InvitationPolicy::Prompt,
|
||||
Self::Known => midi_harbor_ipc::pb::InvitationPolicy::AcceptKnown,
|
||||
Self::All => midi_harbor_ipc::pb::InvitationPolicy::AcceptAll,
|
||||
Self::Reject => midi_harbor_ipc::pb::InvitationPolicy::RejectAll,
|
||||
};
|
||||
mapped as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// A setting switched on or off.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum Switch {
|
||||
/// Switched on.
|
||||
On,
|
||||
/// Switched off.
|
||||
Off,
|
||||
}
|
||||
|
||||
/// Diagnostic reporting.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum DiagnosticsCommand {
|
||||
/// Write configuration, connection history and counters to a file.
|
||||
Export {
|
||||
/// Where to write the report. Omitted, it goes to standard output.
|
||||
#[arg(long)]
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Configuration inspection, transfer and reloading.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum ConfigCommand {
|
||||
/// Print where the configuration file lives.
|
||||
Path,
|
||||
/// Print the configuration file.
|
||||
Show,
|
||||
/// Write the running configuration, ready to import on this machine or another.
|
||||
Export {
|
||||
/// Where to write it. Omitted, it goes to standard output.
|
||||
#[arg(long)]
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
/// Apply a configuration exported from this machine or another.
|
||||
Import {
|
||||
/// The exported file.
|
||||
path: PathBuf,
|
||||
/// Merge adds what this machine lacks; replace makes this machine match the file.
|
||||
#[arg(long, value_enum, default_value_t = ImportMode::Merge)]
|
||||
mode: ImportMode,
|
||||
/// Confirm a replacing import, which removes what the file does not mention.
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Apply changes made to the configuration file by hand, disturbing only what changed.
|
||||
Reload,
|
||||
/// Take over the IAC buses and network sessions set up in Audio MIDI Setup, as virtual
|
||||
/// ports and network ports.
|
||||
///
|
||||
/// Apple's setup is read, never changed. Without --yes, this only shows what it would
|
||||
/// create.
|
||||
ImportApple {
|
||||
/// Create what the preview lists.
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// How an imported configuration combines with the one already here.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum ImportMode {
|
||||
/// Add endpoints, routes and peers this machine lacks, and change nothing it has.
|
||||
Merge,
|
||||
/// Make this machine's setup match the file, removing anything the file does not mention.
|
||||
Replace,
|
||||
}
|
||||
|
||||
/// Which directions a port exposes.
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub enum DirectionArg {
|
||||
/// MIDI arrives from it only.
|
||||
In,
|
||||
/// MIDI leaves through it only.
|
||||
Out,
|
||||
/// Both.
|
||||
Both,
|
||||
}
|
||||
|
||||
impl DirectionArg {
|
||||
/// Returns the wire value for this direction.
|
||||
pub fn to_proto(self) -> i32 {
|
||||
let mapped = match self {
|
||||
Self::In => midi_harbor_ipc::pb::Direction::Input,
|
||||
Self::Out => midi_harbor_ipc::pb::Direction::Output,
|
||||
Self::Both => midi_harbor_ipc::pb::Direction::Bidirectional,
|
||||
};
|
||||
mapped as i32
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::CommandFactory;
|
||||
|
||||
/// Locks that the command tree passes clap's own consistency checks.
|
||||
///
|
||||
/// Clap only reports a clash between two definitions, such as a repeated short flag or a
|
||||
/// conflict naming an argument that does not exist, when the affected command is parsed. This
|
||||
/// walks every subcommand, so a clash in a rarely used one fails here rather than for a user.
|
||||
#[test]
|
||||
fn the_command_tree_is_well_formed() {
|
||||
Cli::command().debug_assert();
|
||||
}
|
||||
|
||||
/// Locks that `port create --direction` still parses after connectors replaced it.
|
||||
///
|
||||
/// The flag no longer does anything, but scripts written before connectors pass it, and
|
||||
/// rejecting it would break them for no gain.
|
||||
#[test]
|
||||
fn a_script_from_before_connectors_still_parses() {
|
||||
let parsed =
|
||||
Cli::try_parse_from(["midi-harbor", "port", "create", "Keys", "--direction", "in"]);
|
||||
assert!(
|
||||
parsed.is_ok(),
|
||||
"a script passing --direction must keep working: {parsed:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
171
crates/cli/src/exit.rs
Normal file
171
crates/cli/src/exit.rs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
//! Exit codes, derived from the daemon's stable failure slugs.
|
||||
|
||||
use midi_harbor_core::failure::FailureReason;
|
||||
use midi_harbor_ipc::status::reason_slug;
|
||||
use tonic::{Code, Status};
|
||||
|
||||
/// The process exit codes this program uses.
|
||||
///
|
||||
/// Stable and scriptable: a caller can branch on these without parsing output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(i32)]
|
||||
pub enum ExitCode {
|
||||
/// The command succeeded.
|
||||
Success = 0,
|
||||
/// Something failed that no other code describes.
|
||||
Failure = 1,
|
||||
/// The command line was wrong, or the graphical interface is not in this build.
|
||||
Usage = 2,
|
||||
/// The daemon is not running or cannot be reached.
|
||||
DaemonUnreachable = 3,
|
||||
/// The capability is not available on this machine.
|
||||
Unavailable = 4,
|
||||
/// No such endpoint, route, peer or device.
|
||||
NotFound = 5,
|
||||
/// The name is already in use, or the route already exists.
|
||||
Conflict = 6,
|
||||
/// The action needs confirmation that was not given.
|
||||
ConfirmationRequired = 7,
|
||||
/// The client and daemon speak different major protocol versions.
|
||||
VersionMismatch = 8,
|
||||
}
|
||||
|
||||
impl ExitCode {
|
||||
/// Returns the numeric code to exit with.
|
||||
pub fn code(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a daemon status onto the exit code a script should see.
|
||||
///
|
||||
/// The stable slug is preferred over the gRPC code, because several conditions share a code but
|
||||
/// need different exits: a name conflict and a missing endpoint are both client errors, and a
|
||||
/// caller acts differently on each.
|
||||
pub fn from_status(status: &Status) -> ExitCode {
|
||||
// A slug this client does not know comes from a newer daemon, and the gRPC code is still a
|
||||
// better guide than the generic failure.
|
||||
let known = reason_slug(status).and_then(|slug| {
|
||||
FailureReason::one_of_each()
|
||||
.into_iter()
|
||||
.find(|reason| reason.code() == slug)
|
||||
});
|
||||
if let Some(reason) = known {
|
||||
return for_reason(&reason);
|
||||
}
|
||||
match status.code() {
|
||||
Code::NotFound => ExitCode::NotFound,
|
||||
Code::AlreadyExists => ExitCode::Conflict,
|
||||
Code::FailedPrecondition => ExitCode::ConfirmationRequired,
|
||||
Code::Unavailable => ExitCode::DaemonUnreachable,
|
||||
Code::Unimplemented => ExitCode::VersionMismatch,
|
||||
Code::InvalidArgument => ExitCode::Usage,
|
||||
Code::PermissionDenied => ExitCode::Unavailable,
|
||||
_ => ExitCode::Failure,
|
||||
}
|
||||
}
|
||||
|
||||
/// The exit code for each failure reason.
|
||||
///
|
||||
/// No wildcard, so a new reason cannot fall through to the generic failure unnoticed. The two
|
||||
/// that do exit `1` do so by decision: a peer declining a session and a peer or the operating
|
||||
/// system breaking protocol are nothing a script on this machine can correct.
|
||||
fn for_reason(reason: &FailureReason) -> ExitCode {
|
||||
match reason {
|
||||
FailureReason::NameConflict { .. } => ExitCode::Conflict,
|
||||
FailureReason::DeviceRemoved => ExitCode::NotFound,
|
||||
FailureReason::AdapterUnavailable
|
||||
| FailureReason::PermissionDenied { .. }
|
||||
| FailureReason::NetworkUnreachable
|
||||
| FailureReason::PeerTimeout
|
||||
| FailureReason::DeviceClaimed { .. }
|
||||
| FailureReason::ResourceLimit => ExitCode::Unavailable,
|
||||
// Raised when a request does not fit the endpoint it names, such as forgetting a port.
|
||||
FailureReason::ConfigInvalid { .. } => ExitCode::Usage,
|
||||
FailureReason::PeerRejected | FailureReason::ProtocolError { .. } => ExitCode::Failure,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use midi_harbor_ipc::status::{IntoStatus, REASON_METADATA_KEY};
|
||||
|
||||
/// Locks the exit code each failure reason produces, as the number a script switches on.
|
||||
///
|
||||
/// Scripts branch on these numbers, so changing one is a contract change. The stable slug
|
||||
/// decides, not the gRPC code: a name conflict and a missing endpoint are both client errors,
|
||||
/// yet a script retries one and gives up on the other. Only a peer declining and a protocol
|
||||
/// error exit 1, by decision (T121), since nothing on this machine can correct them. Every
|
||||
/// reason must have a row, so a new one cannot reach exit 1 by default.
|
||||
#[test]
|
||||
fn every_failure_reason_exits_with_its_contracted_code() {
|
||||
let contract = [
|
||||
("peer_rejected", 1),
|
||||
("protocol_error", 1),
|
||||
("config_invalid", 2),
|
||||
("adapter_unavailable", 4),
|
||||
("permission_denied", 4),
|
||||
("network_unreachable", 4),
|
||||
("peer_timeout", 4),
|
||||
("device_claimed", 4),
|
||||
("resource_limit", 4),
|
||||
("device_removed", 5),
|
||||
("name_conflict", 6),
|
||||
];
|
||||
for reason in FailureReason::one_of_each() {
|
||||
let want = contract
|
||||
.iter()
|
||||
.find(|(slug, _)| *slug == reason.code())
|
||||
.map(|(_, code)| *code);
|
||||
let got = from_status(&reason.clone().into_status()).code();
|
||||
assert_eq!(
|
||||
Some(got),
|
||||
want,
|
||||
"{} must exit {want:?}, and a new reason needs a row here",
|
||||
reason.code()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Locks the exit code for a status that carries no slug this client knows.
|
||||
///
|
||||
/// A transport failure never reaches the daemon, so it has no slug and must still say the
|
||||
/// daemon is unreachable (3). A refusal for want of `--yes` exits 7 so a script can retry with
|
||||
/// it. A slug from a newer daemon is unknown here, and its gRPC code is a better guide than
|
||||
/// the generic failure.
|
||||
#[test]
|
||||
fn a_status_without_a_known_slug_exits_by_its_grpc_code() {
|
||||
let mut newer = Status::new(Code::NotFound, "gone");
|
||||
newer.metadata_mut().insert(
|
||||
REASON_METADATA_KEY,
|
||||
"something_new".parse().expect("a valid metadata value"),
|
||||
);
|
||||
let cases = [
|
||||
(
|
||||
"connection refused",
|
||||
Status::new(Code::Unavailable, "connection refused"),
|
||||
3,
|
||||
),
|
||||
(
|
||||
"missing confirmation",
|
||||
Status::new(Code::FailedPrecondition, "confirm"),
|
||||
7,
|
||||
),
|
||||
("slug from a newer daemon", newer, 5),
|
||||
(
|
||||
"older protocol",
|
||||
Status::new(Code::Unimplemented, "no such call"),
|
||||
8,
|
||||
),
|
||||
("unclassified", Status::new(Code::Internal, "broken"), 1),
|
||||
];
|
||||
for (name, status, want) in cases {
|
||||
assert_eq!(
|
||||
from_status(&status).code(),
|
||||
want,
|
||||
"the {name} status must exit {want}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
15
crates/cli/src/lib.rs
Normal file
15
crates/cli/src/lib.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
//! The command-line interface.
|
||||
//!
|
||||
//! Every action available in the graphical interface has an equivalent here, because the GUI is
|
||||
//! optional at build time and must never be the only way to do something.
|
||||
|
||||
pub mod client;
|
||||
pub mod commands;
|
||||
pub mod exit;
|
||||
pub mod output;
|
||||
pub mod service_cmd;
|
||||
|
||||
pub use commands::{Cli, Command};
|
||||
pub use exit::ExitCode;
|
||||
|
||||
pub mod run;
|
||||
132
crates/cli/src/output.rs
Normal file
132
crates/cli/src/output.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
//! Rendering results for people and for scripts.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::io::Write;
|
||||
|
||||
/// How to present results.
|
||||
///
|
||||
/// Under `--json` every invocation writes exactly one JSON document to stdout, as the contract
|
||||
/// promises: a command's own, or a closing one carrying its outcome and whatever sentences it
|
||||
/// would have printed. Commands that watch write one document per update instead.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Format {
|
||||
/// Emit machine-readable output instead of a table.
|
||||
pub json: bool,
|
||||
/// Suppress anything that is not an error.
|
||||
pub quiet: bool,
|
||||
/// Sentences held back under `--json`, where printing them would corrupt the document.
|
||||
held: std::sync::Mutex<Vec<String>>,
|
||||
/// Whether a document has been written, so the closing one is not added to it.
|
||||
emitted: std::sync::atomic::AtomicBool,
|
||||
/// What a command made or changed, for the closing document under `--json`.
|
||||
result: std::sync::Mutex<Option<serde_json::Value>>,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
/// Creates a format for one invocation.
|
||||
pub fn new(json: bool, quiet: bool) -> Self {
|
||||
Self {
|
||||
json,
|
||||
quiet,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a value as JSON, for scripting.
|
||||
///
|
||||
/// Diagnostics go to stderr throughout, so piping stdout into a parser is always safe.
|
||||
pub fn emit<T: Serialize>(&self, value: &T) {
|
||||
self.emitted
|
||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
if self.quiet {
|
||||
return;
|
||||
}
|
||||
match serde_json::to_string_pretty(value) {
|
||||
Ok(text) => println!("{text}"),
|
||||
Err(error) => eprintln!("could not encode output: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a line of human-readable output, or holds it for the closing document under
|
||||
/// `--json`.
|
||||
pub fn line(&self, text: impl AsRef<str>) {
|
||||
if self.json {
|
||||
if let Ok(mut held) = self.held.lock() {
|
||||
held.push(text.as_ref().to_owned());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.quiet {
|
||||
return;
|
||||
}
|
||||
println!("{}", text.as_ref());
|
||||
}
|
||||
|
||||
/// Records what a command made or changed, so a script gets its identifier as well as the
|
||||
/// sentence a person reads.
|
||||
pub fn result(&self, value: serde_json::Value) {
|
||||
if let Ok(mut result) = self.result.lock() {
|
||||
*result = Some(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends an invocation under `--json` with a document for a command that wrote none.
|
||||
///
|
||||
/// Many commands report in a sentence, and under `--json` printed it anyway, so a script got
|
||||
/// text where it asked for JSON (FR-039d).
|
||||
pub fn finish(&self, succeeded: bool) {
|
||||
if !self.json || self.emitted.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let messages = self
|
||||
.held
|
||||
.lock()
|
||||
.map(|held| held.clone())
|
||||
.unwrap_or_default();
|
||||
let result = self.result.lock().ok().and_then(|result| result.clone());
|
||||
let mut document = serde_json::json!({
|
||||
"ok": succeeded,
|
||||
"messages": messages,
|
||||
});
|
||||
if let (Some(result), Some(fields)) = (result, document.as_object_mut()) {
|
||||
let _ = fields.insert("result".to_owned(), result);
|
||||
}
|
||||
self.emit(&document);
|
||||
}
|
||||
|
||||
/// Writes a note to stderr, so it never pollutes parsed output.
|
||||
pub fn note(&self, text: impl AsRef<str>) {
|
||||
if self.quiet {
|
||||
return;
|
||||
}
|
||||
let _ = writeln!(std::io::stderr(), "{}", text.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a table with aligned columns.
|
||||
pub fn table(headers: &[&str], rows: &[Vec<String>]) -> String {
|
||||
let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
|
||||
for row in rows {
|
||||
for (index, cell) in row.iter().enumerate() {
|
||||
if let Some(width) = widths.get_mut(index) {
|
||||
*width = (*width).max(cell.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
for (index, header) in headers.iter().enumerate() {
|
||||
let width = widths.get(index).copied().unwrap_or(header.len());
|
||||
out.push_str(&format!("{header:<width$} "));
|
||||
}
|
||||
out.push('\n');
|
||||
|
||||
for row in rows {
|
||||
for (index, cell) in row.iter().enumerate() {
|
||||
let width = widths.get(index).copied().unwrap_or(cell.len());
|
||||
out.push_str(&format!("{cell:<width$} "));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
3061
crates/cli/src/run.rs
Normal file
3061
crates/cli/src/run.rs
Normal file
File diff suppressed because it is too large
Load diff
249
crates/cli/src/service_cmd.rs
Normal file
249
crates/cli/src/service_cmd.rs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
//! Installing and controlling the background service.
|
||||
|
||||
use crate::commands::ServiceCommand;
|
||||
use crate::exit::ExitCode;
|
||||
use crate::output::Format;
|
||||
use midi_harbor_service::{self as service, ServiceSpec};
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a freshly started daemon may take to answer before starting is called a failure.
|
||||
const STARTUP_WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How often the socket is tried while waiting.
|
||||
const STARTUP_POLL: Duration = Duration::from_millis(50);
|
||||
|
||||
/// Stops the daemon by asking it over its socket, where no service manager runs it.
|
||||
async fn stop_by_request(format: &Format, socket: Option<std::path::PathBuf>) -> ExitCode {
|
||||
let mut client = match crate::client::Client::connect(socket).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
return ExitCode::DaemonUnreachable;
|
||||
}
|
||||
};
|
||||
match client
|
||||
.rpc()
|
||||
.stop_daemon(midi_harbor_ipc::pb::StopDaemonRequest {})
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
format.line("stopped");
|
||||
ExitCode::Success
|
||||
}
|
||||
Err(status) => {
|
||||
eprintln!("could not stop the daemon: {}", status.message());
|
||||
ExitCode::Failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a service subcommand.
|
||||
pub async fn run(
|
||||
command: &ServiceCommand,
|
||||
format: &Format,
|
||||
socket: Option<std::path::PathBuf>,
|
||||
) -> ExitCode {
|
||||
// A machine without a supported service manager is a supported situation, not a failure of
|
||||
// this program, so it is reported with the alternative rather than as an error.
|
||||
let manager = match service::detect() {
|
||||
Ok(manager) => manager,
|
||||
// The App Store app runs its daemon itself, so there is nothing to install or start, but
|
||||
// it can still be stopped, which the app's own Quit does the same way.
|
||||
Err(service::ServiceError::AppStore) if matches!(command, ServiceCommand::Stop) => {
|
||||
return stop_by_request(format, socket).await;
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
return ExitCode::Unavailable;
|
||||
}
|
||||
};
|
||||
|
||||
match command {
|
||||
ServiceCommand::Install { start } => {
|
||||
let spec = match ServiceSpec::for_current_executable() {
|
||||
Ok(spec) => spec,
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
return ExitCode::Failure;
|
||||
}
|
||||
};
|
||||
|
||||
// Re-running install updates the registration in place, so say which happened.
|
||||
let existed = manager
|
||||
.status()
|
||||
.map(|status| status.installed)
|
||||
.unwrap_or(false);
|
||||
let path = match manager.install(&spec) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
eprintln!("could not install the service: {error}");
|
||||
return ExitCode::Failure;
|
||||
}
|
||||
};
|
||||
|
||||
let verb = if existed { "updated" } else { "installed" };
|
||||
format.line(format!(
|
||||
"{verb} the {} service at {}",
|
||||
manager.name(),
|
||||
path.display()
|
||||
));
|
||||
format.line("it will start automatically at login");
|
||||
|
||||
if *start {
|
||||
if let Err(error) = manager.start() {
|
||||
eprintln!("the service was installed but could not be started: {error}");
|
||||
return ExitCode::Failure;
|
||||
}
|
||||
return report_started(format);
|
||||
}
|
||||
ExitCode::Success
|
||||
}
|
||||
|
||||
ServiceCommand::Uninstall => match manager.uninstall() {
|
||||
Ok(()) => {
|
||||
format.line("the service was removed; your configuration was left untouched");
|
||||
ExitCode::Success
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("could not remove the service: {error}");
|
||||
ExitCode::Failure
|
||||
}
|
||||
},
|
||||
|
||||
ServiceCommand::Start => match manager.start() {
|
||||
Ok(()) => report_started(format),
|
||||
Err(error) => {
|
||||
eprintln!("could not start the service: {error}");
|
||||
ExitCode::Failure
|
||||
}
|
||||
},
|
||||
|
||||
ServiceCommand::Stop => match manager.stop() {
|
||||
Ok(()) => {
|
||||
format.line("stopped");
|
||||
ExitCode::Success
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("could not stop the service: {error}");
|
||||
ExitCode::Failure
|
||||
}
|
||||
},
|
||||
|
||||
ServiceCommand::Status => match manager.status() {
|
||||
Ok(status) => {
|
||||
// The version and uptime come from the daemon itself, when one answers: the
|
||||
// service manager knows only that a process is registered and alive (US2/AC2).
|
||||
let daemon = match crate::client::Client::connect(socket).await {
|
||||
Ok(client) => Some(client.server().clone()),
|
||||
Err(_) => None,
|
||||
};
|
||||
let started = daemon
|
||||
.as_ref()
|
||||
.and_then(|info| info.started_at.as_ref())
|
||||
.and_then(|at| jiff::Timestamp::new(at.seconds, at.nanos).ok());
|
||||
let uptime = started.map(|at| {
|
||||
jiff::Timestamp::now()
|
||||
.as_second()
|
||||
.saturating_sub(at.as_second())
|
||||
});
|
||||
if format.json {
|
||||
format.emit(&serde_json::json!({
|
||||
"manager": manager.name(),
|
||||
"installed": status.installed,
|
||||
"running": status.running,
|
||||
"stale": status.stale,
|
||||
"definition_path": status.definition_path,
|
||||
"registered_executable": status.registered_executable,
|
||||
"daemon_version": daemon.as_ref().map(|info| info.daemon_version.clone()),
|
||||
"started_at": started.map(|at| at.to_string()),
|
||||
"uptime_seconds": uptime,
|
||||
}));
|
||||
} else {
|
||||
format.line(format!("{}: {}", manager.name(), status.describe()));
|
||||
if let Some(info) = &daemon {
|
||||
let mut line = format!("daemon: version {}", info.daemon_version);
|
||||
if let Some(seconds) = uptime {
|
||||
line.push_str(&format!(", up {}", uptime_text(seconds)));
|
||||
}
|
||||
format.line(line);
|
||||
}
|
||||
if let Some(path) = &status.definition_path {
|
||||
format.line(format!("definition: {}", path.display()));
|
||||
}
|
||||
}
|
||||
// A stale registration is a problem the user must fix, so it must not exit zero.
|
||||
if status.stale {
|
||||
ExitCode::Failure
|
||||
} else {
|
||||
ExitCode::Success
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("could not read the service status: {error}");
|
||||
ExitCode::Failure
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports a start once the daemon answers, which is what "started" has to mean.
|
||||
///
|
||||
/// The service manager returns as soon as it has launched the process, before the daemon has
|
||||
/// opened its socket. Saying "started" then sent a script's very next command to a daemon not
|
||||
/// yet listening: `service install --start` followed by `port create`, the quickstart's first
|
||||
/// two commands, failed with "the daemon is not running".
|
||||
fn report_started(format: &Format) -> ExitCode {
|
||||
let socket = match midi_harbor_core::paths::Paths::resolve() {
|
||||
Ok(paths) => paths.socket_file(),
|
||||
Err(error) => {
|
||||
eprintln!("started, but could not find where the daemon listens: {error}");
|
||||
return ExitCode::Failure;
|
||||
}
|
||||
};
|
||||
if answers_within(&socket, STARTUP_WAIT) {
|
||||
format.line("started");
|
||||
ExitCode::Success
|
||||
} else {
|
||||
eprintln!(
|
||||
"the service was started but the daemon did not answer within {} seconds",
|
||||
STARTUP_WAIT.as_secs()
|
||||
);
|
||||
eprintln!("see 'midi-harbor service status', or run 'midi-harbor daemon' to see why");
|
||||
ExitCode::DaemonUnreachable
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders how long the daemon has been up, to the two largest units that say something.
|
||||
#[allow(clippy::integer_division)]
|
||||
fn uptime_text(seconds: i64) -> String {
|
||||
let seconds = seconds.max(0);
|
||||
let (days, hours, minutes) = (
|
||||
seconds / 86_400,
|
||||
seconds % 86_400 / 3_600,
|
||||
seconds % 3_600 / 60,
|
||||
);
|
||||
if days > 0 {
|
||||
format!("{days}d {hours}h")
|
||||
} else if hours > 0 {
|
||||
format!("{hours}h {minutes}m")
|
||||
} else if minutes > 0 {
|
||||
format!("{minutes}m")
|
||||
} else {
|
||||
format!("{seconds}s")
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits up to `bound` for something to accept connections on the socket.
|
||||
fn answers_within(socket: &Path, bound: Duration) -> bool {
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
if midi_harbor_ipc::transport::answers(socket) {
|
||||
return true;
|
||||
}
|
||||
if started.elapsed() >= bound {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(STARTUP_POLL);
|
||||
}
|
||||
}
|
||||
25
crates/core/Cargo.toml
Normal file
25
crates/core/Cargo.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[package]
|
||||
name = "midi-harbor-core"
|
||||
description = "Domain types, connection state machine, routing and configuration"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
directories.workspace = true
|
||||
jiff.workspace = true
|
||||
rand.workspace = true
|
||||
rtrb.workspace = true
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde_yaml_ng.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
serde_json.workspace = true
|
||||
82
crates/core/examples/sample_config.rs
Normal file
82
crates/core/examples/sample_config.rs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
//! Prints a representative configuration document, so the stored format can be reviewed by eye.
|
||||
//!
|
||||
//! Run with `cargo run -p midi-harbor-core --example sample_config`.
|
||||
|
||||
use midi_harbor_core::config::{Configuration, RouteConfig};
|
||||
use midi_harbor_core::endpoint::{
|
||||
Direction, Endpoint, EndpointKind, EndpointName, InvitationPolicy, NetworkSession,
|
||||
PhysicalDevice, VirtualPort,
|
||||
};
|
||||
use midi_harbor_core::fingerprint::{DeviceFingerprint, MatchConfidence};
|
||||
|
||||
/// Builds a setup covering every endpoint kind, joined by one connection.
|
||||
fn sample() -> Result<Configuration, midi_harbor_core::endpoint::NameError> {
|
||||
let mut config = Configuration::default();
|
||||
config.preferences.machine_name = Some("Studio Mac".to_owned());
|
||||
|
||||
let bus = Endpoint::new(
|
||||
EndpointName::new("Sequencer Bus")?,
|
||||
EndpointKind::VirtualPort(VirtualPort {
|
||||
input_ids: vec![1_296_564_226],
|
||||
output_ids: vec![1_296_564_225],
|
||||
..VirtualPort::default()
|
||||
}),
|
||||
);
|
||||
|
||||
let keyboard = Endpoint {
|
||||
direction: Direction::Input,
|
||||
..Endpoint::new(
|
||||
EndpointName::new("Acme K61")?,
|
||||
EndpointKind::PhysicalDevice(PhysicalDevice {
|
||||
fingerprint: DeviceFingerprint {
|
||||
usb_serial: Some("SN-001".to_owned()),
|
||||
manufacturer: Some("Acme".to_owned()),
|
||||
model: Some("K61".to_owned()),
|
||||
name: "Acme K61".to_owned(),
|
||||
..DeviceFingerprint::default()
|
||||
},
|
||||
present: true,
|
||||
confidence: MatchConfidence::Exact,
|
||||
claimed_by: None,
|
||||
software: false,
|
||||
}),
|
||||
)
|
||||
};
|
||||
|
||||
let stage = Endpoint::new(
|
||||
EndpointName::new("Stage Laptop")?,
|
||||
EndpointKind::NetworkSession(NetworkSession::new(
|
||||
EndpointName::new("Studio Mac")?,
|
||||
5004,
|
||||
InvitationPolicy::AcceptKnown,
|
||||
)),
|
||||
);
|
||||
|
||||
// Repeat the attached keyboard over the network to the other machine.
|
||||
config.routes.push(RouteConfig {
|
||||
from: "Acme K61".to_owned(),
|
||||
to: "Stage Laptop".to_owned(),
|
||||
from_kind: None,
|
||||
to_kind: None,
|
||||
from_connector: None,
|
||||
to_connector: None,
|
||||
both_ways: false,
|
||||
enabled: true,
|
||||
});
|
||||
config.endpoints = vec![bus, keyboard, stage];
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let config = match sample() {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
eprintln!("could not build the sample configuration: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
match serde_yaml_ng::to_string(&config) {
|
||||
Ok(text) => print!("{text}"),
|
||||
Err(error) => eprintln!("could not encode the sample configuration: {error}"),
|
||||
}
|
||||
}
|
||||
7
crates/core/proptest-regressions/midi.txt
Normal file
7
crates/core/proptest-regressions/midi.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc afbbc769a21d8c0deb36dedabbd2ef35d173d6705ef352f105e3d9f8f2de6207 # shrinks to bytes = [224, 128, 0]
|
||||
252
crates/core/src/backoff.rs
Normal file
252
crates/core/src/backoff.rs
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
//! Retry pacing for connections that are down.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// Base delay before the first retry.
|
||||
const DEFAULT_BASE: Duration = Duration::from_millis(250);
|
||||
/// Each successive attempt multiplies the delay by this factor.
|
||||
const DEFAULT_FACTOR: f64 = 2.0;
|
||||
/// Ceiling on the delay, so a long outage still retries about twice a minute.
|
||||
const DEFAULT_MAX: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Ceiling for a connection whose retry costs a handful of packets.
|
||||
///
|
||||
/// This number is what SC-003 is made of. Nothing reports that an interruption ended when this
|
||||
/// machine's own address never changed — an upstream router rebooting looks like silence — so the
|
||||
/// ceiling is the longest a session can fail to notice that the network came back. Ten seconds is
|
||||
/// the promise, and the handshake needs some of it.
|
||||
const RESPONSIVE_MAX: Duration = Duration::from_secs(5);
|
||||
|
||||
/// How quickly retries back off, and how far.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct BackoffPolicy {
|
||||
/// Delay before the first retry.
|
||||
pub base: Duration,
|
||||
/// Multiplier applied per attempt.
|
||||
pub factor: f64,
|
||||
/// Upper bound on the computed delay.
|
||||
pub max: Duration,
|
||||
}
|
||||
|
||||
impl Default for BackoffPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base: DEFAULT_BASE,
|
||||
factor: DEFAULT_FACTOR,
|
||||
max: DEFAULT_MAX,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BackoffPolicy {
|
||||
/// Pacing for a connection that is cheap to retry and expensive to leave down.
|
||||
///
|
||||
/// A network session's retry is three small packets. Waiting half a minute between them
|
||||
/// saves nothing worth having and is most of the time a user spends wondering why their
|
||||
/// keyboard stopped working.
|
||||
pub fn responsive() -> Self {
|
||||
Self {
|
||||
max: RESPONSIVE_MAX,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks how many times a connection has been retried and how long to wait next.
|
||||
///
|
||||
/// The delay grows exponentially and is then capped, but it never stops being produced: there is
|
||||
/// no attempt count at which this yields "give up". Principle I forbids a terminal failure state
|
||||
/// for a connection the user has left enabled.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Backoff {
|
||||
policy: BackoffPolicy,
|
||||
attempt: u32,
|
||||
}
|
||||
|
||||
impl Backoff {
|
||||
/// Creates a backoff with the given policy and no attempts recorded.
|
||||
pub fn new(policy: BackoffPolicy) -> Self {
|
||||
Self { policy, attempt: 0 }
|
||||
}
|
||||
|
||||
/// Returns how many attempts have been recorded.
|
||||
pub fn attempt(&self) -> u32 {
|
||||
self.attempt
|
||||
}
|
||||
|
||||
/// Clears the attempt count, called once a connection succeeds.
|
||||
pub fn reset(&mut self) {
|
||||
self.attempt = 0;
|
||||
}
|
||||
|
||||
/// Records an attempt and returns how long to wait before the next one.
|
||||
///
|
||||
/// `jitter` is a fraction in `[0.0, 1.0]` scaling the computed delay, which spreads retries
|
||||
/// out when many connections fail together. Callers in production supply a random value; tests
|
||||
/// supply a fixed one so the result is deterministic.
|
||||
pub fn next_delay(&mut self, jitter: f64) -> Duration {
|
||||
// Compute the uncapped exponential delay for this attempt.
|
||||
let exponent = f64::from(self.attempt);
|
||||
let base = self.policy.base.as_secs_f64();
|
||||
let factor = if self.policy.factor.is_finite() && self.policy.factor >= 1.0 {
|
||||
self.policy.factor
|
||||
} else {
|
||||
DEFAULT_FACTOR
|
||||
};
|
||||
let grown = base * factor.powf(exponent);
|
||||
|
||||
// Cap it, then apply full jitter over the capped value.
|
||||
let capped = grown.min(self.policy.max.as_secs_f64());
|
||||
let clamped_jitter = if jitter.is_finite() {
|
||||
jitter.clamp(0.0, 1.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let delayed = capped * clamped_jitter;
|
||||
|
||||
self.attempt = self.attempt.saturating_add(1);
|
||||
|
||||
// A zero delay would spin, so hold a floor of one jittered millisecond.
|
||||
if !delayed.is_finite() || delayed <= 0.0 {
|
||||
return Duration::from_millis(1);
|
||||
}
|
||||
Duration::from_secs_f64(delayed).max(Duration::from_millis(1))
|
||||
}
|
||||
|
||||
/// Records an attempt and returns a randomly jittered delay.
|
||||
pub fn next_delay_random(&mut self) -> Duration {
|
||||
self.next_delay(rand::random::<f64>())
|
||||
}
|
||||
|
||||
/// Returns the uncapped-then-capped delay for the current attempt without jitter, for display.
|
||||
pub fn peek_ceiling(&self) -> Duration {
|
||||
let exponent = f64::from(self.attempt);
|
||||
let grown = self.policy.base.as_secs_f64() * self.policy.factor.powf(exponent);
|
||||
let capped = grown.min(self.policy.max.as_secs_f64());
|
||||
if capped.is_finite() && capped > 0.0 {
|
||||
Duration::from_secs_f64(capped)
|
||||
} else {
|
||||
self.policy.max
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Backoff {
|
||||
fn default() -> Self {
|
||||
Self::new(BackoffPolicy::default())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The delay doubles from 250 ms and then holds at the 30 s ceiling for as long as retries
|
||||
/// continue.
|
||||
///
|
||||
/// With full jitter (1.0) the delay is the ceiling itself: 0.25 s * 2^n for attempt n, so
|
||||
/// 0.25, 0.5, 1, 2, 4, 8 and 16 s, then 0.25 s * 2^7 = 32 s is capped at 30 s. Principle I
|
||||
/// forbids a terminal failure, so the ten-thousandth attempt still yields the ceiling rather
|
||||
/// than a signal to stop.
|
||||
#[test]
|
||||
fn the_delay_doubles_from_the_base_and_holds_at_the_ceiling_forever() {
|
||||
let mut backoff = Backoff::default();
|
||||
let want: Vec<Duration> = [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000]
|
||||
.into_iter()
|
||||
.map(Duration::from_millis)
|
||||
.collect();
|
||||
let got: Vec<Duration> = want.iter().map(|_| backoff.next_delay(1.0)).collect();
|
||||
assert_eq!(
|
||||
got, want,
|
||||
"the delay must double from the base and stop at the ceiling"
|
||||
);
|
||||
|
||||
for _ in want.len()..10_000 {
|
||||
let _ = backoff.next_delay(1.0);
|
||||
}
|
||||
assert_eq!(
|
||||
backoff.next_delay(1.0),
|
||||
DEFAULT_MAX,
|
||||
"no attempt count may turn the ceiling into giving up"
|
||||
);
|
||||
}
|
||||
|
||||
/// Jitter scales the capped delay, and no jitter or policy value yields a zero, negative or
|
||||
/// unbounded delay.
|
||||
///
|
||||
/// A zero delay would spin the retry loop, so the floor is one millisecond. A factor that is
|
||||
/// not a finite number of at least one falls back to doubling, and jitter that is not finite
|
||||
/// counts as full, so a hostile or mistyped policy still paces retries.
|
||||
#[test]
|
||||
fn jitter_and_hostile_policies_stay_between_the_floor_and_the_ceiling() {
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
policy: BackoffPolicy,
|
||||
earlier_attempts: u32,
|
||||
jitter: f64,
|
||||
want: Duration,
|
||||
}
|
||||
let cases = [
|
||||
Case {
|
||||
name: "half jitter on the first attempt halves 250 ms",
|
||||
policy: BackoffPolicy::default(),
|
||||
earlier_attempts: 0,
|
||||
jitter: 0.5,
|
||||
want: Duration::from_millis(125),
|
||||
},
|
||||
Case {
|
||||
name: "zero jitter holds the one millisecond floor",
|
||||
policy: BackoffPolicy::default(),
|
||||
earlier_attempts: 2,
|
||||
jitter: 0.0,
|
||||
want: Duration::from_millis(1),
|
||||
},
|
||||
Case {
|
||||
name: "negative jitter clamps to zero and holds the floor",
|
||||
policy: BackoffPolicy::default(),
|
||||
earlier_attempts: 2,
|
||||
jitter: -3.0,
|
||||
want: Duration::from_millis(1),
|
||||
},
|
||||
Case {
|
||||
name: "infinite jitter counts as full, 250 ms * 2^2 = 1 s",
|
||||
policy: BackoffPolicy::default(),
|
||||
earlier_attempts: 2,
|
||||
jitter: f64::INFINITY,
|
||||
want: Duration::from_secs(1),
|
||||
},
|
||||
Case {
|
||||
name: "a NaN factor falls back to doubling, 250 ms * 2^2 = 1 s",
|
||||
policy: BackoffPolicy {
|
||||
factor: f64::NAN,
|
||||
..BackoffPolicy::default()
|
||||
},
|
||||
earlier_attempts: 2,
|
||||
jitter: 1.0,
|
||||
want: Duration::from_secs(1),
|
||||
},
|
||||
Case {
|
||||
name: "a zero base holds the floor rather than spinning",
|
||||
policy: BackoffPolicy {
|
||||
base: Duration::ZERO,
|
||||
..BackoffPolicy::default()
|
||||
},
|
||||
earlier_attempts: 0,
|
||||
jitter: 1.0,
|
||||
want: Duration::from_millis(1),
|
||||
},
|
||||
];
|
||||
for case in cases {
|
||||
let mut backoff = Backoff::new(case.policy);
|
||||
for _ in 0..case.earlier_attempts {
|
||||
let _ = backoff.next_delay(1.0);
|
||||
}
|
||||
assert_eq!(
|
||||
backoff.next_delay(case.jitter),
|
||||
case.want,
|
||||
"{}: the delay must stay between the floor and the ceiling",
|
||||
case.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
204
crates/core/src/capability.rs
Normal file
204
crates/core/src/capability.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
//! What this computer can actually do right now.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
/// A thing Midi Harbor may or may not be able to do on the current machine.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CapabilityName {
|
||||
/// Creating virtual MIDI ports other applications can see.
|
||||
VirtualPorts,
|
||||
/// Enumerating and opening attached MIDI hardware.
|
||||
PhysicalDevices,
|
||||
/// Establishing RTP-MIDI sessions with peers.
|
||||
NetworkSessions,
|
||||
/// Advertising and discovering peers over mDNS.
|
||||
MdnsResponder,
|
||||
/// Connecting out to Bluetooth LE MIDI devices.
|
||||
BluetoothCentral,
|
||||
/// Advertising this computer as a Bluetooth LE MIDI device.
|
||||
BluetoothPeripheral,
|
||||
/// Installing a service that starts at login.
|
||||
ServiceManager,
|
||||
}
|
||||
|
||||
impl CapabilityName {
|
||||
/// Returns the stable identifier clients match on, the same as the serialised form.
|
||||
///
|
||||
/// The display text is for people and may be reworded. A client choosing what to show from
|
||||
/// a capability needs a name that will not change under it.
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Self::VirtualPorts => "virtual_ports",
|
||||
Self::PhysicalDevices => "physical_devices",
|
||||
Self::NetworkSessions => "network_sessions",
|
||||
Self::MdnsResponder => "mdns_responder",
|
||||
Self::BluetoothCentral => "bluetooth_central",
|
||||
Self::BluetoothPeripheral => "bluetooth_peripheral",
|
||||
Self::ServiceManager => "service_manager",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns every capability, so a platform backend cannot forget to answer for one.
|
||||
pub fn all() -> [Self; 7] {
|
||||
[
|
||||
Self::VirtualPorts,
|
||||
Self::PhysicalDevices,
|
||||
Self::NetworkSessions,
|
||||
Self::MdnsResponder,
|
||||
Self::BluetoothCentral,
|
||||
Self::BluetoothPeripheral,
|
||||
Self::ServiceManager,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CapabilityName {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let text = match self {
|
||||
Self::VirtualPorts => "virtual ports",
|
||||
Self::PhysicalDevices => "physical devices",
|
||||
Self::NetworkSessions => "network sessions",
|
||||
Self::MdnsResponder => "network discovery",
|
||||
Self::BluetoothCentral => "bluetooth connections",
|
||||
Self::BluetoothPeripheral => "bluetooth advertising",
|
||||
Self::ServiceManager => "service installation",
|
||||
};
|
||||
f.write_str(text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a capability is not available.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UnavailableReason {
|
||||
/// The hardware is not present.
|
||||
NoAdapter,
|
||||
/// The hardware is present but switched off.
|
||||
AdapterOff,
|
||||
/// The operating system has not granted permission.
|
||||
PermissionDenied {
|
||||
/// What the user needs to grant.
|
||||
what: String,
|
||||
},
|
||||
/// A required system service is missing.
|
||||
MissingSystemComponent {
|
||||
/// Names the component, so the user knows what to install or start.
|
||||
component: String,
|
||||
},
|
||||
/// This build does not include the capability.
|
||||
NotBuilt,
|
||||
/// The platform genuinely cannot do this.
|
||||
UnsupportedPlatform,
|
||||
}
|
||||
|
||||
impl fmt::Display for UnavailableReason {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::NoAdapter => write!(f, "no adapter is present"),
|
||||
Self::AdapterOff => write!(f, "the adapter is switched off"),
|
||||
Self::PermissionDenied { what } => write!(f, "{what} permission was not granted"),
|
||||
Self::MissingSystemComponent { component } => {
|
||||
write!(f, "{component} is not available on this system")
|
||||
}
|
||||
Self::NotBuilt => write!(f, "this build does not include it"),
|
||||
Self::UnsupportedPlatform => write!(f, "this platform does not support it"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether one capability is available, and why not when it is not.
|
||||
///
|
||||
/// Queried at runtime so the interface can present something unavailable as unavailable rather
|
||||
/// than letting the user try it and meet a failure.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Capability {
|
||||
/// Which capability this describes.
|
||||
pub name: CapabilityName,
|
||||
/// Whether it can be used right now.
|
||||
pub available: bool,
|
||||
/// Why not, when it cannot.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<UnavailableReason>,
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
/// Declares a capability as available.
|
||||
pub fn available(name: CapabilityName) -> Self {
|
||||
Self {
|
||||
name,
|
||||
available: true,
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Declares a capability as unavailable, with the reason the user needs.
|
||||
pub fn unavailable(name: CapabilityName, reason: UnavailableReason) -> Self {
|
||||
Self {
|
||||
name,
|
||||
available: false,
|
||||
reason: Some(reason),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every capability's status on this machine.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct CapabilitySet(Vec<Capability>);
|
||||
|
||||
impl CapabilitySet {
|
||||
/// Builds a set from a list of capabilities.
|
||||
pub fn new(capabilities: Vec<Capability>) -> Self {
|
||||
Self(capabilities)
|
||||
}
|
||||
|
||||
/// Looks up one capability.
|
||||
pub fn get(&self, name: CapabilityName) -> Option<&Capability> {
|
||||
self.0.iter().find(|c| c.name == name)
|
||||
}
|
||||
|
||||
/// Reports whether a capability is available, treating an unknown one as unavailable.
|
||||
pub fn is_available(&self, name: CapabilityName) -> bool {
|
||||
self.get(name).is_some_and(|c| c.available)
|
||||
}
|
||||
|
||||
/// Returns every capability.
|
||||
pub fn all(&self) -> &[Capability] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Returns the capabilities this platform has not answered for.
|
||||
///
|
||||
/// Principle IV forbids a silent gap, so an unanswered capability is a bug in a backend, not
|
||||
/// an implicit "no".
|
||||
pub fn unanswered(&self) -> Vec<CapabilityName> {
|
||||
CapabilityName::all()
|
||||
.into_iter()
|
||||
.filter(|name| self.get(*name).is_none())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Each capability's identifier is exactly the name serde writes for it.
|
||||
///
|
||||
/// The identifier travels to clients over the gRPC contract and the serialised name travels
|
||||
/// in JSON output, so a client matching either must see the same spelling. Two hand-kept
|
||||
/// spellings of one name drift apart otherwise.
|
||||
#[test]
|
||||
fn each_identifier_is_the_serialised_name() {
|
||||
for name in CapabilityName::all() {
|
||||
let serialised = serde_json::to_string(&name).expect("a capability name serialises");
|
||||
assert_eq!(
|
||||
serialised,
|
||||
format!("\"{}\"", name.id()),
|
||||
"{name:?}: the contract identifier must match the serialised name"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1018
crates/core/src/config.rs
Normal file
1018
crates/core/src/config.rs
Normal file
File diff suppressed because it is too large
Load diff
269
crates/core/src/controls.rs
Normal file
269
crates/core/src/controls.rs
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
//! The controller state an endpoint has been sent, so a recovered link can be brought back to it.
|
||||
//!
|
||||
//! A link that drops and returns has lost whatever changed while it was down, and a fresh session
|
||||
//! starts with an empty recovery journal. Without a resend the receiver keeps the volume, program
|
||||
//! and pitch it last heard, which is FR-027's stale value. Notes are deliberately absent: a note
|
||||
//! is silenced when its link drops (FR-026) and must never be replayed.
|
||||
|
||||
use crate::midi::{CC_ALL_SOUND_OFF, CC_RESET_ALL_CONTROLLERS, Channel, MidiMessage};
|
||||
|
||||
/// Bank select, most and least significant bytes, which have to reach the receiver before the
|
||||
/// program change they qualify.
|
||||
const CC_BANK_SELECT_MSB: u8 = 0;
|
||||
const CC_BANK_SELECT_LSB: u8 = 32;
|
||||
|
||||
/// Controllers that only mean something in sequence with others, and are not replayed.
|
||||
///
|
||||
/// Data entry (6 and 38) and increment and decrement (96 and 97) apply to whichever parameter
|
||||
/// the RPN and NRPN selectors (98 to 101) last chose. Replayed in numeric order they would land
|
||||
/// on the wrong parameter, so a stale pitch-bend range is the lesser harm than a wrong one.
|
||||
const SEQUENCED: [u8; 8] = [6, 38, 96, 97, 98, 99, 100, 101];
|
||||
|
||||
/// The last values one channel was sent.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct ChannelControls {
|
||||
/// Per controller below the channel-mode range, which starts at all-sound-off.
|
||||
controllers: [Option<u8>; CC_ALL_SOUND_OFF as usize],
|
||||
program: Option<u8>,
|
||||
pitch: Option<u16>,
|
||||
pressure: Option<u8>,
|
||||
}
|
||||
|
||||
impl Default for ChannelControls {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
controllers: [None; CC_ALL_SOUND_OFF as usize],
|
||||
program: None,
|
||||
pitch: None,
|
||||
pressure: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The controller, program, pitch-bend and channel-pressure values an endpoint was last sent,
|
||||
/// per channel.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Controls {
|
||||
channels: [ChannelControls; 16],
|
||||
}
|
||||
|
||||
impl Controls {
|
||||
/// Creates a record with nothing sent.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Updates the record from one message on its way to the endpoint.
|
||||
pub fn record(&mut self, message: &MidiMessage) {
|
||||
let Some(channel) = message.channel() else {
|
||||
return;
|
||||
};
|
||||
let Some(state) = self.channels.get_mut(usize::from(channel.index())) else {
|
||||
return;
|
||||
};
|
||||
match *message {
|
||||
MidiMessage::ControlChange {
|
||||
controller, value, ..
|
||||
} => {
|
||||
// Reset all controllers returns a channel to its defaults, so there is nothing
|
||||
// left to restore on it.
|
||||
if controller == CC_RESET_ALL_CONTROLLERS {
|
||||
*state = ChannelControls {
|
||||
program: state.program,
|
||||
..ChannelControls::default()
|
||||
};
|
||||
} else if !SEQUENCED.contains(&controller)
|
||||
&& let Some(slot) = state.controllers.get_mut(usize::from(controller))
|
||||
{
|
||||
*slot = Some(value);
|
||||
}
|
||||
}
|
||||
MidiMessage::ProgramChange { program, .. } => state.program = Some(program),
|
||||
MidiMessage::PitchBend { value, .. } => state.pitch = Some(value),
|
||||
MidiMessage::ChannelAftertouch { pressure, .. } => state.pressure = Some(pressure),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the messages that bring a receiver back to what it was last sent.
|
||||
///
|
||||
/// Per channel: bank select, then the program it qualifies, then every other controller,
|
||||
/// then pitch bend and pressure. Empty when nothing was ever sent.
|
||||
pub fn restore(&self) -> Vec<MidiMessage> {
|
||||
let mut messages = Vec::new();
|
||||
for channel in Channel::all() {
|
||||
let Some(state) = self.channels.get(usize::from(channel.index())) else {
|
||||
continue;
|
||||
};
|
||||
let control = |controller: u8| {
|
||||
state
|
||||
.controllers
|
||||
.get(usize::from(controller))
|
||||
.copied()
|
||||
.flatten()
|
||||
.map(|value| MidiMessage::ControlChange {
|
||||
channel,
|
||||
controller,
|
||||
value,
|
||||
})
|
||||
};
|
||||
messages.extend(control(CC_BANK_SELECT_MSB));
|
||||
messages.extend(control(CC_BANK_SELECT_LSB));
|
||||
messages.extend(
|
||||
state
|
||||
.program
|
||||
.map(|program| MidiMessage::ProgramChange { channel, program }),
|
||||
);
|
||||
messages.extend(
|
||||
(0..CC_ALL_SOUND_OFF)
|
||||
.filter(|controller| {
|
||||
*controller != CC_BANK_SELECT_MSB && *controller != CC_BANK_SELECT_LSB
|
||||
})
|
||||
.filter_map(control),
|
||||
);
|
||||
messages.extend(
|
||||
state
|
||||
.pitch
|
||||
.map(|value| MidiMessage::PitchBend { channel, value }),
|
||||
);
|
||||
messages.extend(
|
||||
state
|
||||
.pressure
|
||||
.map(|pressure| MidiMessage::ChannelAftertouch { channel, pressure }),
|
||||
);
|
||||
}
|
||||
messages
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn channel(number: u8) -> Channel {
|
||||
Channel::new(number).expect("a channel")
|
||||
}
|
||||
|
||||
fn control(number: u8, controller: u8, value: u8) -> MidiMessage {
|
||||
MidiMessage::ControlChange {
|
||||
channel: channel(number),
|
||||
controller,
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
fn program(number: u8, program: u8) -> MidiMessage {
|
||||
MidiMessage::ProgramChange {
|
||||
channel: channel(number),
|
||||
program,
|
||||
}
|
||||
}
|
||||
|
||||
fn pitch(number: u8, value: u16) -> MidiMessage {
|
||||
MidiMessage::PitchBend {
|
||||
channel: channel(number),
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
fn pressure(number: u8, pressure: u8) -> MidiMessage {
|
||||
MidiMessage::ChannelAftertouch {
|
||||
channel: channel(number),
|
||||
pressure,
|
||||
}
|
||||
}
|
||||
|
||||
/// The resend after a recovery brings a receiver back to the last value of each control, in
|
||||
/// an order the receiver can act on, and replays nothing that would do harm (FR-027).
|
||||
///
|
||||
/// Per channel, bank select comes before the program change it qualifies, as the MIDI 1.0
|
||||
/// specification requires for a bank to apply. Data entry, increment and decrement and the
|
||||
/// RPN and NRPN selectors (controllers 6, 38 and 96 to 101) only mean something in sequence,
|
||||
/// so they are left out. Notes are silenced when a link drops (FR-026) and never replayed,
|
||||
/// and reset-all-controllers (121) leaves only the program to restore.
|
||||
#[test]
|
||||
fn a_recovered_link_is_sent_the_last_value_of_each_control_in_an_order_it_can_use() {
|
||||
let cases = [
|
||||
("nothing sent restores nothing", vec![], vec![]),
|
||||
(
|
||||
"the last value of each control is restored, channel by channel",
|
||||
vec![
|
||||
control(0, 7, 40),
|
||||
control(0, 7, 90),
|
||||
control(2, 10, 64),
|
||||
program(2, 12),
|
||||
pitch(0, 9_000),
|
||||
pressure(0, 33),
|
||||
],
|
||||
vec![
|
||||
control(0, 7, 90),
|
||||
pitch(0, 9_000),
|
||||
pressure(0, 33),
|
||||
program(2, 12),
|
||||
control(2, 10, 64),
|
||||
],
|
||||
),
|
||||
(
|
||||
"bank select goes before the program it qualifies",
|
||||
vec![
|
||||
control(0, 1, 20),
|
||||
program(0, 5),
|
||||
control(0, 32, 3),
|
||||
control(0, 0, 1),
|
||||
],
|
||||
vec![
|
||||
control(0, 0, 1),
|
||||
control(0, 32, 3),
|
||||
program(0, 5),
|
||||
control(0, 1, 20),
|
||||
],
|
||||
),
|
||||
(
|
||||
"notes and poly pressure are never replayed",
|
||||
vec![
|
||||
MidiMessage::NoteOn {
|
||||
channel: channel(0),
|
||||
note: 60,
|
||||
velocity: 100,
|
||||
},
|
||||
MidiMessage::PolyAftertouch {
|
||||
channel: channel(0),
|
||||
note: 60,
|
||||
pressure: 50,
|
||||
},
|
||||
],
|
||||
vec![],
|
||||
),
|
||||
(
|
||||
"sequenced controllers and channel-mode messages are left out",
|
||||
SEQUENCED
|
||||
.iter()
|
||||
.map(|controller| control(0, *controller, 1))
|
||||
.chain([control(0, CC_ALL_SOUND_OFF, 0)])
|
||||
.collect(),
|
||||
vec![],
|
||||
),
|
||||
(
|
||||
"reset all controllers leaves only the program",
|
||||
vec![
|
||||
control(0, 7, 90),
|
||||
program(0, 9),
|
||||
pitch(0, 100),
|
||||
control(0, CC_RESET_ALL_CONTROLLERS, 0),
|
||||
],
|
||||
vec![program(0, 9)],
|
||||
),
|
||||
];
|
||||
for (case, sent, want) in cases {
|
||||
let mut controls = Controls::new();
|
||||
for message in &sent {
|
||||
controls.record(message);
|
||||
}
|
||||
assert_eq!(
|
||||
controls.restore(),
|
||||
want,
|
||||
"{case}: the resend must bring the receiver back to what it was last sent"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
132
crates/core/src/counters.rs
Normal file
132
crates/core/src/counters.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
//! Per-connection traffic counters.
|
||||
|
||||
use jiff::Timestamp;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
|
||||
|
||||
/// Sentinel stored in `last_activity` when nothing has been seen yet.
|
||||
const NO_ACTIVITY: i64 = i64::MIN;
|
||||
|
||||
/// Live traffic counts for one endpoint or route.
|
||||
///
|
||||
/// Every field is an atomic because these are incremented from the real-time path, where taking a
|
||||
/// lock or allocating is forbidden. Reads are approximate under concurrent writes, which is
|
||||
/// correct for a display: exact cross-field consistency is not worth stalling MIDI for.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TrafficCounters {
|
||||
messages_sent: AtomicU64,
|
||||
messages_received: AtomicU64,
|
||||
bytes_sent: AtomicU64,
|
||||
bytes_received: AtomicU64,
|
||||
messages_lost: AtomicU64,
|
||||
messages_recovered: AtomicU64,
|
||||
messages_dropped: AtomicU64,
|
||||
packets_malformed: AtomicU64,
|
||||
last_activity: AtomicI64,
|
||||
last_received: AtomicI64,
|
||||
last_sent: AtomicI64,
|
||||
}
|
||||
|
||||
impl TrafficCounters {
|
||||
/// Creates counters with every value at zero and no activity recorded.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
last_activity: AtomicI64::new(NO_ACTIVITY),
|
||||
last_received: AtomicI64::new(NO_ACTIVITY),
|
||||
last_sent: AtomicI64::new(NO_ACTIVITY),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a message sent, with its encoded size.
|
||||
pub fn record_sent(&self, bytes: u64, at: Timestamp) {
|
||||
self.messages_sent.fetch_add(1, Ordering::Relaxed);
|
||||
self.bytes_sent.fetch_add(bytes, Ordering::Relaxed);
|
||||
let nanos = self.touch(at);
|
||||
self.last_sent.store(nanos, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Records a message received, with its encoded size.
|
||||
pub fn record_received(&self, bytes: u64, at: Timestamp) {
|
||||
self.messages_received.fetch_add(1, Ordering::Relaxed);
|
||||
self.bytes_received.fetch_add(bytes, Ordering::Relaxed);
|
||||
let nanos = self.touch(at);
|
||||
self.last_received.store(nanos, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Records a message we discarded ourselves because a buffer was full.
|
||||
///
|
||||
/// Deliberately separate from `messages_lost`: this is our own backpressure, not the
|
||||
/// network's packet loss, and conflating the two would hide a capacity problem behind a
|
||||
/// network excuse.
|
||||
pub fn record_dropped(&self, count: u64) {
|
||||
self.messages_dropped.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Records packets from the device that could not be decoded and were discarded whole.
|
||||
///
|
||||
/// Separate from both loss and drops: the device sent something wrong, which neither the
|
||||
/// network nor our own capacity explains.
|
||||
pub fn record_malformed(&self, count: u64) {
|
||||
self.packets_malformed.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Returns an immediate copy for display or serialisation.
|
||||
pub fn snapshot(&self) -> CounterSnapshot {
|
||||
let time = |stored: &AtomicI64| match stored.load(Ordering::Relaxed) {
|
||||
NO_ACTIVITY => None,
|
||||
nanos => Timestamp::from_nanosecond(i128::from(nanos)).ok(),
|
||||
};
|
||||
CounterSnapshot {
|
||||
messages_sent: self.messages_sent.load(Ordering::Relaxed),
|
||||
messages_received: self.messages_received.load(Ordering::Relaxed),
|
||||
bytes_sent: self.bytes_sent.load(Ordering::Relaxed),
|
||||
bytes_received: self.bytes_received.load(Ordering::Relaxed),
|
||||
messages_lost: self.messages_lost.load(Ordering::Relaxed),
|
||||
messages_recovered: self.messages_recovered.load(Ordering::Relaxed),
|
||||
messages_dropped: self.messages_dropped.load(Ordering::Relaxed),
|
||||
packets_malformed: self.packets_malformed.load(Ordering::Relaxed),
|
||||
last_activity: time(&self.last_activity),
|
||||
last_received: time(&self.last_received),
|
||||
last_sent: time(&self.last_sent),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the time of the most recent traffic, saturating rather than failing on odd clocks,
|
||||
/// and returns it as stored for the direction's own time.
|
||||
fn touch(&self, at: Timestamp) -> i64 {
|
||||
let nanos = i64::try_from(at.as_nanosecond()).unwrap_or(i64::MAX);
|
||||
self.last_activity.store(nanos, Ordering::Relaxed);
|
||||
nanos
|
||||
}
|
||||
}
|
||||
|
||||
/// An immediate copy of a set of counters.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CounterSnapshot {
|
||||
/// Messages handed to the transport.
|
||||
pub messages_sent: u64,
|
||||
/// Messages taken from the transport.
|
||||
pub messages_received: u64,
|
||||
/// Encoded bytes sent.
|
||||
pub bytes_sent: u64,
|
||||
/// Encoded bytes received.
|
||||
pub bytes_received: u64,
|
||||
/// Messages the network lost.
|
||||
pub messages_lost: u64,
|
||||
/// Messages rebuilt from the recovery journal.
|
||||
pub messages_recovered: u64,
|
||||
/// Messages we discarded because a buffer was full.
|
||||
pub messages_dropped: u64,
|
||||
/// Packets from the device that could not be decoded.
|
||||
#[serde(default)]
|
||||
pub packets_malformed: u64,
|
||||
/// When traffic was last seen.
|
||||
pub last_activity: Option<Timestamp>,
|
||||
/// When a message was last received.
|
||||
#[serde(default)]
|
||||
pub last_received: Option<Timestamp>,
|
||||
/// When a message was last sent.
|
||||
#[serde(default)]
|
||||
pub last_sent: Option<Timestamp>,
|
||||
}
|
||||
156
crates/core/src/devicetime.rs
Normal file
156
crates/core/src/devicetime.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
//! Delivering a Bluetooth device's messages when its own clock says they were played (FR-019).
|
||||
//!
|
||||
//! A Bluetooth MIDI device collects what is played into packets sent once per connection
|
||||
//! interval, 7.5 ms or more, and stamps each message with the millisecond it was played. Delivered
|
||||
//! as they arrive, a chord played over a few milliseconds lands all at once and a run played
|
||||
//! evenly lands in bunches. Holding each message until its timestamp, measured against the latest
|
||||
//! a packet has run, gives back the spacing the player made, at the cost of that much latency.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// The longest any message is held. A connection interval is 7.5 to 15 ms on most devices, and
|
||||
/// holding longer than about one of them trades more latency than the spacing is worth.
|
||||
pub const MAX_WAIT: Duration = Duration::from_millis(10);
|
||||
|
||||
/// How fast the anchor relaxes after a late packet: by 1/64 of the device time that passes, a
|
||||
/// right shift of six, so one delayed packet does not keep every later message waiting.
|
||||
const RELAX_SHIFT: u32 = 6;
|
||||
|
||||
/// Relates a device's millisecond clock to this machine's, and says how long to hold a message.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct DeviceTiming {
|
||||
/// Local minus device time for the latest arrival seen, relaxed over time, in microseconds so
|
||||
/// relaxation too small to show in milliseconds still accumulates. Holding a message until
|
||||
/// its device time plus this delivers it in step with that arrival.
|
||||
anchor_us: Option<i64>,
|
||||
/// The device time last seen, from which relaxation is measured.
|
||||
last_device: Option<u64>,
|
||||
}
|
||||
|
||||
impl DeviceTiming {
|
||||
/// Creates a relation with nothing observed.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Returns how long to hold a message stamped `device_ms` that arrived at `local_ms`.
|
||||
///
|
||||
/// Both are milliseconds on their own clocks, each counting forward. A message that arrived
|
||||
/// as late as any before it is not held at all; one that arrived early, relative to the
|
||||
/// others, is held by how early it came, up to `MAX_WAIT`.
|
||||
pub fn hold(&mut self, device_ms: u64, local_ms: u64) -> Duration {
|
||||
let micros = |millis: u64| {
|
||||
i64::try_from(millis)
|
||||
.unwrap_or(i64::MAX)
|
||||
.saturating_mul(1_000)
|
||||
};
|
||||
let lateness = micros(local_ms).saturating_sub(micros(device_ms));
|
||||
|
||||
// Relax the anchor by a share of the device time that has passed since the last message.
|
||||
let relaxed = match (self.anchor_us, self.last_device) {
|
||||
(Some(anchor), Some(last)) => {
|
||||
let passed = micros(device_ms.saturating_sub(last));
|
||||
anchor.saturating_sub(passed >> RELAX_SHIFT)
|
||||
}
|
||||
(anchor, _) => anchor.unwrap_or(lateness),
|
||||
};
|
||||
self.last_device = Some(device_ms);
|
||||
|
||||
// Never earlier than this arrival, which has already happened, and never so far ahead
|
||||
// that the hold exceeds the bound.
|
||||
let bound = i64::try_from(MAX_WAIT.as_micros()).unwrap_or(i64::MAX);
|
||||
let anchor = relaxed.max(lateness).min(lateness.saturating_add(bound));
|
||||
self.anchor_us = Some(anchor);
|
||||
|
||||
Duration::from_micros(u64::try_from(anchor.saturating_sub(lateness)).unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ms(value: u64) -> Duration {
|
||||
Duration::from_millis(value)
|
||||
}
|
||||
|
||||
/// Reports whether a hold is within a tenth of a millisecond of what was expected, which
|
||||
/// relaxation between notes of one packet may take off.
|
||||
fn about(held: Duration, expected: Duration) -> bool {
|
||||
held.abs_diff(expected) <= Duration::from_micros(100)
|
||||
}
|
||||
|
||||
/// Each message is held until its device timestamp, measured against the latest arrival, and
|
||||
/// never longer than `MAX_WAIT` (FR-019).
|
||||
///
|
||||
/// Each arrival is (device ms, local ms, wanted hold). A packet stamped 1000 ms arriving at
|
||||
/// 20 ms sets the anchor; the next packet, one 10 ms connection interval later, arrives on
|
||||
/// time, so its first note is held 0 ms and a note played 3 ms after it is held 3 ms. Only
|
||||
/// differences matter, so a device clock millions of milliseconds away relates the same
|
||||
/// way. A note stamped 40 ms after one it arrived with would be held 40 ms, so the 10 ms
|
||||
/// bound applies.
|
||||
#[test]
|
||||
fn a_message_is_held_until_its_device_time_within_the_bound() {
|
||||
type Arrivals = &'static [(u64, u64, u64)];
|
||||
let cases: [(&str, Arrivals); 3] = [
|
||||
(
|
||||
"an on-time packet holds only its later notes",
|
||||
&[(1_000, 20, 0), (1_010, 30, 0), (1_013, 30, 3)],
|
||||
),
|
||||
(
|
||||
"clocks far apart still relate by their differences",
|
||||
&[(5, 9_000_000, 0), (9, 9_000_000, 4)],
|
||||
),
|
||||
(
|
||||
"nothing is held longer than the bound",
|
||||
&[(1_000, 50, 0), (1_040, 50, 10)],
|
||||
),
|
||||
];
|
||||
for (case, arrivals) in cases {
|
||||
let mut timing = DeviceTiming::new();
|
||||
for (device, local, want) in arrivals {
|
||||
let held = timing.hold(*device, *local);
|
||||
assert!(
|
||||
about(held, ms(*want)),
|
||||
"{case}: stamped {device} and arriving at {local}, held {held:?} rather than \
|
||||
{want} ms, so the spacing the player made is lost"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One late packet raises the anchor, and the anchor relaxes until on-time packets are not
|
||||
/// held at all.
|
||||
///
|
||||
/// A packet 8 ms late sets the anchor 8 ms later than on-time packets need. It relaxes by
|
||||
/// 1/64 of the device time that passes, so each 10 ms packet takes 10 / 64 = 0.156 ms off,
|
||||
/// and 8 ms is gone after about 52 packets; 200 are ample. Without relaxation one delayed
|
||||
/// packet would add 8 ms of latency to everything after it.
|
||||
#[test]
|
||||
fn one_late_packet_does_not_keep_everything_after_it_waiting() {
|
||||
let mut timing = DeviceTiming::new();
|
||||
let _ = timing.hold(1_000, 20);
|
||||
assert_eq!(
|
||||
timing.hold(1_010, 38),
|
||||
ms(0),
|
||||
"a late packet has already waited and must not be held further"
|
||||
);
|
||||
let early = timing.hold(1_020, 40);
|
||||
assert!(
|
||||
early > ms(0),
|
||||
"the packet after a late one must still wait while the anchor is raised"
|
||||
);
|
||||
|
||||
let (mut device, mut local, mut held) = (1_020, 40, early);
|
||||
for _ in 0..200 {
|
||||
device += 10;
|
||||
local += 10;
|
||||
held = timing.hold(device, local);
|
||||
}
|
||||
assert_eq!(
|
||||
held,
|
||||
ms(0),
|
||||
"the anchor must relax until on-time packets are not held"
|
||||
);
|
||||
}
|
||||
}
|
||||
692
crates/core/src/endpoint.rs
Normal file
692
crates/core/src/endpoint.rs
Normal file
|
|
@ -0,0 +1,692 @@
|
|||
//! Endpoints: everything MIDI can flow to or from.
|
||||
|
||||
use crate::fingerprint::{DeviceFingerprint, MatchConfidence};
|
||||
use crate::ids::{EndpointId, PeerId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
/// Shortest allowed endpoint name.
|
||||
const NAME_MIN: usize = 1;
|
||||
/// Longest allowed endpoint name.
|
||||
const NAME_MAX: usize = 128;
|
||||
|
||||
/// Why a proposed endpoint name was rejected.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum NameError {
|
||||
/// The name was empty, or only whitespace.
|
||||
#[error("endpoint name cannot be empty")]
|
||||
Empty,
|
||||
/// The name exceeded the length limit.
|
||||
#[error("endpoint name cannot exceed {NAME_MAX} characters, got {0}")]
|
||||
TooLong(usize),
|
||||
/// The name contained a control character.
|
||||
#[error("endpoint name cannot contain control characters")]
|
||||
ControlCharacter,
|
||||
}
|
||||
|
||||
/// A validated endpoint name.
|
||||
///
|
||||
/// Names are trimmed on the way in, so trailing whitespace cannot make two endpoints look
|
||||
/// distinct while displaying identically.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct EndpointName(String);
|
||||
|
||||
impl EndpointName {
|
||||
/// Validates and normalises a proposed name.
|
||||
pub fn new(raw: impl AsRef<str>) -> Result<Self, NameError> {
|
||||
let trimmed = raw.as_ref().trim();
|
||||
if trimmed.chars().count() < NAME_MIN {
|
||||
return Err(NameError::Empty);
|
||||
}
|
||||
let length = trimmed.chars().count();
|
||||
if length > NAME_MAX {
|
||||
return Err(NameError::TooLong(length));
|
||||
}
|
||||
if trimmed.chars().any(char::is_control) {
|
||||
return Err(NameError::ControlCharacter);
|
||||
}
|
||||
Ok(Self(trimmed.to_owned()))
|
||||
}
|
||||
|
||||
/// Returns the name as text.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for EndpointName {
|
||||
type Error = NameError;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EndpointName> for String {
|
||||
fn from(value: EndpointName) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EndpointName {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which way MIDI can travel through an endpoint.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Direction {
|
||||
/// MIDI arrives from it, so it can only be a route source.
|
||||
Input,
|
||||
/// MIDI leaves through it, so it can only be a route destination.
|
||||
Output,
|
||||
/// Both.
|
||||
Bidirectional,
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
/// Reports whether this endpoint can be a route source.
|
||||
pub fn can_source(&self) -> bool {
|
||||
matches!(self, Self::Input | Self::Bidirectional)
|
||||
}
|
||||
|
||||
/// Reports whether this endpoint can be a route destination.
|
||||
pub fn can_sink(&self) -> bool {
|
||||
matches!(self, Self::Output | Self::Bidirectional)
|
||||
}
|
||||
}
|
||||
|
||||
/// The most connectors of one kind a virtual port may have, as the IAC Driver allows.
|
||||
pub const MAX_CONNECTORS: u8 = 16;
|
||||
|
||||
/// A virtual port that exists only so local applications can exchange MIDI.
|
||||
///
|
||||
/// It has MIDI In connectors, which other applications send to, and MIDI Out connectors, which
|
||||
/// they receive from, at least one of each, as the IAC Driver's buses do (FR-002, FR-002a). A
|
||||
/// count above one shows to other applications as numbered ports.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VirtualPort {
|
||||
/// How many MIDI In connectors it has: what other applications send to, and so what a route
|
||||
/// can start from.
|
||||
#[serde(default = "one_connector")]
|
||||
pub inputs: u8,
|
||||
/// How many MIDI Out connectors it has: what other applications receive from, and so what a
|
||||
/// route can end at.
|
||||
#[serde(default = "one_connector")]
|
||||
pub outputs: u8,
|
||||
/// The identifiers pinned on each MIDI In connector's platform endpoint, in order, so other
|
||||
/// applications keep recognising them across restarts. Assigned by the system on first
|
||||
/// creation, then reused.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub input_ids: Vec<u32>,
|
||||
/// The identifiers pinned on each MIDI Out connector's platform endpoint, in order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub output_ids: Vec<u32>,
|
||||
/// The single identifier a port had before it had connectors, which was its MIDI Out's.
|
||||
/// Read, moved into `output_ids`, and never written again.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub platform_unique_id: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for VirtualPort {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inputs: 1,
|
||||
outputs: 1,
|
||||
input_ids: Vec::new(),
|
||||
output_ids: Vec::new(),
|
||||
platform_unique_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VirtualPort {
|
||||
/// Creates a port with the given connector counts, each kept between one and sixteen.
|
||||
pub fn with_connectors(inputs: u8, outputs: u8) -> Self {
|
||||
Self {
|
||||
inputs: inputs.clamp(1, MAX_CONNECTORS),
|
||||
outputs: outputs.clamp(1, MAX_CONNECTORS),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the port as a file left it, made whole: counts kept between one and sixteen, and
|
||||
/// a single identifier from before connectors moved to the MIDI Out it belonged to.
|
||||
///
|
||||
/// Clamped rather than rejected, because a count out of range in a hand-edited file would
|
||||
/// otherwise make the whole file unreadable, and an unreadable file is set aside.
|
||||
fn settled(mut self) -> Self {
|
||||
self.inputs = self.inputs.clamp(1, MAX_CONNECTORS);
|
||||
self.outputs = self.outputs.clamp(1, MAX_CONNECTORS);
|
||||
if let Some(id) = self.platform_unique_id.take()
|
||||
&& self.output_ids.is_empty()
|
||||
{
|
||||
self.output_ids.push(id);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns one, the connector count a virtual port has when its file does not say.
|
||||
fn one_connector() -> u8 {
|
||||
1
|
||||
}
|
||||
|
||||
/// Returns the name other applications see for one connector of a port.
|
||||
///
|
||||
/// A port with one connector of a kind shows under its own name; with several, each is numbered
|
||||
/// from one, as "Keys 1" and "Keys 2".
|
||||
pub fn connector_name(name: &str, count: u8, index: u8) -> String {
|
||||
if count > 1 {
|
||||
format!("{name} {}", u16::from(index) + 1)
|
||||
} else {
|
||||
name.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// MIDI hardware attached to this computer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PhysicalDevice {
|
||||
/// The values used to recognise this hardware again after it is replugged.
|
||||
pub fingerprint: DeviceFingerprint,
|
||||
/// Whether the hardware is attached right now.
|
||||
#[serde(skip, default)]
|
||||
pub present: bool,
|
||||
/// How confidently the attached hardware was matched to this stored entry.
|
||||
#[serde(skip, default = "default_confidence")]
|
||||
pub confidence: MatchConfidence,
|
||||
/// Names the application holding the device exclusively, when one does.
|
||||
#[serde(skip, default)]
|
||||
pub claimed_by: Option<String>,
|
||||
/// Whether this is another application's port rather than hardware.
|
||||
///
|
||||
/// Kept only while a route names it: an application's port that goes away with nothing
|
||||
/// routed to or from it is forgotten, where hardware would be remembered.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub software: bool,
|
||||
}
|
||||
|
||||
/// Returns the confidence a freshly loaded device entry starts with.
|
||||
fn default_confidence() -> MatchConfidence {
|
||||
MatchConfidence::None
|
||||
}
|
||||
|
||||
/// How a session treats incoming invitations.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InvitationPolicy {
|
||||
/// Ask the user each time. The default, because silently accepting inbound connections is a
|
||||
/// surprising thing for a program to do.
|
||||
#[default]
|
||||
Prompt,
|
||||
/// Accept without asking from peers the user has already trusted.
|
||||
AcceptKnown,
|
||||
/// Accept from anyone.
|
||||
AcceptAll,
|
||||
/// Refuse everything.
|
||||
RejectAll,
|
||||
}
|
||||
|
||||
/// What to do about one invitation that has arrived.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum InvitationDecision {
|
||||
/// Let the peer in.
|
||||
Accept,
|
||||
/// Tell the peer no.
|
||||
Refuse,
|
||||
/// Neither, until the user says.
|
||||
Ask,
|
||||
}
|
||||
|
||||
impl InvitationPolicy {
|
||||
/// Decides what to do about an invitation from a peer.
|
||||
///
|
||||
/// A peer the user has already trusted is let in under every policy but `RejectAll`, which is
|
||||
/// what "always accept from this machine" has to mean if answering a prompt is to be worth
|
||||
/// anything.
|
||||
pub fn decide(self, trusted: bool) -> InvitationDecision {
|
||||
match self {
|
||||
Self::RejectAll => InvitationDecision::Refuse,
|
||||
Self::AcceptAll => InvitationDecision::Accept,
|
||||
Self::AcceptKnown | Self::Prompt if trusted => InvitationDecision::Accept,
|
||||
// An unknown peer under AcceptKnown is refused rather than held: the user has already
|
||||
// said which machines may connect, so there is nothing left to ask them.
|
||||
Self::AcceptKnown => InvitationDecision::Refuse,
|
||||
Self::Prompt => InvitationDecision::Ask,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An RTP-MIDI session with another machine or device.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NetworkSession {
|
||||
/// The name advertised to other machines.
|
||||
pub local_name: EndpointName,
|
||||
/// The control port. The data port is always this plus one.
|
||||
pub control_port: u16,
|
||||
/// The peer this session connects to, when one is chosen.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub peer: Option<PeerId>,
|
||||
/// The machines this side connected beside the peer, reconnected like the peer until the
|
||||
/// user disconnects each (FR-015i). Written only when there are some.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub other_peers: Vec<PeerId>,
|
||||
/// How incoming invitations are handled.
|
||||
#[serde(default)]
|
||||
pub invitation_policy: InvitationPolicy,
|
||||
/// Whether other applications on this computer see the session as a MIDI port of its name,
|
||||
/// joined to it both ways (FR-015h).
|
||||
#[serde(default = "default_enabled")]
|
||||
pub automatic_port: bool,
|
||||
/// The platform identifier of the automatic port's MIDI In, pinned so other applications
|
||||
/// recognise the same port after a restart.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub port_input_id: Option<u32>,
|
||||
/// The platform identifier of the automatic port's MIDI Out.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub port_output_id: Option<u32>,
|
||||
}
|
||||
|
||||
impl NetworkSession {
|
||||
/// Creates a session advertised under `local_name`, with its automatic port on.
|
||||
pub fn new(local_name: EndpointName, control_port: u16, policy: InvitationPolicy) -> Self {
|
||||
Self {
|
||||
local_name,
|
||||
control_port,
|
||||
peer: None,
|
||||
other_peers: Vec::new(),
|
||||
invitation_policy: policy,
|
||||
automatic_port: true,
|
||||
port_input_id: None,
|
||||
port_output_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which side of a Bluetooth link this endpoint represents.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BleRole {
|
||||
/// We connected out to a device.
|
||||
Central,
|
||||
/// A device connected to us while we were advertising.
|
||||
Peripheral,
|
||||
}
|
||||
|
||||
/// A Bluetooth LE MIDI link.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BluetoothDevice {
|
||||
/// The platform's address for the device.
|
||||
pub address: String,
|
||||
/// Which side of the link we are.
|
||||
pub role: BleRole,
|
||||
/// Whether the user has paired with this device before.
|
||||
#[serde(default)]
|
||||
pub paired: bool,
|
||||
/// Signal strength, when the platform reports it.
|
||||
#[serde(skip, default)]
|
||||
pub rssi: Option<i16>,
|
||||
}
|
||||
|
||||
/// What kind of thing an endpoint is, with the data specific to that kind.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum EndpointKind {
|
||||
/// A virtual port for local applications.
|
||||
VirtualPort(VirtualPort),
|
||||
/// Attached MIDI hardware.
|
||||
PhysicalDevice(PhysicalDevice),
|
||||
/// A network port: an RTP-MIDI session with other machines.
|
||||
///
|
||||
/// Written as `network_port`; `network_session`, the name it had before, is still read.
|
||||
#[serde(rename = "network_port", alias = "network_session")]
|
||||
NetworkSession(NetworkSession),
|
||||
/// A Bluetooth LE MIDI link.
|
||||
BluetoothDevice(BluetoothDevice),
|
||||
}
|
||||
|
||||
/// Which kind an endpoint is, as the configuration file names it.
|
||||
///
|
||||
/// Written on a route only when the name alone would not say which endpoint it means, since names
|
||||
/// may repeat across kinds.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum KindTag {
|
||||
/// A virtual port.
|
||||
VirtualPort,
|
||||
/// Attached hardware, or another application's port.
|
||||
PhysicalDevice,
|
||||
/// A network port. Written as `network_port`; `network_session` is still read.
|
||||
#[serde(rename = "network_port", alias = "network_session")]
|
||||
NetworkSession,
|
||||
/// A Bluetooth link.
|
||||
BluetoothDevice,
|
||||
}
|
||||
|
||||
impl EndpointKind {
|
||||
/// Returns which kind this is, as the configuration file names it.
|
||||
pub fn tag(&self) -> KindTag {
|
||||
match self {
|
||||
Self::VirtualPort(_) => KindTag::VirtualPort,
|
||||
Self::PhysicalDevice(_) => KindTag::PhysicalDevice,
|
||||
Self::NetworkSession(_) => KindTag::NetworkSession,
|
||||
Self::BluetoothDevice(_) => KindTag::BluetoothDevice,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a short slug naming the kind, used in listings and filters.
|
||||
pub fn slug(&self) -> &'static str {
|
||||
match self {
|
||||
Self::VirtualPort(_) => "virtual",
|
||||
Self::PhysicalDevice(_) => "physical",
|
||||
Self::NetworkSession(_) => "network",
|
||||
Self::BluetoothDevice(_) => "bluetooth",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Anything MIDI can flow to or from.
|
||||
///
|
||||
/// The kind is flattened into the endpoint when stored, so a configuration entry reads as one
|
||||
/// flat block rather than nesting a `kind` map inside a `kind` field.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "StoredEndpoint")]
|
||||
pub struct Endpoint {
|
||||
/// Stable identity, used by routes at runtime and by the daemon API.
|
||||
///
|
||||
/// Generated when absent, so a hand-written configuration file need not contain any
|
||||
/// identifiers. The daemon fills it in the next time it writes the file.
|
||||
#[serde(default)]
|
||||
pub id: EndpointId,
|
||||
/// The user-facing name.
|
||||
pub name: EndpointName,
|
||||
/// What kind of endpoint this is.
|
||||
#[serde(flatten)]
|
||||
pub kind: EndpointKind,
|
||||
/// Whether the user wants this endpoint running. Independent of whether it currently is.
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
/// Which way MIDI can travel.
|
||||
#[serde(default = "default_direction")]
|
||||
pub direction: Direction,
|
||||
}
|
||||
|
||||
/// An endpoint as a configuration file may state it, before the gaps are filled.
|
||||
///
|
||||
/// A session written by hand as a name and a kind made the whole file unreadable, because its
|
||||
/// advertised name and port were required, and an unreadable file is set aside for an empty
|
||||
/// one. Both now default as `session create` defaults them: the advertised name is the
|
||||
/// endpoint's own, and the port is the system's choice.
|
||||
#[derive(Deserialize)]
|
||||
struct StoredEndpoint {
|
||||
#[serde(default)]
|
||||
id: EndpointId,
|
||||
name: EndpointName,
|
||||
#[serde(flatten)]
|
||||
kind: StoredKind,
|
||||
#[serde(default = "default_enabled")]
|
||||
enabled: bool,
|
||||
#[serde(default = "default_direction")]
|
||||
direction: Direction,
|
||||
}
|
||||
|
||||
/// The kinds as a file may state them; only a session has anything left to fill in.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
enum StoredKind {
|
||||
VirtualPort(VirtualPort),
|
||||
PhysicalDevice(PhysicalDevice),
|
||||
#[serde(rename = "network_port", alias = "network_session")]
|
||||
NetworkSession(StoredSession),
|
||||
BluetoothDevice(BluetoothDevice),
|
||||
}
|
||||
|
||||
/// A session as a file may state it.
|
||||
#[derive(Deserialize)]
|
||||
struct StoredSession {
|
||||
#[serde(default)]
|
||||
local_name: Option<EndpointName>,
|
||||
#[serde(default)]
|
||||
control_port: u16,
|
||||
#[serde(default)]
|
||||
peer: Option<PeerId>,
|
||||
#[serde(default)]
|
||||
other_peers: Vec<PeerId>,
|
||||
#[serde(default)]
|
||||
invitation_policy: InvitationPolicy,
|
||||
#[serde(default = "default_enabled")]
|
||||
automatic_port: bool,
|
||||
#[serde(default)]
|
||||
port_input_id: Option<u32>,
|
||||
#[serde(default)]
|
||||
port_output_id: Option<u32>,
|
||||
}
|
||||
|
||||
impl From<StoredEndpoint> for Endpoint {
|
||||
fn from(stored: StoredEndpoint) -> Self {
|
||||
let kind = match stored.kind {
|
||||
StoredKind::VirtualPort(port) => EndpointKind::VirtualPort(port.settled()),
|
||||
StoredKind::PhysicalDevice(device) => EndpointKind::PhysicalDevice(device),
|
||||
StoredKind::BluetoothDevice(device) => EndpointKind::BluetoothDevice(device),
|
||||
StoredKind::NetworkSession(session) => EndpointKind::NetworkSession(NetworkSession {
|
||||
local_name: session.local_name.unwrap_or_else(|| stored.name.clone()),
|
||||
control_port: session.control_port,
|
||||
peer: session.peer,
|
||||
other_peers: session.other_peers,
|
||||
invitation_policy: session.invitation_policy,
|
||||
automatic_port: session.automatic_port,
|
||||
port_input_id: session.port_input_id,
|
||||
port_output_id: session.port_output_id,
|
||||
}),
|
||||
};
|
||||
// A virtual port always has a connector of each kind, so an in-only or out-only port
|
||||
// from before connectors is read as one of each (R-078).
|
||||
let direction = match &kind {
|
||||
EndpointKind::VirtualPort(_) => Direction::Bidirectional,
|
||||
_ => stored.direction,
|
||||
};
|
||||
Self {
|
||||
id: stored.id,
|
||||
name: stored.name,
|
||||
kind,
|
||||
enabled: stored.enabled,
|
||||
direction,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default for endpoints that do not say whether they are switched on.
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns the default direction for endpoints that do not state one.
|
||||
fn default_direction() -> Direction {
|
||||
Direction::Bidirectional
|
||||
}
|
||||
|
||||
impl Endpoint {
|
||||
/// Creates an enabled, bidirectional endpoint.
|
||||
pub fn new(name: EndpointName, kind: EndpointKind) -> Self {
|
||||
Self {
|
||||
id: EndpointId::new(),
|
||||
name,
|
||||
kind,
|
||||
enabled: true,
|
||||
direction: Direction::Bidirectional,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether this endpoint may be the source of a route.
|
||||
pub fn can_source(&self) -> bool {
|
||||
self.direction.can_source()
|
||||
}
|
||||
|
||||
/// Reports whether this endpoint may be the destination of a route.
|
||||
pub fn can_sink(&self) -> bool {
|
||||
self.direction.can_sink()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether `name` is already taken by a different endpoint that shares its namespace.
|
||||
///
|
||||
/// Names may repeat across most kinds, but two virtual ports called the same thing would be
|
||||
/// indistinguishable to other applications. A network port shows to them as a port of its name
|
||||
/// (FR-015h), so virtual ports and network ports share one namespace.
|
||||
pub fn name_conflicts(
|
||||
endpoints: &[Endpoint],
|
||||
name: &EndpointName,
|
||||
kind_slug: &str,
|
||||
excluding: Option<EndpointId>,
|
||||
) -> bool {
|
||||
endpoints.iter().any(|existing| {
|
||||
namespace(existing.kind.slug()) == namespace(kind_slug)
|
||||
&& &existing.name == name
|
||||
&& Some(existing.id) != excluding
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns each pair of endpoints that hold one name within one namespace, earlier one first.
|
||||
///
|
||||
/// A configuration written by hand can hold such a pair, which creating and renaming refuse.
|
||||
pub fn name_clashes(endpoints: &[Endpoint]) -> Vec<(&Endpoint, &Endpoint)> {
|
||||
let mut clashes = Vec::new();
|
||||
for (index, later) in endpoints.iter().enumerate() {
|
||||
let earlier = endpoints.get(..index).unwrap_or_default();
|
||||
if let Some(first) = earlier.iter().find(|held| {
|
||||
namespace(held.kind.slug()) == namespace(later.kind.slug()) && held.name == later.name
|
||||
}) {
|
||||
clashes.push((first, later));
|
||||
}
|
||||
}
|
||||
clashes
|
||||
}
|
||||
|
||||
/// Returns the namespace names of a kind are unique within.
|
||||
///
|
||||
/// Virtual ports and network ports share one, because other applications see both as ports.
|
||||
fn namespace(kind_slug: &str) -> &str {
|
||||
match kind_slug {
|
||||
"virtual" | "network" => "port",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Each invitation policy decides as the user means it, trusted peer or not.
|
||||
///
|
||||
/// A trusted peer is let in under every policy but refusing everything, or answering "always
|
||||
/// accept from this machine" at a prompt would mean nothing, and refusing everything must
|
||||
/// refuse even a trusted peer or there is no way to shut out a machine trusted once. Under
|
||||
/// accept-known an unknown peer is refused rather than asked about, because the user has
|
||||
/// already said which machines may connect. The default asks, because silently accepting
|
||||
/// inbound connections is a surprising thing for a program to do.
|
||||
#[test]
|
||||
fn each_policy_decides_as_the_user_means_it() {
|
||||
use InvitationDecision::{Accept, Ask, Refuse};
|
||||
let cases = [
|
||||
(
|
||||
"the default asks about an unknown peer",
|
||||
InvitationPolicy::default(),
|
||||
false,
|
||||
Ask,
|
||||
),
|
||||
(
|
||||
"prompting accepts a trusted peer",
|
||||
InvitationPolicy::Prompt,
|
||||
true,
|
||||
Accept,
|
||||
),
|
||||
(
|
||||
"accept-known accepts a trusted peer",
|
||||
InvitationPolicy::AcceptKnown,
|
||||
true,
|
||||
Accept,
|
||||
),
|
||||
(
|
||||
"accept-known refuses an unknown peer",
|
||||
InvitationPolicy::AcceptKnown,
|
||||
false,
|
||||
Refuse,
|
||||
),
|
||||
(
|
||||
"accept-all accepts an unknown peer",
|
||||
InvitationPolicy::AcceptAll,
|
||||
false,
|
||||
Accept,
|
||||
),
|
||||
(
|
||||
"reject-all refuses a trusted peer",
|
||||
InvitationPolicy::RejectAll,
|
||||
true,
|
||||
Refuse,
|
||||
),
|
||||
(
|
||||
"reject-all refuses an unknown peer",
|
||||
InvitationPolicy::RejectAll,
|
||||
false,
|
||||
Refuse,
|
||||
),
|
||||
];
|
||||
for (case, policy, trusted, want) in cases {
|
||||
assert_eq!(
|
||||
policy.decide(trusted),
|
||||
want,
|
||||
"{case}: the policy must decide as the user set it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A name is trimmed, and accepted up to 128 characters without control characters.
|
||||
///
|
||||
/// Trimming stops two endpoints that display identically from coexisting. The limit is on
|
||||
/// characters rather than bytes, so the longest accepted name sits beside the shortest
|
||||
/// refused one.
|
||||
#[test]
|
||||
fn a_name_is_trimmed_and_held_to_its_limits() {
|
||||
let longest = "a".repeat(NAME_MAX);
|
||||
let too_long = "a".repeat(NAME_MAX + 1);
|
||||
let cases = [
|
||||
(
|
||||
"padding is trimmed",
|
||||
" Sequencer Bus ",
|
||||
Ok("Sequencer Bus"),
|
||||
),
|
||||
(
|
||||
"the longest name is accepted",
|
||||
longest.as_str(),
|
||||
Ok(longest.as_str()),
|
||||
),
|
||||
(
|
||||
"one character more is refused",
|
||||
too_long.as_str(),
|
||||
Err(NameError::TooLong(NAME_MAX + 1)),
|
||||
),
|
||||
("an empty name is refused", "", Err(NameError::Empty)),
|
||||
("whitespace alone is refused", " ", Err(NameError::Empty)),
|
||||
(
|
||||
"a bell is refused",
|
||||
"bad\u{7}name",
|
||||
Err(NameError::ControlCharacter),
|
||||
),
|
||||
(
|
||||
"a newline is refused",
|
||||
"bad\nname",
|
||||
Err(NameError::ControlCharacter),
|
||||
),
|
||||
];
|
||||
for (case, raw, want) in cases {
|
||||
assert_eq!(
|
||||
EndpointName::new(raw).as_ref().map(EndpointName::as_str),
|
||||
want.as_ref().map(|text| *text),
|
||||
"{case}: a name must be accepted exactly within its limits"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
245
crates/core/src/events.rs
Normal file
245
crates/core/src/events.rs
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
//! The bounded history of what happened to each connection.
|
||||
|
||||
use crate::ids::{EndpointId, EventId, RouteId};
|
||||
use jiff::Timestamp;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// How many events the daemon keeps before discarding the oldest.
|
||||
pub const DEFAULT_CAPACITY: usize = 10_000;
|
||||
|
||||
/// How much attention an event deserves.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Severity {
|
||||
/// Normal lifecycle.
|
||||
Info,
|
||||
/// Something degraded but recovered, or will.
|
||||
Warning,
|
||||
/// Something failed and needs attention.
|
||||
Error,
|
||||
}
|
||||
|
||||
/// What kind of thing happened.
|
||||
///
|
||||
/// Stable machine-readable names, so clients and log processors can match on them without parsing
|
||||
/// prose.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EventKind {
|
||||
/// An endpoint moved between lifecycle phases.
|
||||
EndpointStateChanged,
|
||||
/// An endpoint appeared, through creation or discovery.
|
||||
EndpointAdded,
|
||||
/// An endpoint went away.
|
||||
EndpointRemoved,
|
||||
/// A route became valid, broken, or part of a loop.
|
||||
RouteValidityChanged,
|
||||
/// Notes were silenced on an endpoint.
|
||||
NotesSilenced,
|
||||
/// Controller and program state were resent after a recovery.
|
||||
StateRestored,
|
||||
/// A peer invited this machine to a session.
|
||||
InvitationReceived,
|
||||
/// The set of available capabilities changed.
|
||||
CapabilitiesChanged,
|
||||
/// The machine suspended and came back, or its network changed underneath us.
|
||||
SystemResumed,
|
||||
/// Configuration was loaded, reloaded or repaired.
|
||||
ConfigurationChanged,
|
||||
/// The daemon started.
|
||||
DaemonStarted,
|
||||
/// The daemon is shutting down.
|
||||
DaemonStopping,
|
||||
/// The daemon replaced one that lost the platform's MIDI service, which other applications
|
||||
/// may have lost with it.
|
||||
MidiServerReplaced,
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
/// Returns the stable wire name for this kind.
|
||||
///
|
||||
/// Written out rather than derived from the Rust identifier, because the name travels in the
|
||||
/// IPC contract: deriving it would let a variant rename change what clients match on without
|
||||
/// anything in the contract appearing to move.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
EventKind::EndpointStateChanged => "endpoint_state_changed",
|
||||
EventKind::EndpointAdded => "endpoint_added",
|
||||
EventKind::EndpointRemoved => "endpoint_removed",
|
||||
EventKind::RouteValidityChanged => "route_validity_changed",
|
||||
EventKind::NotesSilenced => "notes_silenced",
|
||||
EventKind::StateRestored => "state_restored",
|
||||
EventKind::InvitationReceived => "invitation_received",
|
||||
EventKind::CapabilitiesChanged => "capabilities_changed",
|
||||
EventKind::SystemResumed => "system_resumed",
|
||||
EventKind::ConfigurationChanged => "configuration_changed",
|
||||
EventKind::DaemonStarted => "daemon_started",
|
||||
EventKind::DaemonStopping => "daemon_stopping",
|
||||
EventKind::MidiServerReplaced => "midi_server_replaced",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One timestamped record of something that happened.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Event {
|
||||
/// Monotonic within a daemon run.
|
||||
pub id: EventId,
|
||||
/// When it happened.
|
||||
pub at: Timestamp,
|
||||
/// Which endpoint it concerns, when it concerns one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint: Option<EndpointId>,
|
||||
/// Which route it concerns, when it concerns one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub route: Option<RouteId>,
|
||||
/// How much attention it deserves.
|
||||
pub severity: Severity,
|
||||
/// What kind of thing happened.
|
||||
pub kind: EventKind,
|
||||
/// A human-readable description.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// A fixed-size history of recent events.
|
||||
///
|
||||
/// Lives in the daemon rather than in a client, which is what lets a user see a failure that
|
||||
/// happened and recovered while no window was open. Bounded so an endpoint flapping for a week
|
||||
/// cannot exhaust memory.
|
||||
#[derive(Debug)]
|
||||
pub struct EventLog {
|
||||
entries: VecDeque<Event>,
|
||||
capacity: usize,
|
||||
next_id: EventId,
|
||||
}
|
||||
|
||||
impl EventLog {
|
||||
/// Creates a log holding at most `capacity` events. A zero capacity is raised to one, because
|
||||
/// a log that silently keeps nothing would be worse than a small one.
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
let capacity = capacity.max(1);
|
||||
Self {
|
||||
entries: VecDeque::with_capacity(capacity),
|
||||
capacity,
|
||||
next_id: EventId::from_raw(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Records an event and returns the identifier it was given.
|
||||
pub fn record(&mut self, mut event: Event) -> EventId {
|
||||
let id = self.next_id;
|
||||
event.id = id;
|
||||
self.next_id = self.next_id.next();
|
||||
|
||||
if self.entries.len() >= self.capacity {
|
||||
let _ = self.entries.pop_front();
|
||||
}
|
||||
self.entries.push_back(event);
|
||||
id
|
||||
}
|
||||
|
||||
/// Returns events newer than `after`, oldest first, capped at `limit`.
|
||||
pub fn since(&self, after: Option<EventId>, limit: usize) -> Vec<&Event> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|event| after.is_none_or(|cutoff| event.id > cutoff))
|
||||
.take(limit)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventLog {
|
||||
fn default() -> Self {
|
||||
Self::new(DEFAULT_CAPACITY)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an event, leaving the identifier for the log to assign.
|
||||
pub fn event(
|
||||
kind: EventKind,
|
||||
severity: Severity,
|
||||
at: Timestamp,
|
||||
detail: impl Into<String>,
|
||||
) -> Event {
|
||||
Event {
|
||||
id: EventId::from_raw(0),
|
||||
at,
|
||||
endpoint: None,
|
||||
route: None,
|
||||
severity,
|
||||
kind,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The log holds at most its capacity, discarding the oldest, and a zero capacity is raised
|
||||
/// to one.
|
||||
///
|
||||
/// Bounded so an endpoint flapping for a week cannot exhaust the daemon's memory. Ten events
|
||||
/// into a log of three keep identifiers 8, 9 and 10; into a log asked for zero, which keeps
|
||||
/// one rather than silently nothing, they keep 10 alone.
|
||||
#[test]
|
||||
fn the_log_keeps_only_the_newest_events_up_to_its_capacity() {
|
||||
let cases = [
|
||||
("a log of three keeps the last three", 3, vec![8, 9, 10]),
|
||||
("a log asked for zero keeps the last one", 0, vec![10]),
|
||||
];
|
||||
for (case, capacity, want) in cases {
|
||||
let mut log = EventLog::new(capacity);
|
||||
for _ in 0..10 {
|
||||
let _ = log.record(event(
|
||||
EventKind::EndpointStateChanged,
|
||||
Severity::Info,
|
||||
Timestamp::UNIX_EPOCH,
|
||||
"connected",
|
||||
));
|
||||
}
|
||||
let kept: Vec<u64> = log.since(None, 100).iter().map(|e| e.id.get()).collect();
|
||||
assert_eq!(
|
||||
kept, want,
|
||||
"{case}: the oldest events must go first and the log must never grow past its bound"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every event kind's wire name is the snake_case name serde writes for it.
|
||||
///
|
||||
/// The name travels in the gRPC contract, written out by hand so a variant rename cannot move
|
||||
/// it; the serialised form travels in JSON. Two spellings of one event would let a client
|
||||
/// match one and miss the other.
|
||||
#[test]
|
||||
fn every_kind_has_one_snake_case_wire_name() {
|
||||
for kind in [
|
||||
EventKind::EndpointStateChanged,
|
||||
EventKind::EndpointAdded,
|
||||
EventKind::EndpointRemoved,
|
||||
EventKind::RouteValidityChanged,
|
||||
EventKind::NotesSilenced,
|
||||
EventKind::StateRestored,
|
||||
EventKind::InvitationReceived,
|
||||
EventKind::CapabilitiesChanged,
|
||||
EventKind::SystemResumed,
|
||||
EventKind::ConfigurationChanged,
|
||||
EventKind::DaemonStarted,
|
||||
EventKind::DaemonStopping,
|
||||
EventKind::MidiServerReplaced,
|
||||
] {
|
||||
let name = kind.as_str();
|
||||
assert!(
|
||||
!name.is_empty() && name.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
|
||||
"{kind:?}: the wire name {name:?} must be snake_case, not a Rust identifier's shape"
|
||||
);
|
||||
let serialised = serde_json::to_string(&kind).expect("an event kind serialises");
|
||||
assert_eq!(
|
||||
serialised.trim_matches('"'),
|
||||
name,
|
||||
"{kind:?}: the wire name must match the serialised name"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
241
crates/core/src/failure.rs
Normal file
241
crates/core/src/failure.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
//! The closed set of reasons a connection can fail.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
/// Why a connection is not currently usable.
|
||||
///
|
||||
/// Deliberately a closed enum rather than a string: the interface renders specific guidance from
|
||||
/// it, the IPC layer maps it onto stable error codes, and the command line derives exit codes from
|
||||
/// it. Adding a variant is a contract change, not an implementation detail.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FailureReason {
|
||||
/// No route to the peer, or no network at all.
|
||||
NetworkUnreachable,
|
||||
/// The peer stopped answering within the liveness window.
|
||||
PeerTimeout,
|
||||
/// The peer explicitly refused the session.
|
||||
PeerRejected,
|
||||
/// The hardware backing this endpoint was removed.
|
||||
DeviceRemoved,
|
||||
/// Another application holds the device exclusively.
|
||||
DeviceClaimed {
|
||||
/// Names the holder when the platform reports it.
|
||||
by: Option<String>,
|
||||
},
|
||||
/// The operating system has not granted the permission this endpoint needs.
|
||||
PermissionDenied {
|
||||
/// Names the permission, so the interface can tell the user what to grant.
|
||||
what: String,
|
||||
},
|
||||
/// Bluetooth cannot be used on this machine right now.
|
||||
///
|
||||
/// Said without advice, by the owner's decision (R-077): the cause may be a missing adapter,
|
||||
/// one switched off, or a system service that is not running, and the capability query is
|
||||
/// where each is named.
|
||||
AdapterUnavailable,
|
||||
/// Another endpoint already uses this name.
|
||||
NameConflict {
|
||||
/// The name that collided.
|
||||
name: String,
|
||||
},
|
||||
/// The operating system refused to create more endpoints.
|
||||
ResourceLimit,
|
||||
/// The peer sent something that violates the protocol.
|
||||
ProtocolError {
|
||||
/// Describes what was malformed.
|
||||
detail: String,
|
||||
},
|
||||
/// The stored configuration for this endpoint cannot be applied.
|
||||
ConfigInvalid {
|
||||
/// Describes what is wrong with it.
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl FailureReason {
|
||||
/// Returns one value of every variant, for checks that must cover them all.
|
||||
///
|
||||
/// Exit codes, IPC codes and guidance are each decided per variant, and a variant missing
|
||||
/// from one of those checks would quietly take whatever default applies. The match below
|
||||
/// stops compiling when a variant is added, which is what keeps this list complete.
|
||||
pub fn one_of_each() -> [FailureReason; 11] {
|
||||
let reasons = [
|
||||
Self::NetworkUnreachable,
|
||||
Self::PeerTimeout,
|
||||
Self::PeerRejected,
|
||||
Self::DeviceRemoved,
|
||||
Self::DeviceClaimed { by: None },
|
||||
Self::PermissionDenied {
|
||||
what: "bluetooth".to_owned(),
|
||||
},
|
||||
Self::AdapterUnavailable,
|
||||
Self::NameConflict {
|
||||
name: "Bus".to_owned(),
|
||||
},
|
||||
Self::ResourceLimit,
|
||||
Self::ProtocolError {
|
||||
detail: "bad header".to_owned(),
|
||||
},
|
||||
Self::ConfigInvalid {
|
||||
detail: "port 0".to_owned(),
|
||||
},
|
||||
];
|
||||
for reason in &reasons {
|
||||
// A new variant belongs in the list above as well as here.
|
||||
match reason {
|
||||
Self::NetworkUnreachable
|
||||
| Self::PeerTimeout
|
||||
| Self::PeerRejected
|
||||
| Self::DeviceRemoved
|
||||
| Self::DeviceClaimed { .. }
|
||||
| Self::PermissionDenied { .. }
|
||||
| Self::AdapterUnavailable
|
||||
| Self::NameConflict { .. }
|
||||
| Self::ResourceLimit
|
||||
| Self::ProtocolError { .. }
|
||||
| Self::ConfigInvalid { .. } => {}
|
||||
}
|
||||
}
|
||||
reasons
|
||||
}
|
||||
|
||||
/// Reports whether clearing this failure requires the user to do something.
|
||||
///
|
||||
/// This is the distinction FR-028 draws, and it decides which phase the connection reports:
|
||||
/// a failure the user cannot act on retries silently in `Retrying`, while one they can act on
|
||||
/// is surfaced as `Unavailable`, with guidance wherever there is one fix to name.
|
||||
///
|
||||
/// Neither is terminal. Retrying continues in both cases, because the user may fix the
|
||||
/// condition at any moment — switching an adapter on or closing the application holding a
|
||||
/// device must reconnect without them touching Midi Harbor.
|
||||
pub fn needs_user_action(&self) -> bool {
|
||||
match self {
|
||||
Self::NetworkUnreachable
|
||||
| Self::PeerTimeout
|
||||
| Self::DeviceRemoved
|
||||
| Self::ProtocolError { .. } => false,
|
||||
Self::PeerRejected
|
||||
| Self::DeviceClaimed { .. }
|
||||
| Self::PermissionDenied { .. }
|
||||
| Self::AdapterUnavailable
|
||||
| Self::NameConflict { .. }
|
||||
| Self::ResourceLimit
|
||||
| Self::ConfigInvalid { .. } => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stable slug used as an IPC error code and to derive command-line exit codes.
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NetworkUnreachable => "network_unreachable",
|
||||
Self::PeerTimeout => "peer_timeout",
|
||||
Self::PeerRejected => "peer_rejected",
|
||||
Self::DeviceRemoved => "device_removed",
|
||||
Self::DeviceClaimed { .. } => "device_claimed",
|
||||
Self::PermissionDenied { .. } => "permission_denied",
|
||||
Self::AdapterUnavailable => "adapter_unavailable",
|
||||
Self::NameConflict { .. } => "name_conflict",
|
||||
Self::ResourceLimit => "resource_limit",
|
||||
Self::ProtocolError { .. } => "protocol_error",
|
||||
Self::ConfigInvalid { .. } => "config_invalid",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns what the user can do about this failure, or `None` when there is nothing to do but
|
||||
/// wait for the automatic retry.
|
||||
pub fn guidance(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::NetworkUnreachable | Self::PeerTimeout | Self::DeviceRemoved => None,
|
||||
Self::PeerRejected => Some(
|
||||
"the peer refused the session; accept it there, or check its invitation policy"
|
||||
.to_owned(),
|
||||
),
|
||||
Self::DeviceClaimed { by } => Some(match by {
|
||||
Some(app) => format!(
|
||||
"close {app} or release the device in it, then it reconnects automatically"
|
||||
),
|
||||
None => "another application holds this device exclusively".to_owned(),
|
||||
}),
|
||||
Self::PermissionDenied { what } => Some(format!(
|
||||
"grant {what} permission in system settings, then retry"
|
||||
)),
|
||||
Self::AdapterUnavailable => None,
|
||||
Self::NameConflict { name } => Some(format!("choose a name other than '{name}'")),
|
||||
Self::ResourceLimit => Some(
|
||||
"the operating system will not create more endpoints; remove one first".to_owned(),
|
||||
),
|
||||
Self::ProtocolError { .. } => None,
|
||||
Self::ConfigInvalid { detail } => Some(format!("correct the configuration: {detail}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for FailureReason {}
|
||||
|
||||
impl fmt::Display for FailureReason {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::NetworkUnreachable => write!(f, "network unreachable"),
|
||||
Self::PeerTimeout => write!(f, "peer not responding"),
|
||||
Self::PeerRejected => write!(f, "peer refused the session"),
|
||||
Self::DeviceRemoved => write!(f, "device was removed"),
|
||||
Self::DeviceClaimed { by: Some(app) } => write!(f, "device is in use by {app}"),
|
||||
Self::DeviceClaimed { by: None } => {
|
||||
write!(f, "device is in use by another application")
|
||||
}
|
||||
Self::PermissionDenied { what } => write!(f, "{what} permission was not granted"),
|
||||
Self::AdapterUnavailable => write!(f, "bluetooth is not available"),
|
||||
Self::NameConflict { name } => write!(f, "the name '{name}' is already in use"),
|
||||
Self::ResourceLimit => write!(f, "the system endpoint limit was reached"),
|
||||
Self::ProtocolError { detail } => write!(f, "protocol error: {detail}"),
|
||||
Self::ConfigInvalid { detail } => write!(f, "configuration is invalid: {detail}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A failure the user must act on comes with guidance, and one they cannot act on asks
|
||||
/// nothing of them (FR-028).
|
||||
///
|
||||
/// Bluetooth being unavailable is the one exception, by the owner's decision (R-077): it is
|
||||
/// surfaced as needing the user, but "switch the adapter on" was wrong for a machine with no
|
||||
/// adapter and for one where BlueZ was not running, and the refusal cannot tell which.
|
||||
#[test]
|
||||
fn guidance_is_offered_exactly_when_the_user_must_act() {
|
||||
for reason in FailureReason::one_of_each() {
|
||||
let want_guidance =
|
||||
reason.needs_user_action() && reason != FailureReason::AdapterUnavailable;
|
||||
assert_eq!(
|
||||
reason.guidance().is_some(),
|
||||
want_guidance,
|
||||
"{reason}: guidance must be offered exactly when there is one fix to name"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
FailureReason::AdapterUnavailable.needs_user_action(),
|
||||
"unavailable Bluetooth must still be surfaced rather than retried quietly"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every reason has its own code.
|
||||
///
|
||||
/// The code is the IPC error code clients switch on and the key the command line derives its
|
||||
/// exit code from, so two reasons sharing one would make them indistinguishable to both.
|
||||
#[test]
|
||||
fn every_reason_has_its_own_code() {
|
||||
let codes = FailureReason::one_of_each().map(|reason| reason.code());
|
||||
let mut distinct = codes.to_vec();
|
||||
distinct.sort_unstable();
|
||||
distinct.dedup();
|
||||
assert_eq!(
|
||||
distinct.len(),
|
||||
codes.len(),
|
||||
"no two reasons may share a code: {codes:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
272
crates/core/src/fingerprint.rs
Normal file
272
crates/core/src/fingerprint.rs
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
//! Identifying physical MIDI hardware across unplugging and replugging.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
/// How confidently a stored fingerprint matches hardware currently attached.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MatchConfidence {
|
||||
/// Nothing matched.
|
||||
None,
|
||||
/// Only the reported name matched, or several devices matched equally well.
|
||||
///
|
||||
/// Never rebinds a route on its own: binding the wrong hardware is worse than asking.
|
||||
Ambiguous,
|
||||
/// Manufacturer, model and name all matched, with no competing candidate.
|
||||
Probable,
|
||||
/// A hardware-unique value matched — a CoreMIDI unique id or a USB serial number.
|
||||
Exact,
|
||||
}
|
||||
|
||||
impl MatchConfidence {
|
||||
/// Reports whether this match is strong enough to rebind a route without asking the user.
|
||||
pub fn is_automatic(&self) -> bool {
|
||||
matches!(self, Self::Exact | Self::Probable)
|
||||
}
|
||||
}
|
||||
|
||||
/// The set of identifying values a platform reports for one piece of MIDI hardware.
|
||||
///
|
||||
/// No single field is reliable on every platform, so identity is a composite. `name` is the only
|
||||
/// field always present and is never sufficient on its own, because two identical devices report
|
||||
/// the same one.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DeviceFingerprint {
|
||||
/// CoreMIDI's `kMIDIPropertyUniqueID`, stable across replug on macOS.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub unique_id: Option<u32>,
|
||||
/// The USB serial number, when the device reports one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub usb_serial: Option<String>,
|
||||
/// The manufacturer as reported by the device.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manufacturer: Option<String>,
|
||||
/// The model as reported by the device.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// The display name. Always present, never sufficient alone.
|
||||
pub name: String,
|
||||
/// The physical connection path, stable per socket but not across sockets.
|
||||
///
|
||||
/// The fallback for Linux hardware that reports no serial number, and the reason such devices
|
||||
/// can only ever match `Ambiguous` when moved to a different port.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub topology_path: Option<String>,
|
||||
}
|
||||
|
||||
impl DeviceFingerprint {
|
||||
/// Creates a fingerprint carrying only a name, which is all some platforms report.
|
||||
pub fn from_name(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether this fingerprint carries a hardware-unique value.
|
||||
pub fn has_unique_identity(&self) -> bool {
|
||||
self.unique_id.is_some() || self.usb_serial.is_some()
|
||||
}
|
||||
|
||||
/// Scores how well `candidate` matches this stored fingerprint.
|
||||
///
|
||||
/// Unique values are checked first and are decisive in both directions: a stored unique value
|
||||
/// that disagrees with the candidate's means this is different hardware, whatever else lines
|
||||
/// up.
|
||||
pub fn compare(&self, candidate: &Self) -> MatchConfidence {
|
||||
// A hardware-unique value settles the question outright.
|
||||
if let (Some(mine), Some(theirs)) = (self.unique_id, candidate.unique_id) {
|
||||
return if mine == theirs {
|
||||
MatchConfidence::Exact
|
||||
} else {
|
||||
MatchConfidence::None
|
||||
};
|
||||
}
|
||||
if let (Some(mine), Some(theirs)) = (&self.usb_serial, &candidate.usb_serial) {
|
||||
return if mine == theirs {
|
||||
MatchConfidence::Exact
|
||||
} else {
|
||||
MatchConfidence::None
|
||||
};
|
||||
}
|
||||
|
||||
// Without one, a full descriptive match is good enough to rebind automatically.
|
||||
if self.name != candidate.name {
|
||||
return MatchConfidence::None;
|
||||
}
|
||||
let descriptive = self.manufacturer.is_some()
|
||||
&& self.model.is_some()
|
||||
&& self.manufacturer == candidate.manufacturer
|
||||
&& self.model == candidate.model;
|
||||
if descriptive {
|
||||
return MatchConfidence::Probable;
|
||||
}
|
||||
|
||||
// The same socket is decent evidence, but only for hardware that has not been moved.
|
||||
if self.topology_path.is_some() && self.topology_path == candidate.topology_path {
|
||||
return MatchConfidence::Probable;
|
||||
}
|
||||
|
||||
// Identical in everything known. There is nothing more to learn about this device, and
|
||||
// nothing says it is a different one. Refusing it would add the device again on every
|
||||
// enumeration, since the entry made for it would not match it either. Two such devices
|
||||
// still tie, and a tie is never bound.
|
||||
if self == candidate {
|
||||
return MatchConfidence::Probable;
|
||||
}
|
||||
|
||||
// Name alone. Correct often enough to offer, never enough to assume.
|
||||
MatchConfidence::Ambiguous
|
||||
}
|
||||
|
||||
/// Reports whether both were found in the same place.
|
||||
///
|
||||
/// Only true when both know where they are: two devices that report no position are not in
|
||||
/// the same one, they are simply unplaced.
|
||||
fn same_position(&self, candidate: &Self) -> bool {
|
||||
self.topology_path.is_some() && self.topology_path == candidate.topology_path
|
||||
}
|
||||
|
||||
/// Picks the best match for this fingerprint among `candidates`.
|
||||
///
|
||||
/// Returns `Ambiguous` when several candidates tie at the best score, because choosing between
|
||||
/// two identical devices is the user's call.
|
||||
pub fn best_match<'a>(&self, candidates: &'a [Self]) -> (Option<&'a Self>, MatchConfidence) {
|
||||
let mut best: Option<&Self> = None;
|
||||
let mut best_score = MatchConfidence::None;
|
||||
let mut tied = false;
|
||||
|
||||
for candidate in candidates {
|
||||
let score = self.compare(candidate);
|
||||
if score == MatchConfidence::None {
|
||||
continue;
|
||||
}
|
||||
if score > best_score {
|
||||
best_score = score;
|
||||
best = Some(candidate);
|
||||
tied = false;
|
||||
} else if score == best_score {
|
||||
// Where it is plugged in breaks a tie that nothing else can. Two identical
|
||||
// devices score the same on every descriptive field, so without this they are
|
||||
// ambiguous even when one of them is in the very socket this entry remembers.
|
||||
match (
|
||||
self.same_position(candidate),
|
||||
best.is_some_and(|b| self.same_position(b)),
|
||||
) {
|
||||
(true, false) => {
|
||||
best = Some(candidate);
|
||||
tied = false;
|
||||
}
|
||||
(false, true) => {}
|
||||
_ => tied = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (best, tied) {
|
||||
(None, _) => (None, MatchConfidence::None),
|
||||
(Some(found), true) => (Some(found), MatchConfidence::Ambiguous),
|
||||
(Some(found), false) => (Some(found), best_score),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DeviceFingerprint {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.name)?;
|
||||
if let Some(serial) = &self.usb_serial {
|
||||
write!(f, " (serial {serial})")?;
|
||||
} else if let Some(id) = self.unique_id {
|
||||
write!(f, " (id {id})")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn keyboard() -> DeviceFingerprint {
|
||||
DeviceFingerprint {
|
||||
unique_id: None,
|
||||
usb_serial: Some("SN-001".to_owned()),
|
||||
manufacturer: Some("Acme".to_owned()),
|
||||
model: Some("K61".to_owned()),
|
||||
name: "Acme K61".to_owned(),
|
||||
topology_path: Some("usb-0:1.2".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attached hardware is matched to a stored entry by the strongest evidence available, and
|
||||
/// two identical devices tie rather than one being picked.
|
||||
///
|
||||
/// A serial number is decisive both ways: it follows the device to another socket (FR-015e)
|
||||
/// and a different one is different hardware whatever else agrees. Manufacturer, model and
|
||||
/// name without a serial are enough to rebind. A device reporting only a name matches itself,
|
||||
/// or the entry made for it would never match and it would be added again on every
|
||||
/// enumeration, but two such devices attached at once are ambiguous (FR-015g), because
|
||||
/// binding the wrong one is worse than asking.
|
||||
#[test]
|
||||
fn hardware_is_matched_by_its_strongest_evidence() {
|
||||
let moved = DeviceFingerprint {
|
||||
topology_path: Some("usb-0:4.1".to_owned()),
|
||||
..keyboard()
|
||||
};
|
||||
let other_serial = DeviceFingerprint {
|
||||
usb_serial: Some("SN-002".to_owned()),
|
||||
..keyboard()
|
||||
};
|
||||
let no_serial = DeviceFingerprint {
|
||||
usb_serial: None,
|
||||
..keyboard()
|
||||
};
|
||||
let named = DeviceFingerprint::from_name("Keystation");
|
||||
let cases = [
|
||||
(
|
||||
"a serial matches across a different socket",
|
||||
keyboard(),
|
||||
vec![moved],
|
||||
MatchConfidence::Exact,
|
||||
),
|
||||
(
|
||||
"a differing serial is different hardware",
|
||||
keyboard(),
|
||||
vec![other_serial],
|
||||
MatchConfidence::None,
|
||||
),
|
||||
(
|
||||
"manufacturer, model and name match without a serial",
|
||||
no_serial.clone(),
|
||||
vec![no_serial],
|
||||
MatchConfidence::Probable,
|
||||
),
|
||||
(
|
||||
"a different name never matches",
|
||||
named.clone(),
|
||||
vec![DeviceFingerprint::from_name("Other Thing")],
|
||||
MatchConfidence::None,
|
||||
),
|
||||
(
|
||||
"a device known only by name matches itself",
|
||||
named.clone(),
|
||||
vec![named.clone()],
|
||||
MatchConfidence::Probable,
|
||||
),
|
||||
(
|
||||
"two identical devices known only by name tie",
|
||||
named.clone(),
|
||||
vec![named.clone(), named],
|
||||
MatchConfidence::Ambiguous,
|
||||
),
|
||||
];
|
||||
for (case, stored, attached, want) in cases {
|
||||
assert_eq!(
|
||||
stored.best_match(&attached).1,
|
||||
want,
|
||||
"{case}: the match must be as strong as the evidence and no stronger"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
114
crates/core/src/ids.rs
Normal file
114
crates/core/src/ids.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
//! Stable identifiers for the entities routes and configuration refer to.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Declares a newtype over `Uuid` with the shared constructor, display and serde behaviour.
|
||||
macro_rules! uuid_id {
|
||||
($(#[$meta:meta])* $name:ident, $prefix:literal) => {
|
||||
$(#[$meta])*
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct $name(Uuid);
|
||||
|
||||
impl $name {
|
||||
/// Generates a new identifier that has never been used before.
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Returns the underlying UUID, for callers that need the raw value.
|
||||
pub fn as_uuid(&self) -> &Uuid {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Parses an identifier from its canonical hyphenated text form.
|
||||
pub fn parse(text: &str) -> Result<Self, uuid::Error> {
|
||||
Uuid::parse_str(text).map(Self)
|
||||
}
|
||||
|
||||
/// Wraps an existing UUID, for identifiers derived rather than generated.
|
||||
pub fn from_uuid(value: Uuid) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for $name {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for $name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}({})", $prefix, self.0)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
uuid_id!(
|
||||
/// Identifies an endpoint for the lifetime of the configuration that contains it.
|
||||
///
|
||||
/// Assigned once at creation and never reused. Renaming an endpoint does not change it, which
|
||||
/// is what allows routes to survive renames, reboots and hardware being replugged.
|
||||
EndpointId,
|
||||
"EndpointId"
|
||||
);
|
||||
|
||||
impl EndpointId {
|
||||
/// Derives the identifier of something belonging to this endpoint, the same on every run.
|
||||
pub fn derived(&self, label: &str) -> Self {
|
||||
Self(Uuid::new_v5(&self.0, label.as_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
uuid_id!(
|
||||
/// Identifies a route between two endpoints.
|
||||
RouteId,
|
||||
"RouteId"
|
||||
);
|
||||
|
||||
uuid_id!(
|
||||
/// Identifies a discovered or manually added network peer.
|
||||
PeerId,
|
||||
"PeerId"
|
||||
);
|
||||
|
||||
/// Identifies an entry in the daemon's bounded event history.
|
||||
///
|
||||
/// Monotonic within a single daemon run. The history does not survive a restart, so these are not
|
||||
/// persisted and carry no meaning across runs.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct EventId(u64);
|
||||
|
||||
impl EventId {
|
||||
/// Creates an identifier from a raw sequence number.
|
||||
pub fn from_raw(value: u64) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
/// Returns the raw sequence number.
|
||||
pub fn get(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Returns the next identifier in sequence, saturating rather than wrapping.
|
||||
pub fn next(&self) -> Self {
|
||||
Self(self.0.saturating_add(1))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EventId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
49
crates/core/src/lib.rs
Normal file
49
crates/core/src/lib.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! Domain types, connection state machine, routing and configuration.
|
||||
//!
|
||||
//! This crate carries no I/O and no platform code, so every rule it encodes can be tested on a
|
||||
//! machine with no MIDI hardware, no network peer and no Bluetooth radio.
|
||||
|
||||
pub mod backoff;
|
||||
pub mod capability;
|
||||
pub mod config;
|
||||
pub mod controls;
|
||||
pub mod counters;
|
||||
pub mod devicetime;
|
||||
pub mod endpoint;
|
||||
pub mod events;
|
||||
pub mod failure;
|
||||
pub mod fingerprint;
|
||||
pub mod ids;
|
||||
pub mod loops;
|
||||
pub mod midi;
|
||||
pub mod paths;
|
||||
pub mod router;
|
||||
pub mod rtchannel;
|
||||
pub mod rtevent;
|
||||
pub mod sounding;
|
||||
pub mod state;
|
||||
pub mod stream;
|
||||
pub mod time;
|
||||
|
||||
/// Midi Harbor's version, from the `VERSION` file at the root of the repository, which every
|
||||
/// build step reads. The crates' own versions stay 0.0.0, since they are never published.
|
||||
pub const VERSION: &str = include_str!("../../../VERSION").trim_ascii();
|
||||
|
||||
pub use backoff::{Backoff, BackoffPolicy};
|
||||
pub use capability::{Capability, CapabilityName, CapabilitySet, UnavailableReason};
|
||||
pub use config::Configuration;
|
||||
pub use counters::{CounterSnapshot, TrafficCounters};
|
||||
pub use endpoint::{Direction, Endpoint, EndpointKind, EndpointName};
|
||||
pub use events::{EventKind, EventLog, Severity};
|
||||
pub use failure::FailureReason;
|
||||
pub use fingerprint::{DeviceFingerprint, MatchConfidence};
|
||||
pub use ids::{EndpointId, EventId, PeerId, RouteId};
|
||||
pub use midi::{Channel, MidiMessage};
|
||||
pub use paths::Paths;
|
||||
pub use router::{ResolvedRoute, RouteValidity, Router};
|
||||
pub use rtchannel::{Drained, RtConsumer, RtProducer};
|
||||
pub use rtevent::{RtEvent, RtPayload};
|
||||
pub use sounding::Sounding;
|
||||
pub use state::{ConnectionPhase, ConnectionState, Effect};
|
||||
pub use stream::{Chunk, Scanner, SysExEnd};
|
||||
pub use time::{Clock, SystemClock, TestClock};
|
||||
155
crates/core/src/loops.rs
Normal file
155
crates/core/src/loops.rs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
//! Spotting MIDI that has come back around a loop across machines.
|
||||
//!
|
||||
//! A loop within one machine is visible in its route graph. One across machines is not: machine
|
||||
//! A sends a keyboard to B over a session, B routes that session back to A over another, and A
|
||||
//! routes that one to the first. Each machine's routes look sensible, and a note goes round for
|
||||
//! as long as the machines run. RTP-MIDI carries plain MIDI, with nothing to say where a message
|
||||
//! began, so the loop has to be recognised from what it does.
|
||||
//!
|
||||
//! Routing is direct (R-019), so MIDI arriving on a session can leave on another session only
|
||||
//! through a route from one to the other, and every loop across machines passes through such a
|
||||
//! route on some machine. A message is an echo when it is about to leave through a session it
|
||||
//! left moments ago from a different source. A route that relays one peer to another sees its
|
||||
//! own earlier forwards come from the same source, so relaying repeated notes is not an echo.
|
||||
|
||||
use crate::ids::EndpointId;
|
||||
use crate::midi::MidiMessage;
|
||||
use crate::time;
|
||||
use jiff::Timestamp;
|
||||
use std::time::Duration;
|
||||
|
||||
/// How recently a message must have left for its return to count as an echo.
|
||||
///
|
||||
/// Well above a round trip across two machines on a local network, a few milliseconds, and well
|
||||
/// below the gap between two notes a person plays.
|
||||
pub const ECHO_WINDOW: Duration = Duration::from_millis(250);
|
||||
|
||||
/// How many echoes within `TRIP_WINDOW` mean a loop.
|
||||
///
|
||||
/// A loop echoes every message on every round, hundreds of times a second. Two players striking
|
||||
/// the same note on two machines at once cannot reach this many in a quarter of a second.
|
||||
pub const TRIP_ECHOES: u32 = 16;
|
||||
|
||||
/// The window `TRIP_ECHOES` must fall within.
|
||||
pub const TRIP_WINDOW: Duration = Duration::from_millis(250);
|
||||
|
||||
/// How many recent sends a session remembers.
|
||||
///
|
||||
/// Fixed, so recording never allocates. At 512 messages a second, 250 ms of sends fit.
|
||||
const CAPACITY: usize = 128;
|
||||
|
||||
/// One message sent out through a session, and where it came from.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Sent {
|
||||
message: MidiMessage,
|
||||
source: EndpointId,
|
||||
at: Timestamp,
|
||||
}
|
||||
|
||||
/// The messages a session sent out recently.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SentLog {
|
||||
entries: [Option<Sent>; CAPACITY],
|
||||
next: usize,
|
||||
}
|
||||
|
||||
impl Default for SentLog {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entries: [None; CAPACITY],
|
||||
next: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SentLog {
|
||||
/// Creates a log with nothing in it.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Records a message sent out through the session, overwriting the oldest when full.
|
||||
pub fn record(&mut self, message: MidiMessage, source: EndpointId, at: Timestamp) {
|
||||
if let Some(slot) = self.entries.get_mut(self.next) {
|
||||
*slot = Some(Sent {
|
||||
message,
|
||||
source,
|
||||
at,
|
||||
});
|
||||
}
|
||||
self.next = (self.next + 1) % CAPACITY;
|
||||
}
|
||||
|
||||
/// Reports whether a message arriving from `via` is one this session sent moments ago from
|
||||
/// somewhere else.
|
||||
pub fn came_back(&self, message: &MidiMessage, via: EndpointId, now: Timestamp) -> bool {
|
||||
self.entries.iter().flatten().any(|sent| {
|
||||
sent.message == *message
|
||||
&& sent.source != via
|
||||
&& time::elapsed(sent.at, now) <= ECHO_WINDOW
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts the echoes on one route, and says when they amount to a loop.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct LoopWatch {
|
||||
first: Option<Timestamp>,
|
||||
echoes: u32,
|
||||
}
|
||||
|
||||
impl LoopWatch {
|
||||
/// Creates a watch that has seen no echoes.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Records one echo, reporting true exactly once when the echoes within `TRIP_WINDOW` reach
|
||||
/// `TRIP_ECHOES`, so a loop is acted on once rather than for every message it carries.
|
||||
pub fn echo(&mut self, now: Timestamp) -> bool {
|
||||
match self.first {
|
||||
Some(first) if time::elapsed(first, now) <= TRIP_WINDOW => {
|
||||
self.echoes = self.echoes.saturating_add(1);
|
||||
}
|
||||
_ => {
|
||||
self.first = Some(now);
|
||||
self.echoes = 1;
|
||||
}
|
||||
}
|
||||
self.echoes == TRIP_ECHOES
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(ms: i64) -> Timestamp {
|
||||
Timestamp::from_millisecond(1_790_000_000_000 + ms).expect("a timestamp")
|
||||
}
|
||||
|
||||
/// A loop is reported once, and players who happen to repeat each other never trip it.
|
||||
///
|
||||
/// A loop echoes every message on every round: one echo a millisecond for 100 ms reaches the
|
||||
/// 16 echoes within 250 ms at the sixteenth and is reported that once, so the route is
|
||||
/// switched off once rather than for every message the loop carries. Two players striking
|
||||
/// the same note eight times a second echo every 125 ms, so any 250 ms window holds at most
|
||||
/// three echoes (0, 125 and 250 ms) and never reaches 16.
|
||||
#[test]
|
||||
fn a_loop_trips_once_and_coincidences_never_do() {
|
||||
let cases = [
|
||||
("a loop echoing every millisecond", 100, 1, 1),
|
||||
("two players eight beats a second", 40, 125, 0),
|
||||
];
|
||||
for (case, echoes, every_ms, want) in cases {
|
||||
let mut watch = LoopWatch::new();
|
||||
let trips = (0..echoes)
|
||||
.filter(|index: &i64| watch.echo(at(index * every_ms)))
|
||||
.count();
|
||||
assert_eq!(
|
||||
trips, want,
|
||||
"{case}: a loop must be acted on once and coincidence never"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
651
crates/core/src/midi.rs
Normal file
651
crates/core/src/midi.rs
Normal file
|
|
@ -0,0 +1,651 @@
|
|||
//! MIDI 1.0 message semantics.
|
||||
//!
|
||||
//! Shared by routing, note silencing, the recovery journal and the message monitor, because all
|
||||
//! four need to know what a run of bytes means rather than just how long it is.
|
||||
|
||||
/// Highest value a data byte may carry. Anything above has the status bit set.
|
||||
pub const MAX_DATA: u8 = 0x7F;
|
||||
|
||||
/// Channels in MIDI 1.0.
|
||||
pub const CHANNELS: u8 = 16;
|
||||
|
||||
/// Controller number for all-sound-off.
|
||||
pub const CC_ALL_SOUND_OFF: u8 = 120;
|
||||
/// Controller number for reset-all-controllers.
|
||||
pub const CC_RESET_ALL_CONTROLLERS: u8 = 121;
|
||||
/// Controller number for all-notes-off.
|
||||
pub const CC_ALL_NOTES_OFF: u8 = 123;
|
||||
/// Controller number for the sustain pedal.
|
||||
pub const CC_SUSTAIN: u8 = 64;
|
||||
|
||||
/// A MIDI channel, zero-based internally and one-based when shown to people.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct Channel(u8);
|
||||
|
||||
impl Channel {
|
||||
/// Creates a channel from a zero-based number, returning `None` when out of range.
|
||||
pub fn new(zero_based: u8) -> Option<Self> {
|
||||
(zero_based < CHANNELS).then_some(Self(zero_based))
|
||||
}
|
||||
|
||||
/// Creates a channel from the low nibble of a status byte, which is always in range.
|
||||
pub fn from_status(status: u8) -> Self {
|
||||
Self(status & 0x0F)
|
||||
}
|
||||
|
||||
/// Returns the zero-based number, as it appears on the wire.
|
||||
pub fn index(&self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Returns the one-based number, as people refer to it.
|
||||
pub fn number(&self) -> u8 {
|
||||
self.0.saturating_add(1)
|
||||
}
|
||||
|
||||
/// Returns every channel, for operations that sweep all of them.
|
||||
pub fn all() -> impl Iterator<Item = Self> {
|
||||
(0..CHANNELS).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed MIDI 1.0 message.
|
||||
///
|
||||
/// System-exclusive payloads are deliberately absent: they are unbounded and must not be copied
|
||||
/// around on the data path, so they travel as handles into a pre-allocated pool instead.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MidiMessage {
|
||||
/// A key was released.
|
||||
NoteOff {
|
||||
/// Which channel.
|
||||
channel: Channel,
|
||||
/// Which key.
|
||||
note: u8,
|
||||
/// How fast it was released.
|
||||
velocity: u8,
|
||||
},
|
||||
/// A key was pressed. A velocity of zero means the same as note off.
|
||||
NoteOn {
|
||||
/// Which channel.
|
||||
channel: Channel,
|
||||
/// Which key.
|
||||
note: u8,
|
||||
/// How hard it was pressed.
|
||||
velocity: u8,
|
||||
},
|
||||
/// Pressure applied to one held key.
|
||||
PolyAftertouch {
|
||||
/// Which channel.
|
||||
channel: Channel,
|
||||
/// Which key.
|
||||
note: u8,
|
||||
/// How much pressure.
|
||||
pressure: u8,
|
||||
},
|
||||
/// A controller moved.
|
||||
ControlChange {
|
||||
/// Which channel.
|
||||
channel: Channel,
|
||||
/// Which controller.
|
||||
controller: u8,
|
||||
/// Its new value.
|
||||
value: u8,
|
||||
},
|
||||
/// The sound selection changed.
|
||||
ProgramChange {
|
||||
/// Which channel.
|
||||
channel: Channel,
|
||||
/// Which program.
|
||||
program: u8,
|
||||
},
|
||||
/// Pressure applied to the whole channel.
|
||||
ChannelAftertouch {
|
||||
/// Which channel.
|
||||
channel: Channel,
|
||||
/// How much pressure.
|
||||
pressure: u8,
|
||||
},
|
||||
/// The pitch wheel moved, centred at 8192.
|
||||
PitchBend {
|
||||
/// Which channel.
|
||||
channel: Channel,
|
||||
/// The fourteen-bit position.
|
||||
value: u16,
|
||||
},
|
||||
/// A system common message that carries data: a time code quarter frame, a song position
|
||||
/// or a song select.
|
||||
SystemCommon {
|
||||
/// The status byte, `0xF1` to `0xF3`.
|
||||
status: u8,
|
||||
/// The data bytes, of which a quarter frame and a song select use only the first.
|
||||
data: [u8; 2],
|
||||
},
|
||||
/// A system message of one byte, which is every real-time message and a tune request.
|
||||
System {
|
||||
/// The status byte.
|
||||
status: u8,
|
||||
},
|
||||
}
|
||||
|
||||
impl MidiMessage {
|
||||
/// Returns the channel this message applies to, or `None` for system messages.
|
||||
pub fn channel(&self) -> Option<Channel> {
|
||||
match self {
|
||||
Self::NoteOff { channel, .. }
|
||||
| Self::NoteOn { channel, .. }
|
||||
| Self::PolyAftertouch { channel, .. }
|
||||
| Self::ControlChange { channel, .. }
|
||||
| Self::ProgramChange { channel, .. }
|
||||
| Self::ChannelAftertouch { channel, .. }
|
||||
| Self::PitchBend { channel, .. } => Some(*channel),
|
||||
Self::SystemCommon { .. } | Self::System { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether this message starts a note sounding.
|
||||
///
|
||||
/// A note on with zero velocity is a note off by convention, and treating it as a start is
|
||||
/// the classic way to leave a note hanging forever.
|
||||
pub fn starts_note(&self) -> bool {
|
||||
matches!(self, Self::NoteOn { velocity, .. } if *velocity > 0)
|
||||
}
|
||||
|
||||
/// Reports whether this message stops a note sounding.
|
||||
pub fn stops_note(&self) -> Option<(Channel, u8)> {
|
||||
match self {
|
||||
Self::NoteOff { channel, note, .. } => Some((*channel, *note)),
|
||||
Self::NoteOn {
|
||||
channel,
|
||||
note,
|
||||
velocity,
|
||||
} if *velocity == 0 => Some((*channel, *note)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the status byte for this message.
|
||||
pub fn status(&self) -> u8 {
|
||||
match self {
|
||||
Self::NoteOff { channel, .. } => 0x80 | channel.index(),
|
||||
Self::NoteOn { channel, .. } => 0x90 | channel.index(),
|
||||
Self::PolyAftertouch { channel, .. } => 0xA0 | channel.index(),
|
||||
Self::ControlChange { channel, .. } => 0xB0 | channel.index(),
|
||||
Self::ProgramChange { channel, .. } => 0xC0 | channel.index(),
|
||||
Self::ChannelAftertouch { channel, .. } => 0xD0 | channel.index(),
|
||||
Self::PitchBend { channel, .. } => 0xE0 | channel.index(),
|
||||
Self::SystemCommon { status, .. } | Self::System { status } => *status,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns how many bytes this message occupies, including its status byte.
|
||||
pub fn len(&self) -> usize {
|
||||
match self {
|
||||
Self::ProgramChange { .. } | Self::ChannelAftertouch { .. } => 2,
|
||||
Self::SystemCommon { status, .. } | Self::System { status } => {
|
||||
system_message_len(*status)
|
||||
}
|
||||
_ => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether this message carries no bytes, which cannot happen.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Writes this message into `out`, returning how many bytes were written.
|
||||
///
|
||||
/// Writes nothing and returns zero when `out` is too small, so a caller on the data path
|
||||
/// never has to handle a panic or a reallocation.
|
||||
pub fn encode(&self, out: &mut [u8]) -> usize {
|
||||
let needed = self.len();
|
||||
if out.len() < needed {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let bytes: [u8; 3] = match self {
|
||||
Self::NoteOff { note, velocity, .. } => [self.status(), *note, *velocity],
|
||||
Self::NoteOn { note, velocity, .. } => [self.status(), *note, *velocity],
|
||||
Self::PolyAftertouch { note, pressure, .. } => [self.status(), *note, *pressure],
|
||||
Self::ControlChange {
|
||||
controller, value, ..
|
||||
} => [self.status(), *controller, *value],
|
||||
Self::ProgramChange { program, .. } => [self.status(), *program, 0],
|
||||
Self::ChannelAftertouch { pressure, .. } => [self.status(), *pressure, 0],
|
||||
Self::PitchBend { value, .. } => {
|
||||
// Fourteen bits split across two seven-bit bytes, least significant first.
|
||||
[
|
||||
self.status(),
|
||||
(*value & 0x7F) as u8,
|
||||
((*value >> 7) & 0x7F) as u8,
|
||||
]
|
||||
}
|
||||
Self::SystemCommon {
|
||||
status,
|
||||
data: [first, second],
|
||||
} => [*status, *first, *second],
|
||||
Self::System { status } => [*status, 0, 0],
|
||||
};
|
||||
|
||||
for (slot, byte) in out.iter_mut().zip(bytes.iter()).take(needed) {
|
||||
*slot = *byte;
|
||||
}
|
||||
needed
|
||||
}
|
||||
|
||||
/// Parses one message from `bytes`, returning it and how many bytes it consumed.
|
||||
///
|
||||
/// `running_status` supplies the status byte when the message omits it, which is legal inside
|
||||
/// an RTP-MIDI list and common from hardware.
|
||||
pub fn parse(bytes: &[u8], running_status: Option<u8>) -> Option<(Self, usize)> {
|
||||
let first = bytes.first().copied()?;
|
||||
|
||||
// Resolve the status byte, which may be carried over from the previous message.
|
||||
let (status, payload_offset) = if first >= 0x80 {
|
||||
(first, 1)
|
||||
} else {
|
||||
(running_status?, 0)
|
||||
};
|
||||
|
||||
// A byte with the high bit set is never data. Finding one here means a status arrived
|
||||
// before this message was complete, and forwarding it as data would put a status byte in
|
||||
// the middle of the next device's stream.
|
||||
let len = if status >= 0xF0 {
|
||||
system_message_len(status)
|
||||
} else {
|
||||
channel_message_len(status)
|
||||
};
|
||||
let data = bytes.get(payload_offset..)?;
|
||||
let needed = data.get(..len.saturating_sub(1))?;
|
||||
if needed.iter().any(|byte| *byte >= 0x80) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// System messages carry no channel.
|
||||
if status >= 0xF0 {
|
||||
let message = if len > 1 {
|
||||
Self::SystemCommon {
|
||||
status,
|
||||
data: [
|
||||
needed.first().copied().unwrap_or(0),
|
||||
needed.get(1).copied().unwrap_or(0),
|
||||
],
|
||||
}
|
||||
} else {
|
||||
Self::System { status }
|
||||
};
|
||||
return Some((message, payload_offset + len.saturating_sub(1)));
|
||||
}
|
||||
|
||||
let channel = Channel::from_status(status);
|
||||
let first_data = data.first().copied()?;
|
||||
|
||||
let message = match status & 0xF0 {
|
||||
0x80 => {
|
||||
let velocity = data.get(1).copied()?;
|
||||
Self::NoteOff {
|
||||
channel,
|
||||
note: first_data,
|
||||
velocity,
|
||||
}
|
||||
}
|
||||
0x90 => {
|
||||
let velocity = data.get(1).copied()?;
|
||||
Self::NoteOn {
|
||||
channel,
|
||||
note: first_data,
|
||||
velocity,
|
||||
}
|
||||
}
|
||||
0xA0 => {
|
||||
let pressure = data.get(1).copied()?;
|
||||
Self::PolyAftertouch {
|
||||
channel,
|
||||
note: first_data,
|
||||
pressure,
|
||||
}
|
||||
}
|
||||
0xB0 => {
|
||||
let value = data.get(1).copied()?;
|
||||
Self::ControlChange {
|
||||
channel,
|
||||
controller: first_data,
|
||||
value,
|
||||
}
|
||||
}
|
||||
0xC0 => Self::ProgramChange {
|
||||
channel,
|
||||
program: first_data,
|
||||
},
|
||||
0xD0 => Self::ChannelAftertouch {
|
||||
channel,
|
||||
pressure: first_data,
|
||||
},
|
||||
0xE0 => {
|
||||
let msb = data.get(1).copied()?;
|
||||
Self::PitchBend {
|
||||
channel,
|
||||
value: u16::from(first_data) | (u16::from(msb) << 7),
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let consumed = payload_offset + message.len().saturating_sub(1);
|
||||
Some((message, consumed))
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns how many bytes a channel message occupies, including its status byte.
|
||||
fn channel_message_len(status: u8) -> usize {
|
||||
match status & 0xF0 {
|
||||
0xC0 | 0xD0 => 2,
|
||||
_ => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns how many bytes a system message occupies.
|
||||
///
|
||||
/// System-exclusive is unbounded, so it reports its status byte alone and the caller scans for
|
||||
/// the terminator itself.
|
||||
fn system_message_len(status: u8) -> usize {
|
||||
match status {
|
||||
// Time code quarter frame and song select carry one data byte.
|
||||
0xF1 | 0xF3 => 2,
|
||||
// Song position pointer carries two.
|
||||
0xF2 => 3,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a note number's key and octave, as Apple's MIDI tools write it: middle C, note 60, is C3.
|
||||
///
|
||||
/// Every place a note is shown uses this, so the monitor and the window's note picker name a note
|
||||
/// alike.
|
||||
pub fn note_name(note: u8) -> String {
|
||||
/// Key names, indexed by semitone within an octave.
|
||||
const NAMES: [&str; 12] = [
|
||||
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
|
||||
];
|
||||
let key = NAMES.get(usize::from(note % 12)).copied().unwrap_or("?");
|
||||
// Twelve semitones per octave, and note 0 sits two octaves below octave 0.
|
||||
#[allow(clippy::integer_division)]
|
||||
let octave = i16::from(note / 12) - 2;
|
||||
format!("{key}{octave}")
|
||||
}
|
||||
|
||||
/// Builds the messages that silence a channel completely.
|
||||
///
|
||||
/// Sent on every link teardown and recovery. All-notes-off alone is not enough: a held sustain
|
||||
/// pedal keeps notes sounding through it. This sends the pedal release first, then the two broad
|
||||
/// resets. A synth that ignores all-notes-off needs a note off for each note, which only a caller
|
||||
/// that knows the notes can send; `Sounding::silence` does.
|
||||
pub fn silence_channel(channel: Channel) -> [MidiMessage; 3] {
|
||||
[
|
||||
MidiMessage::ControlChange {
|
||||
channel,
|
||||
controller: CC_SUSTAIN,
|
||||
value: 0,
|
||||
},
|
||||
MidiMessage::ControlChange {
|
||||
channel,
|
||||
controller: CC_ALL_NOTES_OFF,
|
||||
value: 0,
|
||||
},
|
||||
MidiMessage::ControlChange {
|
||||
channel,
|
||||
controller: CC_ALL_SOUND_OFF,
|
||||
value: 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Locks Apple's octave numbering, which the monitor and the note picker both show.
|
||||
///
|
||||
/// Audio MIDI Setup and Logic write middle C, note 60, as C3; other tools write it C4, so the
|
||||
/// convention is a choice that has to stay put. Note 0 is C-2 and note 127 is G8, the ends of
|
||||
/// MIDI's range: 127 is ten octaves and seven semitones above 0.
|
||||
#[test]
|
||||
fn a_note_is_named_with_middle_c_as_c3() {
|
||||
for (note, want) in [(0, "C-2"), (60, "C3"), (61, "C#3"), (69, "A3"), (127, "G8")] {
|
||||
assert_eq!(note_name(note), want, "note {note} must read {want}");
|
||||
}
|
||||
}
|
||||
|
||||
fn channel(index: u8) -> Channel {
|
||||
Channel::new(index).expect("channel in range")
|
||||
}
|
||||
|
||||
/// Every kind of message encodes to the bytes the MIDI 1.0 specification gives it and parses
|
||||
/// back from them.
|
||||
///
|
||||
/// The status byte is the kind in the high nibble and the zero-based channel in the low one.
|
||||
/// Program change and channel pressure carry one data byte, quarter frame and song select one,
|
||||
/// song position two, and real-time messages none. Pitch bend is fourteen bits sent least
|
||||
/// significant seven first, so the centre, 8192 = 0x40 << 7 | 0x00, is `E1 00 40`.
|
||||
#[test]
|
||||
fn messages_encode_to_the_bytes_the_specification_gives_and_parse_back() {
|
||||
let cases = [
|
||||
(
|
||||
"note off",
|
||||
MidiMessage::NoteOff {
|
||||
channel: channel(0),
|
||||
note: 60,
|
||||
velocity: 64,
|
||||
},
|
||||
&[0x80, 60, 64][..],
|
||||
),
|
||||
(
|
||||
"note on, channel 16",
|
||||
MidiMessage::NoteOn {
|
||||
channel: channel(15),
|
||||
note: 127,
|
||||
velocity: 100,
|
||||
},
|
||||
&[0x9F, 127, 100][..],
|
||||
),
|
||||
(
|
||||
"poly pressure",
|
||||
MidiMessage::PolyAftertouch {
|
||||
channel: channel(3),
|
||||
note: 40,
|
||||
pressure: 90,
|
||||
},
|
||||
&[0xA3, 40, 90][..],
|
||||
),
|
||||
(
|
||||
"control change",
|
||||
MidiMessage::ControlChange {
|
||||
channel: channel(7),
|
||||
controller: 74,
|
||||
value: 12,
|
||||
},
|
||||
&[0xB7, 74, 12][..],
|
||||
),
|
||||
(
|
||||
"program change",
|
||||
MidiMessage::ProgramChange {
|
||||
channel: channel(2),
|
||||
program: 5,
|
||||
},
|
||||
&[0xC2, 5][..],
|
||||
),
|
||||
(
|
||||
"channel pressure",
|
||||
MidiMessage::ChannelAftertouch {
|
||||
channel: channel(9),
|
||||
pressure: 77,
|
||||
},
|
||||
&[0xD9, 77][..],
|
||||
),
|
||||
(
|
||||
"pitch bend centred",
|
||||
MidiMessage::PitchBend {
|
||||
channel: channel(1),
|
||||
value: 8192,
|
||||
},
|
||||
&[0xE1, 0x00, 0x40][..],
|
||||
),
|
||||
(
|
||||
"time code quarter frame",
|
||||
MidiMessage::SystemCommon {
|
||||
status: 0xF1,
|
||||
data: [0x35, 0],
|
||||
},
|
||||
&[0xF1, 0x35][..],
|
||||
),
|
||||
(
|
||||
"song position",
|
||||
MidiMessage::SystemCommon {
|
||||
status: 0xF2,
|
||||
data: [0x10, 0x20],
|
||||
},
|
||||
&[0xF2, 0x10, 0x20][..],
|
||||
),
|
||||
(
|
||||
"song select",
|
||||
MidiMessage::SystemCommon {
|
||||
status: 0xF3,
|
||||
data: [0x07, 0],
|
||||
},
|
||||
&[0xF3, 0x07][..],
|
||||
),
|
||||
(
|
||||
"timing clock",
|
||||
MidiMessage::System { status: 0xF8 },
|
||||
&[0xF8][..],
|
||||
),
|
||||
];
|
||||
for (case, message, bytes) in cases {
|
||||
let mut buffer = [0u8; 3];
|
||||
let written = message.encode(&mut buffer);
|
||||
assert_eq!(
|
||||
&buffer[..written],
|
||||
bytes,
|
||||
"{case}: the encoding must be the specification's bytes"
|
||||
);
|
||||
assert_eq!(
|
||||
MidiMessage::parse(bytes, None),
|
||||
Some((message, bytes.len())),
|
||||
"{case}: the specification's bytes must parse back to the message, consumed whole"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
/// Whatever parses from a run of bytes encodes back to exactly the bytes it consumed.
|
||||
///
|
||||
/// A message the daemon would read one way and send another is a peer misreading us.
|
||||
#[test]
|
||||
fn what_is_parsed_encodes_back_to_the_bytes_it_came_from(
|
||||
bytes in proptest::collection::vec(proptest::num::u8::ANY, 1..4),
|
||||
) {
|
||||
proptest::prop_assume!(bytes.first().is_some_and(|status| *status >= 0x80));
|
||||
if let Some((message, consumed)) = MidiMessage::parse(&bytes, None) {
|
||||
let mut buffer = [0u8; 3];
|
||||
let written = message.encode(&mut buffer);
|
||||
proptest::prop_assert_eq!(buffer.get(..written), bytes.get(..consumed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Running status supplies a missing status byte, and bytes that do not make a whole message
|
||||
/// are refused rather than completed by guessing.
|
||||
///
|
||||
/// Running status is legal inside an RTP-MIDI list and common from hardware, so data bytes
|
||||
/// with a status carried over are a message. With nothing carried over, or with the message
|
||||
/// cut short, nothing establishes what the bytes mean.
|
||||
#[test]
|
||||
fn running_status_completes_a_message_and_nothing_else_does() {
|
||||
let note_on = MidiMessage::NoteOn {
|
||||
channel: channel(0),
|
||||
note: 62,
|
||||
velocity: 100,
|
||||
};
|
||||
let cases = [
|
||||
(
|
||||
"data bytes under running status",
|
||||
&[62, 100][..],
|
||||
Some(0x90),
|
||||
Some((note_on, 2)),
|
||||
),
|
||||
(
|
||||
"data bytes with no running status",
|
||||
&[62, 100][..],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"a note on missing its velocity",
|
||||
&[0x90, 60][..],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
("a status byte alone", &[0x90][..], None, None),
|
||||
("nothing at all", &[][..], Some(0x90), None),
|
||||
];
|
||||
for (case, bytes, running, want) in cases {
|
||||
assert_eq!(
|
||||
MidiMessage::parse(bytes, running),
|
||||
want,
|
||||
"{case}: only a whole message may be parsed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte with the high bit set is never read as data, wherever it falls in a message.
|
||||
///
|
||||
/// MIDI 1.0 reserves the high bit for status bytes. One arriving before a message is complete
|
||||
/// abandons it; forwarding it as data would put a status byte in the middle of the next
|
||||
/// device's stream, which that device reads as the start of something else.
|
||||
#[test]
|
||||
fn a_status_byte_is_never_read_as_data() {
|
||||
for status in (0x80..=0xE0).step_by(0x10).chain([0xF1, 0xF2, 0xF3]) {
|
||||
let len = if status >= 0xF0 {
|
||||
system_message_len(status)
|
||||
} else {
|
||||
channel_message_len(status)
|
||||
};
|
||||
for intruder in 0x80..=0xFF {
|
||||
for position in 1..len {
|
||||
let mut bytes = [status, 0x00, 0x00];
|
||||
bytes[position] = intruder;
|
||||
assert_eq!(
|
||||
MidiMessage::parse(&bytes, None),
|
||||
None,
|
||||
"{bytes:02X?}: a status byte inside a message must refuse it"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A note on with zero velocity stops a note rather than starting one.
|
||||
///
|
||||
/// The MIDI 1.0 specification defines it as a note off, and senders use it to keep running
|
||||
/// status across a run of notes. Treating it as a start is the classic way to leave a note
|
||||
/// hanging forever.
|
||||
#[test]
|
||||
fn a_note_on_with_zero_velocity_stops_a_note() {
|
||||
let cases = [
|
||||
("velocity zero", 0, false, Some((channel(0), 60))),
|
||||
("velocity one", 1, true, None),
|
||||
];
|
||||
for (case, velocity, starts, stops) in cases {
|
||||
let message = MidiMessage::NoteOn {
|
||||
channel: channel(0),
|
||||
note: 60,
|
||||
velocity,
|
||||
};
|
||||
assert_eq!(
|
||||
(message.starts_note(), message.stops_note()),
|
||||
(starts, stops),
|
||||
"{case}: only a note on with velocity starts a note"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
168
crates/core/src/paths.rs
Normal file
168
crates/core/src/paths.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
//! Where configuration, runtime state and the daemon socket live on each platform.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Directory name used for configuration and runtime state on every platform.
|
||||
///
|
||||
/// The project's own name, unspaced. A space would buy nothing and cost characters in the socket
|
||||
/// path, which is bounded by `sun_path`.
|
||||
pub const APP_DIR: &str = "midi-harbor";
|
||||
|
||||
/// Reverse-DNS identifier of the application bundle, which the launchd label extends.
|
||||
pub const BUNDLE_ID: &str = "com.mrgeckosmedia.MidiHarbor";
|
||||
/// File name of the configuration document.
|
||||
pub const CONFIG_FILE: &str = "config.yaml";
|
||||
/// File name of the daemon's listening socket.
|
||||
pub const SOCKET_FILE: &str = "daemon.sock";
|
||||
|
||||
/// Names the variable the App Sandbox sets in every sandboxed process, before `main` runs.
|
||||
pub const SANDBOX_VARIABLE: &str = "APP_SANDBOX_CONTAINER_ID";
|
||||
|
||||
/// Reports whether this process runs in the macOS App Sandbox, which only the App Store build
|
||||
/// does.
|
||||
///
|
||||
/// The sandbox sets `APP_SANDBOX_CONTAINER_ID` itself, and redirects `HOME` and `TMPDIR` into the
|
||||
/// app's container (research R-094). Every other platform returns false.
|
||||
pub fn sandboxed() -> bool {
|
||||
cfg!(target_os = "macos") && std::env::var_os(SANDBOX_VARIABLE).is_some()
|
||||
}
|
||||
|
||||
/// Why a standard directory could not be resolved.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PathError {
|
||||
/// The operating system reported no home directory for this user.
|
||||
#[error("could not determine the user's home directory")]
|
||||
NoHome,
|
||||
}
|
||||
|
||||
/// Resolves the standard locations Midi Harbor uses.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Paths {
|
||||
config_dir: PathBuf,
|
||||
runtime_dir: PathBuf,
|
||||
/// A socket named with `--socket`, which replaces the standard one and nothing else.
|
||||
socket: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Paths {
|
||||
/// Resolves the platform's standard locations for the current user.
|
||||
///
|
||||
/// Configuration goes where each platform expects a user-editable document:
|
||||
/// `~/Library/Application Support/midi-harbor` on macOS, and `$XDG_CONFIG_HOME/midi-harbor`
|
||||
/// on Linux, which honours the variable when it is set and falls back to `~/.config`.
|
||||
pub fn resolve() -> Result<Self, PathError> {
|
||||
let base = directories::BaseDirs::new().ok_or(PathError::NoHome)?;
|
||||
let config_dir = base.config_dir().join(APP_DIR);
|
||||
Ok(Self {
|
||||
config_dir,
|
||||
runtime_dir: Self::runtime_dir_in(&base),
|
||||
socket: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the directory transient runtime state belongs in.
|
||||
///
|
||||
/// The socket is not configuration and must not outlive a boot. Linux has
|
||||
/// `XDG_RUNTIME_DIR` for exactly this; macOS has no equivalent, so the per-user temporary
|
||||
/// directory is the closest correct thing.
|
||||
fn runtime_root(base: &directories::BaseDirs) -> PathBuf {
|
||||
if let Some(dir) = base.runtime_dir() {
|
||||
return dir.to_path_buf();
|
||||
}
|
||||
std::env::temp_dir()
|
||||
}
|
||||
|
||||
/// Returns the runtime directory: the app's own directory under the runtime root, or in the
|
||||
/// sandbox the container's `tmp` itself.
|
||||
///
|
||||
/// The container's `tmp` is already private to Midi Harbor, and its path is long: with the
|
||||
/// directory the socket reaches macOS's 103-byte limit for account names over 15 characters,
|
||||
/// and without it for names over 27 (research R-096).
|
||||
fn runtime_dir_in(base: &directories::BaseDirs) -> PathBuf {
|
||||
if sandboxed() {
|
||||
return std::env::temp_dir();
|
||||
}
|
||||
Self::runtime_root(base).join(APP_DIR)
|
||||
}
|
||||
|
||||
/// Builds paths rooted at an explicit directory, for tests.
|
||||
pub fn rooted_at(root: impl Into<PathBuf>) -> Self {
|
||||
let root = root.into();
|
||||
Self {
|
||||
config_dir: root.join("config"),
|
||||
runtime_dir: root.join("run"),
|
||||
socket: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Uses `socket` in place of the standard socket, leaving the configuration where it was.
|
||||
///
|
||||
/// This is what `--socket` means for every role, the daemon included: a second daemon on its
|
||||
/// own socket still reads the user's configuration, and one that silently listened on the
|
||||
/// standard socket instead would collide with the daemon already there.
|
||||
#[must_use]
|
||||
pub fn with_socket(mut self, socket: Option<PathBuf>) -> Self {
|
||||
if socket.is_some() {
|
||||
self.socket = socket;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the directory holding the configuration document.
|
||||
pub fn config_dir(&self) -> &Path {
|
||||
&self.config_dir
|
||||
}
|
||||
|
||||
/// Returns the configuration document's full path.
|
||||
pub fn config_file(&self) -> PathBuf {
|
||||
self.config_dir.join(CONFIG_FILE)
|
||||
}
|
||||
|
||||
/// Returns the directory holding runtime state.
|
||||
pub fn runtime_dir(&self) -> &Path {
|
||||
&self.runtime_dir
|
||||
}
|
||||
|
||||
/// Returns the daemon socket's full path.
|
||||
pub fn socket_file(&self) -> PathBuf {
|
||||
self.socket
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.runtime_dir.join(SOCKET_FILE))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The configuration lives where the platform keeps a user's editable documents, and the
|
||||
/// runtime directory holding the socket has no space in it.
|
||||
///
|
||||
/// macOS keeps them in `~/Library/Application Support`, as the `directories` crate reports
|
||||
/// it; elsewhere the platform's own directory holds a `midi-harbor` folder. The socket path is
|
||||
/// bounded by `sun_path`, 104 bytes on macOS, so characters spent on presentation are
|
||||
/// characters taken from the temporary directory the system hands us.
|
||||
#[test]
|
||||
fn paths_resolve_where_the_platform_expects_them() {
|
||||
let paths = Paths::resolve().expect("this user has a home directory");
|
||||
assert!(
|
||||
paths.config_dir().is_absolute() && paths.runtime_dir().is_absolute(),
|
||||
"resolved paths must not depend on the working directory: {paths:?}"
|
||||
);
|
||||
assert!(
|
||||
!paths.runtime_dir().display().to_string().contains(' '),
|
||||
"the runtime directory must leave room in sun_path: {paths:?}"
|
||||
);
|
||||
|
||||
let shown = paths.config_dir().display().to_string();
|
||||
let expected = if cfg!(target_os = "macos") {
|
||||
"Library/Application Support/midi-harbor"
|
||||
} else {
|
||||
APP_DIR
|
||||
};
|
||||
assert!(
|
||||
shown.ends_with(expected),
|
||||
"the configuration must be in the platform's place for user documents: {shown}"
|
||||
);
|
||||
}
|
||||
}
|
||||
1004
crates/core/src/router.rs
Normal file
1004
crates/core/src/router.rs
Normal file
File diff suppressed because it is too large
Load diff
382
crates/core/src/rtchannel.rs
Normal file
382
crates/core/src/rtchannel.rs
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
//! Carrying MIDI across the real-time boundary.
|
||||
//!
|
||||
//! A platform read callback runs in a real-time context: it may not allocate, lock, or block. So
|
||||
//! it does nothing but stamp what arrived and push it into a pre-allocated ring for an ordinary
|
||||
//! task to drain.
|
||||
//!
|
||||
//! These live here rather than beside the router so that the platform layer can hold a producer
|
||||
//! without depending on the daemon.
|
||||
|
||||
use crate::ids::EndpointId;
|
||||
use crate::midi::MidiMessage;
|
||||
use crate::rtevent::{RtEvent, RtPayload};
|
||||
use crate::stream::SysExEnd;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Events one endpoint may buffer before the oldest are dropped.
|
||||
///
|
||||
/// At a few thousand messages a second this is roughly a second of headroom, which is far longer
|
||||
/// than any drain delay should ever be. Larger would hide a problem rather than solve it.
|
||||
pub const EVENT_CAPACITY: usize = 4096;
|
||||
|
||||
/// System-exclusive bytes one endpoint may buffer.
|
||||
///
|
||||
/// Enough for several large dumps in flight. A sample dump that exceeds it is truncated and
|
||||
/// counted, which is honest; silently growing the buffer would not be.
|
||||
pub const SYSEX_CAPACITY: usize = 65_536;
|
||||
|
||||
/// Wakes whoever drains the rings when something is pushed into one.
|
||||
///
|
||||
/// Draining on a timer made every message wait for the next tick, which cost as much as the
|
||||
/// whole latency budget, and woke every endpoint's drain hundreds of times a second while nothing
|
||||
/// played. Ringing is one atomic swap, and a wake only on the first ring since the listener last
|
||||
/// waited, so a burst of messages costs one wake. `Thread::unpark` takes no lock and allocates
|
||||
/// nothing, which is what lets a real-time callback ring it.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Doorbell {
|
||||
rung: AtomicBool,
|
||||
listener: OnceLock<std::thread::Thread>,
|
||||
}
|
||||
|
||||
impl Doorbell {
|
||||
/// Creates a doorbell no one is listening to yet.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Says something arrived. Safe from a real-time context.
|
||||
pub fn ring(&self) {
|
||||
if !self.rung.swap(true, Ordering::AcqRel)
|
||||
&& let Some(listener) = self.listener.get()
|
||||
{
|
||||
listener.unpark();
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocks until the doorbell rings or `timeout` passes, reporting whether it rang.
|
||||
///
|
||||
/// The first thread to wait becomes the only one ever woken. A ring that came before the wait
|
||||
/// is not lost: it is seen at once.
|
||||
pub fn wait(&self, timeout: Duration) -> bool {
|
||||
let _ = self.listener.set(std::thread::current());
|
||||
if self.rung.swap(false, Ordering::AcqRel) {
|
||||
return true;
|
||||
}
|
||||
std::thread::park_timeout(timeout);
|
||||
self.rung.swap(false, Ordering::AcqRel)
|
||||
}
|
||||
}
|
||||
|
||||
/// The producing half, held by a platform callback.
|
||||
///
|
||||
/// Every method here is safe to call from a real-time context: no allocation, no locking, no
|
||||
/// blocking, and no arithmetic that can panic.
|
||||
pub struct RtProducer {
|
||||
events: rtrb::Producer<RtEvent>,
|
||||
sysex: rtrb::Producer<u8>,
|
||||
dropped: Arc<AtomicU64>,
|
||||
malformed: Arc<AtomicU64>,
|
||||
source: EndpointId,
|
||||
doorbell: Option<Arc<Doorbell>>,
|
||||
}
|
||||
|
||||
impl RtProducer {
|
||||
/// Wakes the drain, if one is listening, after something was accepted.
|
||||
fn announce(&self) {
|
||||
if let Some(doorbell) = &self.doorbell {
|
||||
doorbell.ring();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes a message, counting it as dropped if the ring is full.
|
||||
///
|
||||
/// Returns whether it was accepted, though callers on the real-time path usually ignore that:
|
||||
/// there is nothing useful they could do about a full ring.
|
||||
pub fn push(&mut self, message: MidiMessage, timestamp: u64) -> bool {
|
||||
let event = RtEvent::message(self.source, timestamp, message);
|
||||
match self.events.push(event) {
|
||||
Ok(()) => {
|
||||
self.announce();
|
||||
true
|
||||
}
|
||||
Err(_) => {
|
||||
// Counting rather than growing. A buffer that grows under load allocates on the
|
||||
// real-time path, which is the thing this whole structure exists to avoid.
|
||||
self.dropped.fetch_add(1, Ordering::Relaxed);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes a run of system-exclusive bytes.
|
||||
///
|
||||
/// The bytes go into their own ring and the event records only how many, so the event itself
|
||||
/// stays fixed-size. Both rings are written from this thread, so their order is preserved
|
||||
/// without synchronisation.
|
||||
pub fn push_sysex(&mut self, bytes: &[u8], end: SysExEnd, timestamp: u64) -> bool {
|
||||
let Ok(len) = u16::try_from(bytes.len()) else {
|
||||
self.dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return false;
|
||||
};
|
||||
if self.sysex.slots() < bytes.len() {
|
||||
self.dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write the bytes first, so a consumer that sees the event always finds them present.
|
||||
for byte in bytes {
|
||||
if self.sysex.push(*byte).is_err() {
|
||||
self.dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let event = RtEvent {
|
||||
source: self.source,
|
||||
timestamp,
|
||||
payload: RtPayload::SysEx { len, end },
|
||||
};
|
||||
if self.events.push(event).is_err() {
|
||||
self.dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return false;
|
||||
}
|
||||
self.announce();
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns how many events this endpoint has dropped for want of space.
|
||||
pub fn dropped(&self) -> u64 {
|
||||
self.dropped.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Counts one packet from the device that could not be decoded and was discarded.
|
||||
pub fn record_malformed(&self) {
|
||||
self.malformed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The consuming half, drained by an ordinary task.
|
||||
pub struct RtConsumer {
|
||||
events: rtrb::Consumer<RtEvent>,
|
||||
sysex: rtrb::Consumer<u8>,
|
||||
dropped: Arc<AtomicU64>,
|
||||
malformed: Arc<AtomicU64>,
|
||||
source: EndpointId,
|
||||
/// Which of the endpoint's MIDI In connectors this ring carries, counting from zero.
|
||||
connector: u8,
|
||||
}
|
||||
|
||||
/// One drained event, with any system-exclusive bytes gathered up.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Drained {
|
||||
/// A message that travelled inline.
|
||||
Message {
|
||||
/// When it arrived.
|
||||
timestamp: u64,
|
||||
/// The message.
|
||||
message: MidiMessage,
|
||||
},
|
||||
/// A run of system-exclusive bytes.
|
||||
SysEx {
|
||||
/// When it arrived.
|
||||
timestamp: u64,
|
||||
/// The bytes, including the framing at either end of the message.
|
||||
bytes: Vec<u8>,
|
||||
/// Whether the message is finished, and whether it finished properly.
|
||||
end: SysExEnd,
|
||||
},
|
||||
}
|
||||
|
||||
impl RtConsumer {
|
||||
/// Returns which endpoint this consumer drains.
|
||||
pub fn source(&self) -> EndpointId {
|
||||
self.source
|
||||
}
|
||||
|
||||
/// Returns which of the endpoint's MIDI In connectors this consumer drains, counting from
|
||||
/// zero.
|
||||
///
|
||||
/// Held by the ring rather than by each event: every connector has a ring of its own, so a
|
||||
/// system-exclusive dump arriving on one is never interleaved with one arriving on another.
|
||||
pub fn connector(&self) -> u8 {
|
||||
self.connector
|
||||
}
|
||||
|
||||
/// Returns how many events were dropped for want of space.
|
||||
pub fn dropped(&self) -> u64 {
|
||||
self.dropped.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Returns how many packets from the device could not be decoded.
|
||||
pub fn malformed(&self) -> u64 {
|
||||
self.malformed.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Takes everything waiting, up to `limit` events.
|
||||
///
|
||||
/// Bounded so one very busy endpoint cannot starve the others sharing this task.
|
||||
pub fn drain(&mut self, limit: usize) -> Vec<Drained> {
|
||||
let mut taken = Vec::new();
|
||||
|
||||
while taken.len() < limit {
|
||||
let Ok(event) = self.events.pop() else {
|
||||
break;
|
||||
};
|
||||
match event.payload {
|
||||
RtPayload::Message(message) => {
|
||||
taken.push(Drained::Message {
|
||||
timestamp: event.timestamp,
|
||||
message,
|
||||
});
|
||||
}
|
||||
RtPayload::SysEx { len, end } => {
|
||||
let mut bytes = Vec::with_capacity(usize::from(len));
|
||||
for _ in 0..len {
|
||||
match self.sysex.pop() {
|
||||
Ok(byte) => bytes.push(byte),
|
||||
// The producer writes bytes before the event, so a short read means
|
||||
// the rings have diverged and nothing further can be trusted.
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
taken.push(Drained::SysEx {
|
||||
timestamp: event.timestamp,
|
||||
bytes,
|
||||
end,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
taken
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the two halves of one endpoint's data path.
|
||||
///
|
||||
/// Both rings are allocated here, once, so nothing on the real-time path ever allocates.
|
||||
pub fn channel(source: EndpointId) -> (RtProducer, RtConsumer) {
|
||||
build(source, 0, None)
|
||||
}
|
||||
|
||||
/// Creates the two halves of one endpoint's data path, ringing `doorbell` whenever something is
|
||||
/// pushed.
|
||||
pub fn channel_with_doorbell(
|
||||
source: EndpointId,
|
||||
doorbell: Arc<Doorbell>,
|
||||
) -> (RtProducer, RtConsumer) {
|
||||
build(source, 0, Some(doorbell))
|
||||
}
|
||||
|
||||
/// Creates the two halves of the data path for one of an endpoint's MIDI In connectors, counting
|
||||
/// from zero, ringing `doorbell` whenever something is pushed.
|
||||
pub fn connector_channel(
|
||||
source: EndpointId,
|
||||
connector: u8,
|
||||
doorbell: Arc<Doorbell>,
|
||||
) -> (RtProducer, RtConsumer) {
|
||||
build(source, connector, Some(doorbell))
|
||||
}
|
||||
|
||||
/// Allocates both rings for one endpoint connector.
|
||||
fn build(
|
||||
source: EndpointId,
|
||||
connector: u8,
|
||||
doorbell: Option<Arc<Doorbell>>,
|
||||
) -> (RtProducer, RtConsumer) {
|
||||
let (event_tx, event_rx) = rtrb::RingBuffer::new(EVENT_CAPACITY);
|
||||
let (sysex_tx, sysex_rx) = rtrb::RingBuffer::new(SYSEX_CAPACITY);
|
||||
let dropped = Arc::new(AtomicU64::new(0));
|
||||
let malformed = Arc::new(AtomicU64::new(0));
|
||||
|
||||
(
|
||||
RtProducer {
|
||||
events: event_tx,
|
||||
sysex: sysex_tx,
|
||||
dropped: Arc::clone(&dropped),
|
||||
malformed: Arc::clone(&malformed),
|
||||
source,
|
||||
doorbell,
|
||||
},
|
||||
RtConsumer {
|
||||
events: event_rx,
|
||||
sysex: sysex_rx,
|
||||
dropped,
|
||||
malformed,
|
||||
source,
|
||||
connector,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::midi::Channel;
|
||||
|
||||
fn note() -> MidiMessage {
|
||||
MidiMessage::NoteOn {
|
||||
channel: Channel::new(0).expect("channel 0"),
|
||||
note: 60,
|
||||
velocity: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// A push that lands before the drain waits is seen at once rather than lost.
|
||||
///
|
||||
/// The daemon's listener falls back to looking once a second, so a lost wake costs up to a
|
||||
/// second of latency without failing any routing test; this and the next test are the only
|
||||
/// checks that the doorbell wakes at once.
|
||||
#[test]
|
||||
fn a_push_before_the_wait_is_seen_rather_than_lost() {
|
||||
let doorbell = Arc::new(Doorbell::new());
|
||||
let (mut producer, _consumer) =
|
||||
channel_with_doorbell(EndpointId::new(), Arc::clone(&doorbell));
|
||||
assert!(
|
||||
!doorbell.wait(Duration::from_millis(20)),
|
||||
"with nothing pushed the wait must end by timing out"
|
||||
);
|
||||
|
||||
assert!(producer.push(note(), 0), "an empty ring must accept a note");
|
||||
let started = std::time::Instant::now();
|
||||
assert!(
|
||||
doorbell.wait(Duration::from_secs(5)),
|
||||
"a push before the wait must not be lost"
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(1),
|
||||
"a push before the wait must be seen at once, not after the timeout"
|
||||
);
|
||||
}
|
||||
|
||||
/// A push from another thread wakes a drain that is already waiting.
|
||||
///
|
||||
/// This is the path a real-time callback takes. The pushing thread sleeps first only so the
|
||||
/// waiter is parked when the push lands; the assertion is on the wake, not on the timing.
|
||||
#[test]
|
||||
fn a_push_from_another_thread_wakes_the_waiting_drain() {
|
||||
let doorbell = Arc::new(Doorbell::new());
|
||||
let (mut producer, _consumer) =
|
||||
channel_with_doorbell(EndpointId::new(), Arc::clone(&doorbell));
|
||||
assert!(
|
||||
!doorbell.wait(Duration::from_millis(1)),
|
||||
"with nothing pushed the wait must end by timing out"
|
||||
);
|
||||
|
||||
let pusher = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
producer.push(note(), 0);
|
||||
});
|
||||
let started = std::time::Instant::now();
|
||||
assert!(
|
||||
doorbell.wait(Duration::from_secs(5)),
|
||||
"a push from another thread must wake the waiter"
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(1),
|
||||
"the waiter must be woken by the push, not by the timeout"
|
||||
);
|
||||
pusher.join().expect("the pushing thread finishes");
|
||||
}
|
||||
}
|
||||
52
crates/core/src/rtevent.rs
Normal file
52
crates/core/src/rtevent.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//! The fixed-size event that crosses the real-time boundary.
|
||||
//!
|
||||
//! A MIDI read callback runs in a real-time context: it may not allocate, lock, or block. So the
|
||||
//! only thing it does is stamp what arrived and push it into a ring buffer for an ordinary task
|
||||
//! to deal with. Everything here is `Copy` and fixed-size for that reason.
|
||||
//!
|
||||
//! System-exclusive data is unbounded and cannot travel inline. It goes through a separate byte
|
||||
//! ring alongside this one: the event records how many bytes to read, and because both rings are
|
||||
//! written by the same thread, ordering between them is preserved without any synchronisation.
|
||||
|
||||
use crate::ids::EndpointId;
|
||||
use crate::midi::MidiMessage;
|
||||
use crate::stream::SysExEnd;
|
||||
|
||||
/// What a real-time event carries.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RtPayload {
|
||||
/// A channel or system message, small enough to travel inline.
|
||||
Message(MidiMessage),
|
||||
/// A run of system-exclusive bytes waiting in the byte ring.
|
||||
SysEx {
|
||||
/// How many bytes to read from the byte ring.
|
||||
len: u16,
|
||||
/// Whether the message is finished, and whether it finished properly.
|
||||
end: SysExEnd,
|
||||
},
|
||||
}
|
||||
|
||||
/// One thing that happened on an endpoint, as seen from the real-time path.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RtEvent {
|
||||
/// Where it arrived, so the router knows what to do with it without a lookup by name.
|
||||
pub source: EndpointId,
|
||||
/// When it arrived, in the platform's own units.
|
||||
///
|
||||
/// Taken in the callback rather than when the event is drained, because the delay before
|
||||
/// draining is exactly the jitter this timestamp exists to remove.
|
||||
pub timestamp: u64,
|
||||
/// What it carries.
|
||||
pub payload: RtPayload,
|
||||
}
|
||||
|
||||
impl RtEvent {
|
||||
/// Creates an event carrying a single message.
|
||||
pub fn message(source: EndpointId, timestamp: u64, message: MidiMessage) -> Self {
|
||||
Self {
|
||||
source,
|
||||
timestamp,
|
||||
payload: RtPayload::Message(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
281
crates/core/src/sounding.rs
Normal file
281
crates/core/src/sounding.rs
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
//! Which notes an endpoint has left sounding.
|
||||
//!
|
||||
//! A note on with no matching note off is the failure a musician hears, and the one they cannot
|
||||
//! fix from the application that caused it — the note is sounding on a synth that is no longer
|
||||
//! being told anything. Everything that stops carrying MIDI has to stop the notes it started
|
||||
//! first, and that means knowing which ones those are.
|
||||
|
||||
use crate::midi::{CC_ALL_NOTES_OFF, CC_ALL_SOUND_OFF, Channel, MidiMessage, silence_channel};
|
||||
|
||||
/// How many notes one channel can hold, which is the full seven-bit range.
|
||||
const NOTES_PER_CHANNEL: u8 = 128;
|
||||
|
||||
/// The notes one endpoint currently has sounding, by channel.
|
||||
///
|
||||
/// Tracked per channel rather than silencing all sixteen, because a reset on a channel nothing
|
||||
/// was playing still clears sustain and cuts sound for whatever else is driving that port.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Sounding {
|
||||
/// One bit per note, one word per channel.
|
||||
notes: [u128; 16],
|
||||
}
|
||||
|
||||
impl Sounding {
|
||||
/// Creates a record with nothing sounding.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Updates the record from one message on its way out of an endpoint.
|
||||
pub fn record(&mut self, message: &MidiMessage) {
|
||||
// A note on with zero velocity is a note off by convention, so `stops_note` is asked
|
||||
// first: treating it as a start is the classic way to leave a note hanging forever.
|
||||
if let Some((channel, note)) = message.stops_note() {
|
||||
self.set(channel, note, false);
|
||||
return;
|
||||
}
|
||||
if let MidiMessage::NoteOn { channel, note, .. } = message {
|
||||
self.set(*channel, *note, true);
|
||||
return;
|
||||
}
|
||||
// A reset the sender issued itself leaves the channel quiet, so silencing later has
|
||||
// nothing left to say about it.
|
||||
if let MidiMessage::ControlChange {
|
||||
channel,
|
||||
controller,
|
||||
..
|
||||
} = message
|
||||
&& (*controller == CC_ALL_NOTES_OFF || *controller == CC_ALL_SOUND_OFF)
|
||||
{
|
||||
self.clear_channel(*channel);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the messages that stop everything this endpoint has sounding.
|
||||
///
|
||||
/// Each played channel gets its pedal released, a note off for every note still sounding,
|
||||
/// then the two broad resets. The note offs are for a synth that ignores all-notes-off, and
|
||||
/// come after the pedal release so the pedal does not hold them.
|
||||
///
|
||||
/// Empty when nothing is sounding, which is how a caller avoids disturbing a port that was
|
||||
/// never playing anything.
|
||||
pub fn silence(&self) -> Vec<MidiMessage> {
|
||||
self.silence_beside(0)
|
||||
}
|
||||
|
||||
/// Returns the channels with anything sounding, one bit per channel.
|
||||
pub fn channels(&self) -> u16 {
|
||||
self.notes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, word)| **word != 0)
|
||||
.fold(0, |channels, (index, _)| channels | (1 << index))
|
||||
}
|
||||
|
||||
/// Returns the messages that stop what this record has sounding, on a port others also play.
|
||||
///
|
||||
/// A channel in `shared`, one bit per channel as [`Sounding::channels`] gives them, has
|
||||
/// another sender's notes sounding on it. It gets only this record's note offs: the pedal
|
||||
/// release and the broad resets would stop the other sender's notes as well.
|
||||
pub fn silence_beside(&self, shared: u16) -> Vec<MidiMessage> {
|
||||
let mut messages = Vec::new();
|
||||
for channel in Channel::all() {
|
||||
let Some(word) = self
|
||||
.notes
|
||||
.get(usize::from(channel.index()))
|
||||
.copied()
|
||||
.filter(|word| *word != 0)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let alone = shared & (1 << channel.index()) == 0;
|
||||
let [pedal, notes_off, sound_off] = silence_channel(channel);
|
||||
if alone {
|
||||
messages.push(pedal);
|
||||
}
|
||||
messages.extend(
|
||||
(0..NOTES_PER_CHANNEL)
|
||||
.filter(|note| bit(*note).is_some_and(|bit| word & bit != 0))
|
||||
.map(|note| MidiMessage::NoteOff {
|
||||
channel,
|
||||
note,
|
||||
velocity: 0,
|
||||
}),
|
||||
);
|
||||
if alone {
|
||||
messages.push(notes_off);
|
||||
messages.push(sound_off);
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
/// Forgets everything, for an endpoint that is no longer able to sound anything.
|
||||
pub fn clear(&mut self) {
|
||||
self.notes = [0; 16];
|
||||
}
|
||||
|
||||
/// Records one note as sounding or stopped.
|
||||
fn set(&mut self, channel: Channel, note: u8, sounding: bool) {
|
||||
let Some(word) = self.notes.get_mut(usize::from(channel.index())) else {
|
||||
return;
|
||||
};
|
||||
let Some(bit) = bit(note) else {
|
||||
return;
|
||||
};
|
||||
if sounding {
|
||||
*word |= bit;
|
||||
} else {
|
||||
*word &= !bit;
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a whole channel as quiet.
|
||||
fn clear_channel(&mut self, channel: Channel) {
|
||||
if let Some(word) = self.notes.get_mut(usize::from(channel.index())) {
|
||||
*word = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the bit standing for one note, or nothing for a value outside the seven-bit range.
|
||||
fn bit(note: u8) -> Option<u128> {
|
||||
(note < NOTES_PER_CHANNEL).then(|| 1u128 << note)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::indexing_slicing, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::midi::CC_SUSTAIN;
|
||||
|
||||
fn channel(index: u8) -> Channel {
|
||||
Channel::new(index).expect("channel in range")
|
||||
}
|
||||
|
||||
fn note_on(index: u8, note: u8, velocity: u8) -> MidiMessage {
|
||||
MidiMessage::NoteOn {
|
||||
channel: channel(index),
|
||||
note,
|
||||
velocity,
|
||||
}
|
||||
}
|
||||
|
||||
fn note_off(index: u8, note: u8) -> MidiMessage {
|
||||
MidiMessage::NoteOff {
|
||||
channel: channel(index),
|
||||
note,
|
||||
velocity: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn controller(index: u8, controller: u8) -> MidiMessage {
|
||||
MidiMessage::ControlChange {
|
||||
channel: channel(index),
|
||||
controller,
|
||||
value: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the full silence for one channel with the given notes held, in the order it is
|
||||
/// sent: pedal release, a note off per note, all-notes-off, all-sound-off.
|
||||
fn silenced(index: u8, notes: &[u8]) -> Vec<MidiMessage> {
|
||||
let mut messages = vec![controller(index, CC_SUSTAIN)];
|
||||
messages.extend(notes.iter().map(|note| note_off(index, *note)));
|
||||
messages.push(controller(index, CC_ALL_NOTES_OFF));
|
||||
messages.push(controller(index, CC_ALL_SOUND_OFF));
|
||||
messages
|
||||
}
|
||||
|
||||
/// Silencing stops exactly the notes a sender left sounding, on only the channels it played,
|
||||
/// and leaves alone what another sender is playing.
|
||||
///
|
||||
/// The pedal is released first because a held sustain pedal keeps notes sounding through
|
||||
/// all-notes-off, and each held note gets its own note off for a synth that ignores
|
||||
/// all-notes-off. A channel nothing played is not reset, since a reset still clears sustain
|
||||
/// for whatever else drives the port, and on a channel another sender is playing the pedal
|
||||
/// release and broad resets would stop that sender's notes too, so only note offs go. A note
|
||||
/// on with zero velocity is a note off (MIDI 1.0), and a note number outside the seven-bit
|
||||
/// range is hostile input that must be ignored rather than index out of bounds.
|
||||
#[test]
|
||||
fn silencing_stops_exactly_what_was_left_sounding() {
|
||||
let cases = [
|
||||
(
|
||||
"a released note",
|
||||
vec![note_on(0, 60, 100), note_off(0, 60)],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
(
|
||||
"a note released by a note on with zero velocity",
|
||||
vec![note_on(0, 60, 100), note_on(0, 60, 0)],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
(
|
||||
"the same note struck twice and released once",
|
||||
vec![note_on(0, 60, 100), note_on(0, 60, 90), note_off(0, 60)],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
(
|
||||
"notes the sender silenced itself",
|
||||
vec![
|
||||
note_on(0, 60, 100),
|
||||
note_on(0, 64, 100),
|
||||
controller(0, CC_ALL_NOTES_OFF),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
(
|
||||
"notes out of the seven-bit range",
|
||||
vec![note_on(0, 200, 100), note_off(0, 255)],
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
(
|
||||
"held drum notes on channel 10 only, released by name in ascending order",
|
||||
vec![
|
||||
note_on(9, 82, 100),
|
||||
note_on(9, 36, 100),
|
||||
note_on(9, 40, 100),
|
||||
note_off(9, 40),
|
||||
],
|
||||
vec![],
|
||||
silenced(9, &[36, 82]),
|
||||
),
|
||||
(
|
||||
"notes on three channels, each silenced in channel order",
|
||||
vec![
|
||||
note_on(15, 60, 100),
|
||||
note_on(0, 60, 100),
|
||||
note_on(9, 60, 100),
|
||||
],
|
||||
vec![],
|
||||
[silenced(0, &[60]), silenced(9, &[60]), silenced(15, &[60])].concat(),
|
||||
),
|
||||
(
|
||||
"a channel another sender is playing gets only note offs",
|
||||
vec![note_on(0, 60, 100), note_on(9, 36, 100)],
|
||||
vec![note_on(9, 38, 100)],
|
||||
[silenced(0, &[60]), vec![note_off(9, 36)]].concat(),
|
||||
),
|
||||
];
|
||||
for (case, played, beside, want) in cases {
|
||||
let mut sounding = Sounding::new();
|
||||
for message in &played {
|
||||
sounding.record(message);
|
||||
}
|
||||
let mut other = Sounding::new();
|
||||
for message in &beside {
|
||||
other.record(message);
|
||||
}
|
||||
assert_eq!(
|
||||
sounding.silence_beside(other.channels()),
|
||||
want,
|
||||
"{case}: silencing must stop what was left sounding and nothing else"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
614
crates/core/src/state.rs
Normal file
614
crates/core/src/state.rs
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
//! The connection lifecycle every endpoint shares.
|
||||
//!
|
||||
//! One state machine covers virtual ports, physical devices, network sessions and Bluetooth links.
|
||||
//! Its defining property is that no input drives an enabled endpoint into a state it cannot leave:
|
||||
//! a connection the user has not switched off is always either working or on its way back.
|
||||
|
||||
use crate::backoff::{Backoff, BackoffPolicy};
|
||||
use crate::failure::FailureReason;
|
||||
use crate::time::{self, Clock};
|
||||
use jiff::Timestamp;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Where a connection currently sits in its lifecycle.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConnectionPhase {
|
||||
/// The user switched this endpoint off. The only phase that stays put on its own.
|
||||
Disabled,
|
||||
/// Enabled but not yet started.
|
||||
Disconnected,
|
||||
/// An attempt is in flight.
|
||||
Connecting,
|
||||
/// Carrying MIDI.
|
||||
Connected,
|
||||
/// Down for a reason the user cannot act on, waiting out a backoff delay.
|
||||
Retrying,
|
||||
/// Down for a reason the user can act on. Still retried, and re-evaluated on every attempt.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
impl ConnectionPhase {
|
||||
/// Reports whether MIDI can flow in this phase.
|
||||
pub fn is_usable(&self) -> bool {
|
||||
matches!(self, Self::Connected)
|
||||
}
|
||||
|
||||
/// Reports whether the machine will leave this phase without further input from the user.
|
||||
///
|
||||
/// True for everything except `Disabled`, which is the user's own decision. `Unavailable` is
|
||||
/// included deliberately: it is surfaced to the user but never abandoned.
|
||||
pub fn recovers_on_its_own(&self) -> bool {
|
||||
!matches!(self, Self::Disabled)
|
||||
}
|
||||
}
|
||||
|
||||
/// Something that happened to a connection.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Event {
|
||||
/// The user switched the endpoint on.
|
||||
Enable,
|
||||
/// The user switched the endpoint off.
|
||||
Disable,
|
||||
/// An attempt has started.
|
||||
Attempting,
|
||||
/// The attempt succeeded.
|
||||
Established,
|
||||
/// The attempt failed.
|
||||
Failed(FailureReason),
|
||||
/// An established connection went down.
|
||||
Lost(FailureReason),
|
||||
/// The backoff delay expired.
|
||||
RetryDue,
|
||||
/// The platform hinted that conditions changed — a wake, or a network interface change.
|
||||
///
|
||||
/// Only ever an optimisation. Recovery does not depend on this arriving, because the platform
|
||||
/// sources that produce it are unreliable.
|
||||
Nudge,
|
||||
}
|
||||
|
||||
/// What the supervisor must do as a result of a transition.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Effect {
|
||||
/// Open the connection now.
|
||||
StartConnect,
|
||||
/// Wait this long, then deliver `Event::RetryDue`.
|
||||
ScheduleRetry(Duration),
|
||||
/// Abandon any in-flight attempt and any pending retry.
|
||||
CancelPending,
|
||||
/// Silence every note this endpoint left sounding, before anything else.
|
||||
SilenceNotes,
|
||||
/// Resend controller, program and pitch state, because the peer missed what happened.
|
||||
RestoreState,
|
||||
}
|
||||
|
||||
/// How long a connection must stay up before it counts as recovered.
|
||||
///
|
||||
/// A link that drops sooner than this is flapping, and resetting the backoff on each brief
|
||||
/// connection is what let a flapping link reconnect four times a second indefinitely.
|
||||
pub const STABLE_AFTER: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How many brief connections in a row mark a link as unstable.
|
||||
pub const UNSTABLE_AFTER: u32 = 3;
|
||||
|
||||
/// The lifecycle position of one endpoint, with the history needed to explain it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnectionState {
|
||||
phase: ConnectionPhase,
|
||||
since: Timestamp,
|
||||
last_error: Option<FailureReason>,
|
||||
next_retry: Option<Timestamp>,
|
||||
backoff: Backoff,
|
||||
/// Tracks whether this connection has been up before, so recovery is distinguished from a
|
||||
/// first connection. Only a recovery needs state restoration.
|
||||
was_connected: bool,
|
||||
/// When the current connection was established, while it is up.
|
||||
connected_at: Option<Timestamp>,
|
||||
/// Connections in a row that dropped before `STABLE_AFTER`.
|
||||
flaps: u32,
|
||||
}
|
||||
|
||||
impl ConnectionState {
|
||||
/// Creates a state for an endpoint that starts switched off.
|
||||
pub fn disabled(now: Timestamp) -> Self {
|
||||
Self::with_policy(ConnectionPhase::Disabled, now, BackoffPolicy::default())
|
||||
}
|
||||
|
||||
/// Creates a state for an endpoint that starts switched on but not yet connected.
|
||||
pub fn enabled(now: Timestamp) -> Self {
|
||||
Self::with_policy(ConnectionPhase::Disconnected, now, BackoffPolicy::default())
|
||||
}
|
||||
|
||||
/// Creates a state in a given phase with a specific retry policy.
|
||||
pub fn with_policy(phase: ConnectionPhase, now: Timestamp, policy: BackoffPolicy) -> Self {
|
||||
Self {
|
||||
phase,
|
||||
since: now,
|
||||
last_error: None,
|
||||
next_retry: None,
|
||||
backoff: Backoff::new(policy),
|
||||
was_connected: false,
|
||||
connected_at: None,
|
||||
flaps: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current phase.
|
||||
pub fn phase(&self) -> ConnectionPhase {
|
||||
self.phase
|
||||
}
|
||||
|
||||
/// Returns when the connection entered its current phase.
|
||||
pub fn since(&self) -> Timestamp {
|
||||
self.since
|
||||
}
|
||||
|
||||
/// Returns how long the connection has held its current phase.
|
||||
pub fn time_in_phase(&self, now: Timestamp) -> Duration {
|
||||
time::elapsed(self.since, now)
|
||||
}
|
||||
|
||||
/// Returns the most recent failure, which persists after recovery so users can see what
|
||||
/// happened while they were not watching.
|
||||
pub fn last_error(&self) -> Option<&FailureReason> {
|
||||
self.last_error.as_ref()
|
||||
}
|
||||
|
||||
/// Returns when the next attempt is due, if one is scheduled.
|
||||
pub fn next_retry(&self) -> Option<Timestamp> {
|
||||
self.next_retry
|
||||
}
|
||||
|
||||
/// Returns how many attempts have been made since the last connection that stayed up.
|
||||
pub fn attempt(&self) -> u32 {
|
||||
self.backoff.attempt()
|
||||
}
|
||||
|
||||
/// Reports whether the link keeps connecting and dropping (the rapid connect/disconnect edge
|
||||
/// case).
|
||||
///
|
||||
/// Unstable after `UNSTABLE_AFTER` connections in a row each lasted less than
|
||||
/// `STABLE_AFTER`, and stable again once one lasts that long. The backoff already keeps such
|
||||
/// a link from reconnecting as fast as it drops. This is what tells the user why it keeps
|
||||
/// going quiet.
|
||||
pub fn is_unstable(&self, now: Timestamp) -> bool {
|
||||
let settled = self
|
||||
.connected_at
|
||||
.is_some_and(|at| time::elapsed(at, now) >= STABLE_AFTER);
|
||||
self.flaps >= UNSTABLE_AFTER && !settled
|
||||
}
|
||||
|
||||
/// Applies an event and returns what the supervisor must do about it.
|
||||
///
|
||||
/// `jitter` is a fraction in `[0.0, 1.0]` used to spread retries out; production passes a
|
||||
/// random value and tests pass a fixed one.
|
||||
pub fn apply(&mut self, event: Event, clock: &dyn Clock, jitter: f64) -> Vec<Effect> {
|
||||
let now = clock.now();
|
||||
match event {
|
||||
Event::Disable => self.on_disable(now),
|
||||
Event::Enable => self.on_enable(now),
|
||||
Event::Attempting => self.on_attempting(now),
|
||||
Event::Established => self.on_established(now),
|
||||
Event::Failed(reason) => self.on_down(now, reason, false, jitter),
|
||||
Event::Lost(reason) => self.on_down(now, reason, true, jitter),
|
||||
Event::RetryDue | Event::Nudge => self.on_retry(now, matches!(event, Event::Nudge)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies an event with random jitter, for production callers.
|
||||
pub fn apply_now(&mut self, event: Event, clock: &dyn Clock) -> Vec<Effect> {
|
||||
self.apply(event, clock, rand::random::<f64>())
|
||||
}
|
||||
|
||||
/// Moves to a new phase, recording when, and reports whether the phase actually changed.
|
||||
fn enter(&mut self, phase: ConnectionPhase, now: Timestamp) -> bool {
|
||||
if self.phase == phase {
|
||||
return false;
|
||||
}
|
||||
self.phase = phase;
|
||||
self.since = now;
|
||||
true
|
||||
}
|
||||
|
||||
fn on_disable(&mut self, now: Timestamp) -> Vec<Effect> {
|
||||
if self.phase == ConnectionPhase::Disabled {
|
||||
return Vec::new();
|
||||
}
|
||||
// Silence before tearing down, so nothing is left sounding on the far side.
|
||||
let mut effects = Vec::new();
|
||||
if self.phase == ConnectionPhase::Connected {
|
||||
effects.push(Effect::SilenceNotes);
|
||||
}
|
||||
effects.push(Effect::CancelPending);
|
||||
self.next_retry = None;
|
||||
self.backoff.reset();
|
||||
self.connected_at = None;
|
||||
self.flaps = 0;
|
||||
self.enter(ConnectionPhase::Disabled, now);
|
||||
effects
|
||||
}
|
||||
|
||||
fn on_enable(&mut self, now: Timestamp) -> Vec<Effect> {
|
||||
if self.phase != ConnectionPhase::Disabled {
|
||||
return Vec::new();
|
||||
}
|
||||
self.backoff.reset();
|
||||
self.last_error = None;
|
||||
self.enter(ConnectionPhase::Disconnected, now);
|
||||
vec![Effect::StartConnect]
|
||||
}
|
||||
|
||||
fn on_attempting(&mut self, now: Timestamp) -> Vec<Effect> {
|
||||
if self.phase == ConnectionPhase::Disabled || self.phase == ConnectionPhase::Connected {
|
||||
return Vec::new();
|
||||
}
|
||||
self.next_retry = None;
|
||||
self.enter(ConnectionPhase::Connecting, now);
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn on_established(&mut self, now: Timestamp) -> Vec<Effect> {
|
||||
if self.phase == ConnectionPhase::Disabled {
|
||||
return Vec::new();
|
||||
}
|
||||
// Only a reconnection needs state restoration; a first connection has nothing to restore.
|
||||
let recovering = self.was_connected;
|
||||
// The backoff is not reset here. A connection earns that by staying up, which is
|
||||
// decided when it drops.
|
||||
self.next_retry = None;
|
||||
self.was_connected = true;
|
||||
self.connected_at = Some(now);
|
||||
self.enter(ConnectionPhase::Connected, now);
|
||||
if recovering {
|
||||
vec![Effect::RestoreState]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn on_down(
|
||||
&mut self,
|
||||
now: Timestamp,
|
||||
reason: FailureReason,
|
||||
was_up: bool,
|
||||
jitter: f64,
|
||||
) -> Vec<Effect> {
|
||||
if self.phase == ConnectionPhase::Disabled {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut effects = Vec::new();
|
||||
// Losing an established link can leave notes held on the far side.
|
||||
if was_up && self.phase == ConnectionPhase::Connected {
|
||||
effects.push(Effect::SilenceNotes);
|
||||
}
|
||||
|
||||
// A connection that stayed up has recovered, and the next outage starts from the short
|
||||
// delay. One that dropped quickly carries its backoff forward, so a flapping link slows
|
||||
// down instead of reconnecting as fast as it fails.
|
||||
if let Some(at) = self.connected_at.take() {
|
||||
if time::elapsed(at, now) >= STABLE_AFTER {
|
||||
self.backoff.reset();
|
||||
self.flaps = 0;
|
||||
} else {
|
||||
self.flaps = self.flaps.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
// A reason the user must act on is surfaced; one they cannot act on retries quietly.
|
||||
// Both keep retrying, because the user may clear the condition at any moment.
|
||||
let phase = if reason.needs_user_action() {
|
||||
ConnectionPhase::Unavailable
|
||||
} else {
|
||||
ConnectionPhase::Retrying
|
||||
};
|
||||
self.last_error = Some(reason);
|
||||
self.enter(phase, now);
|
||||
|
||||
let delay = self.backoff.next_delay(jitter);
|
||||
self.next_retry = Some(time::saturating_add(now, delay));
|
||||
effects.push(Effect::ScheduleRetry(delay));
|
||||
effects
|
||||
}
|
||||
|
||||
fn on_retry(&mut self, now: Timestamp, nudged: bool) -> Vec<Effect> {
|
||||
match self.phase {
|
||||
// Unavailable is included on purpose: it is re-evaluated, never abandoned.
|
||||
ConnectionPhase::Retrying
|
||||
| ConnectionPhase::Unavailable
|
||||
| ConnectionPhase::Disconnected => {}
|
||||
_ => return Vec::new(),
|
||||
}
|
||||
// A platform hint means conditions genuinely changed, so start again from the short delay
|
||||
// rather than making the user wait out a backoff that is no longer relevant.
|
||||
if nudged {
|
||||
self.backoff.reset();
|
||||
}
|
||||
self.next_retry = None;
|
||||
self.enter(ConnectionPhase::Connecting, now);
|
||||
vec![Effect::StartConnect]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::time::TestClock;
|
||||
|
||||
/// Drops a connection that has been up for `lasted` and returns the delay chosen for the
|
||||
/// retry, then brings it back up either by waiting that delay or, when `nudged`, at once.
|
||||
fn flap(
|
||||
state: &mut ConnectionState,
|
||||
clock: &TestClock,
|
||||
lasted: Duration,
|
||||
nudged: bool,
|
||||
) -> Duration {
|
||||
clock.advance(lasted);
|
||||
let _ = state.apply(Event::Lost(FailureReason::PeerTimeout), clock, 1.0);
|
||||
let delay = time::elapsed(
|
||||
clock.now(),
|
||||
state.next_retry().expect("a retry is scheduled"),
|
||||
);
|
||||
if nudged {
|
||||
let _ = state.apply(Event::Nudge, clock, 1.0);
|
||||
} else {
|
||||
clock.advance(delay);
|
||||
let _ = state.apply(Event::RetryDue, clock, 1.0);
|
||||
}
|
||||
let _ = state.apply(Event::Established, clock, 1.0);
|
||||
delay
|
||||
}
|
||||
|
||||
/// Each event moves the connection to the phase it means and asks the supervisor for exactly
|
||||
/// the effects it needs, in order.
|
||||
///
|
||||
/// Only a reconnection restores controller state; a first connection has nothing to restore.
|
||||
/// Losing an established link silences its notes before anything else, so nothing is left
|
||||
/// sounding on the far side. A failure the user must act on is surfaced as unavailable but
|
||||
/// still retried (Principle I), since they may fix it at any moment. Switching off is the
|
||||
/// user's decision and is final until they switch on again. The first retry delay with full
|
||||
/// jitter is the 250 ms base.
|
||||
#[test]
|
||||
fn each_event_asks_for_exactly_the_effects_it_needs() {
|
||||
let first_retry = Effect::ScheduleRetry(Duration::from_millis(250));
|
||||
let connect = || vec![Event::Attempting, Event::Established];
|
||||
let lost = || Event::Lost(FailureReason::PeerTimeout);
|
||||
let refused = || Event::Failed(FailureReason::AdapterUnavailable);
|
||||
let ignored = |event: Event| {
|
||||
(
|
||||
"a disabled endpoint ignores everything but being switched on",
|
||||
false,
|
||||
vec![],
|
||||
event,
|
||||
vec![],
|
||||
ConnectionPhase::Disabled,
|
||||
)
|
||||
};
|
||||
let cases = [
|
||||
(
|
||||
"a first connection restores nothing",
|
||||
true,
|
||||
vec![Event::Attempting],
|
||||
Event::Established,
|
||||
vec![],
|
||||
ConnectionPhase::Connected,
|
||||
),
|
||||
(
|
||||
"a reconnection restores controller state",
|
||||
true,
|
||||
[connect(), vec![lost(), Event::RetryDue]].concat(),
|
||||
Event::Established,
|
||||
vec![Effect::RestoreState],
|
||||
ConnectionPhase::Connected,
|
||||
),
|
||||
(
|
||||
"losing a link silences it before scheduling a retry",
|
||||
true,
|
||||
connect(),
|
||||
lost(),
|
||||
vec![Effect::SilenceNotes, first_retry.clone()],
|
||||
ConnectionPhase::Retrying,
|
||||
),
|
||||
(
|
||||
"a failure the user must act on is surfaced and still retried",
|
||||
true,
|
||||
vec![Event::Attempting],
|
||||
refused(),
|
||||
vec![first_retry],
|
||||
ConnectionPhase::Unavailable,
|
||||
),
|
||||
(
|
||||
"an unavailable endpoint is tried again when its retry falls due",
|
||||
true,
|
||||
vec![Event::Attempting, refused()],
|
||||
Event::RetryDue,
|
||||
vec![Effect::StartConnect],
|
||||
ConnectionPhase::Connecting,
|
||||
),
|
||||
(
|
||||
"switching off a connected endpoint silences it and cancels what is pending",
|
||||
true,
|
||||
connect(),
|
||||
Event::Disable,
|
||||
vec![Effect::SilenceNotes, Effect::CancelPending],
|
||||
ConnectionPhase::Disabled,
|
||||
),
|
||||
ignored(Event::Attempting),
|
||||
ignored(Event::Established),
|
||||
ignored(refused()),
|
||||
ignored(lost()),
|
||||
ignored(Event::RetryDue),
|
||||
ignored(Event::Nudge),
|
||||
(
|
||||
"switching on a disabled endpoint connects it",
|
||||
false,
|
||||
vec![],
|
||||
Event::Enable,
|
||||
vec![Effect::StartConnect],
|
||||
ConnectionPhase::Disconnected,
|
||||
),
|
||||
];
|
||||
for (case, enabled, before, event, want, phase) in cases {
|
||||
let clock = TestClock::at_epoch();
|
||||
let mut state = if enabled {
|
||||
ConnectionState::enabled(clock.now())
|
||||
} else {
|
||||
ConnectionState::disabled(clock.now())
|
||||
};
|
||||
for earlier in before {
|
||||
let _ = state.apply(earlier, &clock, 1.0);
|
||||
}
|
||||
let applied = format!("{event:?}");
|
||||
assert_eq!(
|
||||
state.apply(event, &clock, 1.0),
|
||||
want,
|
||||
"{case}: {applied} must ask for exactly these effects"
|
||||
);
|
||||
assert_eq!(
|
||||
state.phase(),
|
||||
phase,
|
||||
"{case}: {applied} must leave the connection in this phase"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The retry delay resets only after a connection holds for `STABLE_AFTER`, or when the
|
||||
/// platform says conditions changed.
|
||||
///
|
||||
/// Resetting on every brief connection kept the delay at its shortest forever, so a link that
|
||||
/// came up and dropped at once was retried four times a second. Brief connections instead
|
||||
/// carry the backoff forward: 250 ms, then 500 ms, then 1 s. One that holds 10 s has
|
||||
/// recovered and starts again from 250 ms. A wake or network change (a nudge) starts again
|
||||
/// from 250 ms too, because the backoff it would wait out is no longer relevant.
|
||||
#[test]
|
||||
fn the_backoff_resets_only_when_a_connection_holds_or_conditions_change() {
|
||||
let ms = Duration::from_millis;
|
||||
let steps = [
|
||||
("a first brief connection drops", ms(1_000), false, ms(250)),
|
||||
("a second brief connection drops", ms(1_000), false, ms(500)),
|
||||
(
|
||||
"a third brief connection drops",
|
||||
ms(1_000),
|
||||
false,
|
||||
ms(1_000),
|
||||
),
|
||||
("a connection that held drops", STABLE_AFTER, false, ms(250)),
|
||||
(
|
||||
"a brief connection drops, then a nudge",
|
||||
ms(200),
|
||||
true,
|
||||
ms(500),
|
||||
),
|
||||
(
|
||||
"a brief connection after the nudge drops",
|
||||
ms(200),
|
||||
false,
|
||||
ms(250),
|
||||
),
|
||||
];
|
||||
let clock = TestClock::at_epoch();
|
||||
let mut state = ConnectionState::enabled(clock.now());
|
||||
let _ = state.apply(Event::Attempting, &clock, 1.0);
|
||||
let _ = state.apply(Event::Established, &clock, 1.0);
|
||||
for (step, lasted, nudged, want) in steps {
|
||||
assert_eq!(
|
||||
flap(&mut state, &clock, lasted, nudged),
|
||||
want,
|
||||
"{step}: the delay must grow across brief connections and reset only when earned"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A link is called unstable after `UNSTABLE_AFTER` brief connections in a row, and stable
|
||||
/// again as soon as one holds for `STABLE_AFTER`.
|
||||
///
|
||||
/// The backoff already keeps such a link from reconnecting as fast as it drops; this is what
|
||||
/// tells the user why it keeps going quiet. A connection that has held is stable even before
|
||||
/// it next drops, and a drop after it starts the count again.
|
||||
#[test]
|
||||
fn a_flapping_link_says_so_until_a_connection_holds() {
|
||||
let clock = TestClock::at_epoch();
|
||||
let mut state = ConnectionState::enabled(clock.now());
|
||||
let _ = state.apply(Event::Attempting, &clock, 1.0);
|
||||
let _ = state.apply(Event::Established, &clock, 1.0);
|
||||
for flaps in 1..UNSTABLE_AFTER {
|
||||
let _ = flap(&mut state, &clock, Duration::from_secs(1), false);
|
||||
assert!(
|
||||
!state.is_unstable(clock.now()),
|
||||
"{flaps} brief connections must not yet call the link unstable"
|
||||
);
|
||||
}
|
||||
let _ = flap(&mut state, &clock, Duration::from_secs(1), false);
|
||||
assert!(
|
||||
state.is_unstable(clock.now()),
|
||||
"{UNSTABLE_AFTER} brief connections in a row must call the link unstable"
|
||||
);
|
||||
|
||||
clock.advance(STABLE_AFTER);
|
||||
assert!(
|
||||
!state.is_unstable(clock.now()),
|
||||
"a connection that has held must read as stable before it next drops"
|
||||
);
|
||||
let _ = flap(&mut state, &clock, Duration::ZERO, false);
|
||||
assert!(
|
||||
!state.is_unstable(clock.now()),
|
||||
"a drop after a connection held must start the count again"
|
||||
);
|
||||
}
|
||||
|
||||
/// No sequence of events leaves an enabled endpoint stranded: it is connected, on its way
|
||||
/// there, or has a retry scheduled.
|
||||
///
|
||||
/// Principle I stated as a property. Five hundred rounds cycle attempts, failures of every
|
||||
/// kind the user may or may not be able to act on, retries, connections and losses, with the
|
||||
/// clock moving 100 ms between each, and after every one the phase must be one the machine
|
||||
/// leaves on its own.
|
||||
#[test]
|
||||
fn no_event_sequence_strands_an_enabled_endpoint() {
|
||||
let clock = TestClock::at_epoch();
|
||||
let reasons = [
|
||||
FailureReason::NetworkUnreachable,
|
||||
FailureReason::PeerTimeout,
|
||||
FailureReason::PeerRejected,
|
||||
FailureReason::DeviceRemoved,
|
||||
FailureReason::AdapterUnavailable,
|
||||
FailureReason::PermissionDenied {
|
||||
what: "bluetooth".to_owned(),
|
||||
},
|
||||
FailureReason::ResourceLimit,
|
||||
FailureReason::ConfigInvalid {
|
||||
detail: "bad port".to_owned(),
|
||||
},
|
||||
];
|
||||
let mut state = ConnectionState::enabled(clock.now());
|
||||
|
||||
for round in 0..500usize {
|
||||
let reason = reasons[round % reasons.len()].clone();
|
||||
let event = match round % 5 {
|
||||
0 => Event::Attempting,
|
||||
1 => Event::Failed(reason),
|
||||
2 => Event::RetryDue,
|
||||
3 => Event::Established,
|
||||
_ => Event::Lost(FailureReason::PeerTimeout),
|
||||
};
|
||||
let _ = state.apply(event, &clock, 0.5);
|
||||
clock.advance(Duration::from_millis(100));
|
||||
|
||||
assert!(
|
||||
state.phase().recovers_on_its_own(),
|
||||
"round {round}: {:?} is a phase an enabled endpoint cannot leave",
|
||||
state.phase()
|
||||
);
|
||||
if matches!(
|
||||
state.phase(),
|
||||
ConnectionPhase::Retrying | ConnectionPhase::Unavailable
|
||||
) {
|
||||
assert!(
|
||||
state.next_retry().is_some(),
|
||||
"round {round}: down in {:?} with no retry scheduled leaves it stranded",
|
||||
state.phase()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
326
crates/core/src/stream.rs
Normal file
326
crates/core/src/stream.rs
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
//! Splitting a run of raw MIDI bytes into whole messages and system-exclusive runs.
|
||||
//!
|
||||
//! Every platform backend receives bytes rather than messages, and every one of them has to solve
|
||||
//! the same three problems: running status, system-exclusive messages that span several reads, and
|
||||
//! real-time bytes that may appear in the middle of one. Solving them here means they are solved
|
||||
//! once and can be tested on a machine with no MIDI hardware.
|
||||
//!
|
||||
//! Nothing in this module allocates, locks, or can panic, because it runs inside platform read
|
||||
//! callbacks where none of those are permitted.
|
||||
|
||||
use crate::midi::MidiMessage;
|
||||
|
||||
/// How a run of system-exclusive bytes ends.
|
||||
///
|
||||
/// The distinction matters because an abandoned message must never be forwarded: a dump cut short
|
||||
/// still looks like valid framing to a receiver, which will act on whatever arrived.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SysExEnd {
|
||||
/// More bytes of this message are still to come.
|
||||
Open,
|
||||
/// The terminator arrived, so the message is whole.
|
||||
Complete,
|
||||
/// The sender started something else mid-message, so what arrived is unusable.
|
||||
Abandoned,
|
||||
}
|
||||
|
||||
/// One thing found in a run of raw MIDI bytes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Chunk<'a> {
|
||||
/// A complete channel, system common or real-time message.
|
||||
Message(MidiMessage),
|
||||
/// Part or all of a system-exclusive message, framing bytes included.
|
||||
SysEx {
|
||||
/// The bytes, including `0xF0` on the first run and `0xF7` on the last.
|
||||
bytes: &'a [u8],
|
||||
/// Whether more is to come.
|
||||
end: SysExEnd,
|
||||
},
|
||||
}
|
||||
|
||||
/// Splits raw MIDI bytes into messages, carrying the state that spans reads.
|
||||
///
|
||||
/// One scanner belongs to one source. Running status and an unfinished system-exclusive message
|
||||
/// both continue across reads, so a scanner shared between two sources would splice one device's
|
||||
/// dump onto another's.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Scanner {
|
||||
/// The status byte a data-only message inherits.
|
||||
running: Option<u8>,
|
||||
/// Whether a system-exclusive message is still open.
|
||||
in_sysex: bool,
|
||||
}
|
||||
|
||||
impl Scanner {
|
||||
/// Creates a scanner with no message in progress.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Splits `data` into chunks, handing each to `emit` in the order it appeared.
|
||||
///
|
||||
/// Emits through a closure rather than returning a collection because this runs on the
|
||||
/// real-time path, where a returned `Vec` would mean allocating in a read callback.
|
||||
pub fn scan<'a>(&mut self, data: &'a [u8], emit: &mut impl FnMut(Chunk<'a>)) {
|
||||
let mut index = 0;
|
||||
|
||||
while index < data.len() {
|
||||
let Some(byte) = data.get(index).copied() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Continue or begin a system-exclusive message.
|
||||
if self.in_sysex || byte == 0xF0 {
|
||||
if byte == 0xF0 {
|
||||
// A dump that is still open when another begins was abandoned, and saying so
|
||||
// is what stops a consumer gluing the two together.
|
||||
if self.in_sysex {
|
||||
emit(Chunk::SysEx {
|
||||
bytes: &[],
|
||||
end: SysExEnd::Abandoned,
|
||||
});
|
||||
}
|
||||
// System common clears running status, and this is the loudest case of it.
|
||||
self.running = None;
|
||||
self.in_sysex = true;
|
||||
}
|
||||
let resume = self.sysex_run(data, index, emit);
|
||||
if resume <= index {
|
||||
return;
|
||||
}
|
||||
index = resume;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Real-time bytes may appear anywhere and belong to no other message, so they never
|
||||
// touch running status.
|
||||
if byte >= 0xF8 {
|
||||
emit(Chunk::Message(MidiMessage::System { status: byte }));
|
||||
index = index.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A terminator with nothing open is the tail of a message whose start was lost. It
|
||||
// frames nothing, so passing it on would only confuse a receiver.
|
||||
if byte == 0xF7 {
|
||||
index = index.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(rest) = data.get(index..) else {
|
||||
return;
|
||||
};
|
||||
let Some((message, consumed)) = MidiMessage::parse(rest, self.running) else {
|
||||
// A status byte before this message was complete abandons it, and the status
|
||||
// starts the next one. Anything else is an incomplete tail or data with no status
|
||||
// to inherit, and the rest of this read cannot be trusted.
|
||||
let Some(next) = rest.iter().skip(1).position(|byte| *byte >= 0x80) else {
|
||||
return;
|
||||
};
|
||||
index = index.saturating_add(next).saturating_add(1);
|
||||
continue;
|
||||
};
|
||||
if byte >= 0x80 {
|
||||
self.running = (message.status() < 0xF0).then_some(message.status());
|
||||
}
|
||||
emit(Chunk::Message(message));
|
||||
if consumed == 0 {
|
||||
return;
|
||||
}
|
||||
index = index.saturating_add(consumed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits one run of system-exclusive bytes, returning where scanning resumes.
|
||||
///
|
||||
/// `start` points either at the `0xF0` that opens a message or at the first payload byte of a
|
||||
/// message already in progress.
|
||||
fn sysex_run<'a>(
|
||||
&mut self,
|
||||
data: &'a [u8],
|
||||
start: usize,
|
||||
emit: &mut impl FnMut(Chunk<'a>),
|
||||
) -> usize {
|
||||
// Find where the payload stops, which is the first byte that is a status of any kind.
|
||||
let opens = data.get(start).copied() == Some(0xF0);
|
||||
let mut cursor = if opens {
|
||||
start.saturating_add(1)
|
||||
} else {
|
||||
start
|
||||
};
|
||||
while data.get(cursor).is_some_and(|byte| *byte < 0x80) {
|
||||
cursor = cursor.saturating_add(1);
|
||||
}
|
||||
|
||||
match data.get(cursor).copied() {
|
||||
// The read ended mid-message, which is ordinary for a dump of any size.
|
||||
None => {
|
||||
emit_run(data, start, cursor, SysExEnd::Open, emit);
|
||||
cursor
|
||||
}
|
||||
// The terminator belongs to the message, so it travels with it.
|
||||
Some(0xF7) => {
|
||||
let end = cursor.saturating_add(1);
|
||||
emit_run(data, start, end, SysExEnd::Complete, emit);
|
||||
self.in_sysex = false;
|
||||
end
|
||||
}
|
||||
// A real-time byte may interrupt a dump without ending it.
|
||||
Some(status) if status >= 0xF8 => {
|
||||
emit_run(data, start, cursor, SysExEnd::Open, emit);
|
||||
emit(Chunk::Message(MidiMessage::System { status }));
|
||||
cursor.saturating_add(1)
|
||||
}
|
||||
// Anything else means the sender moved on and this dump will never be completed.
|
||||
Some(_) => {
|
||||
emit_run(data, start, cursor, SysExEnd::Abandoned, emit);
|
||||
self.in_sysex = false;
|
||||
cursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits one slice of system-exclusive bytes, skipping runs that say nothing.
|
||||
///
|
||||
/// An empty run still has to be reported when it ends a message, because that is how a consumer
|
||||
/// learns to stop waiting or to discard what it has.
|
||||
fn emit_run<'a>(
|
||||
data: &'a [u8],
|
||||
start: usize,
|
||||
end: usize,
|
||||
kind: SysExEnd,
|
||||
emit: &mut impl FnMut(Chunk<'a>),
|
||||
) {
|
||||
let bytes = data.get(start..end).unwrap_or_default();
|
||||
if bytes.is_empty() && kind == SysExEnd::Open {
|
||||
return;
|
||||
}
|
||||
emit(Chunk::SysEx { bytes, end: kind });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::indexing_slicing, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::midi::Channel;
|
||||
|
||||
/// Collects everything a scan emits, copying system-exclusive runs so they outlive the input.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Owned {
|
||||
Message(MidiMessage),
|
||||
SysEx(Vec<u8>, SysExEnd),
|
||||
}
|
||||
|
||||
fn scan(scanner: &mut Scanner, data: &[u8]) -> Vec<Owned> {
|
||||
let mut out = Vec::new();
|
||||
scanner.scan(data, &mut |chunk| match chunk {
|
||||
Chunk::Message(message) => out.push(Owned::Message(message)),
|
||||
Chunk::SysEx { bytes, end } => out.push(Owned::SysEx(bytes.to_vec(), end)),
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn note_on(note: u8) -> Owned {
|
||||
Owned::Message(MidiMessage::NoteOn {
|
||||
channel: Channel::new(0).expect("channel 0"),
|
||||
note,
|
||||
velocity: 100,
|
||||
})
|
||||
}
|
||||
|
||||
/// Raw bytes split across reads come out as the messages MIDI 1.0 says they are.
|
||||
///
|
||||
/// A data byte inherits the last channel status, across reads, but system common clears it,
|
||||
/// and a dump is the loudest case of that. A terminator with nothing open is the tail of a
|
||||
/// dump whose start was lost and frames nothing. A dump begun while another is open abandons
|
||||
/// the first, which must be said or a consumer glues the two together. A status arriving
|
||||
/// before a message is complete abandons that message and starts the next. An empty read in
|
||||
/// the middle of a dump neither ends it nor loses its place. The dump cases the daemon
|
||||
/// forwards, whole, split, interrupted by clock and abandoned, are proved end to end in
|
||||
/// `crates/daemon/tests/sysex.rs`.
|
||||
#[test]
|
||||
fn raw_bytes_split_into_the_messages_they_are() {
|
||||
type Reads = &'static [&'static [u8]];
|
||||
let cases: [(&str, Reads, Vec<Owned>); 7] = [
|
||||
(
|
||||
"running status carries across reads",
|
||||
&[&[0x90, 0x3C, 0x64], &[0x3E, 0x64]],
|
||||
vec![note_on(0x3C), note_on(0x3E)],
|
||||
),
|
||||
(
|
||||
"a dump clears running status",
|
||||
&[&[0x90, 0x3C, 0x64], &[0xF0, 0x7E, 0xF7], &[0x40, 0x64]],
|
||||
vec![
|
||||
note_on(0x3C),
|
||||
Owned::SysEx(vec![0xF0, 0x7E, 0xF7], SysExEnd::Complete),
|
||||
],
|
||||
),
|
||||
(
|
||||
"a terminator with nothing open is dropped",
|
||||
&[&[0xF7, 0x90, 0x3C, 0x64]],
|
||||
vec![note_on(0x3C)],
|
||||
),
|
||||
(
|
||||
"a dump begun while another is open abandons the first",
|
||||
&[&[0xF0, 0x01], &[0xF0, 0x02, 0xF7]],
|
||||
vec![
|
||||
Owned::SysEx(vec![0xF0, 0x01], SysExEnd::Open),
|
||||
Owned::SysEx(vec![], SysExEnd::Abandoned),
|
||||
Owned::SysEx(vec![0xF0, 0x02, 0xF7], SysExEnd::Complete),
|
||||
],
|
||||
),
|
||||
(
|
||||
"a status that interrupts a message starts the next one",
|
||||
&[&[0x90, 0x3C, 0x80, 0x3C, 0x00]],
|
||||
vec![Owned::Message(MidiMessage::NoteOff {
|
||||
channel: Channel::new(0).expect("channel 0"),
|
||||
note: 0x3C,
|
||||
velocity: 0,
|
||||
})],
|
||||
),
|
||||
(
|
||||
"a song position keeps both data bytes",
|
||||
&[&[0xF2, 0x10, 0x20]],
|
||||
vec![Owned::Message(MidiMessage::SystemCommon {
|
||||
status: 0xF2,
|
||||
data: [0x10, 0x20],
|
||||
})],
|
||||
),
|
||||
(
|
||||
"an empty read neither ends a dump nor loses its place",
|
||||
&[&[], &[0xF0, 0x43], &[], &[0x10, 0xF7]],
|
||||
vec![
|
||||
Owned::SysEx(vec![0xF0, 0x43], SysExEnd::Open),
|
||||
Owned::SysEx(vec![0x10, 0xF7], SysExEnd::Complete),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (case, reads, want) in cases {
|
||||
let mut scanner = Scanner::new();
|
||||
let got: Vec<Owned> = reads
|
||||
.iter()
|
||||
.flat_map(|read| scan(&mut scanner, read))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
got, want,
|
||||
"{case}: the bytes must come out as the messages they are"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every pair of bytes, repeated, scans to an end without panicking or stalling.
|
||||
///
|
||||
/// Device input is hostile, and this scanner runs inside platform read callbacks where a
|
||||
/// panic takes down the MIDI system's thread. No fuzz target reaches it, so all 65,536 byte
|
||||
/// pairs stand in for one.
|
||||
#[test]
|
||||
fn hostile_input_neither_panics_nor_stalls() {
|
||||
let mut scanner = Scanner::new();
|
||||
for first in 0u8..=255 {
|
||||
for second in 0u8..=255 {
|
||||
let _ = scan(&mut scanner, &[first, second, first, second]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
crates/core/src/time.rs
Normal file
91
crates/core/src/time.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
//! Clock injection, so state machines advance without waiting on wall-clock time.
|
||||
|
||||
use jiff::{SignedDuration, Timestamp};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Nanoseconds in one second.
|
||||
const NANOS_PER_SECOND: u128 = 1_000_000_000;
|
||||
|
||||
/// Supplies the current time to logic that needs to stamp or schedule.
|
||||
///
|
||||
/// Injected everywhere rather than read from the system, so tests can drive a state machine
|
||||
/// through hours of behaviour instantly and deterministically.
|
||||
pub trait Clock: Send + Sync + 'static {
|
||||
/// Returns the current wall-clock time.
|
||||
fn now(&self) -> Timestamp;
|
||||
}
|
||||
|
||||
/// Reads the real system clock.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct SystemClock;
|
||||
|
||||
impl Clock for SystemClock {
|
||||
fn now(&self) -> Timestamp {
|
||||
Timestamp::now()
|
||||
}
|
||||
}
|
||||
|
||||
/// A clock that only moves when a test moves it.
|
||||
pub struct TestClock {
|
||||
now: Mutex<Timestamp>,
|
||||
}
|
||||
|
||||
impl TestClock {
|
||||
/// Creates a clock fixed at the given instant.
|
||||
pub fn new(start: Timestamp) -> Self {
|
||||
Self {
|
||||
now: Mutex::new(start),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a clock fixed at the Unix epoch, which is enough for most tests.
|
||||
pub fn at_epoch() -> Self {
|
||||
Self::new(Timestamp::UNIX_EPOCH)
|
||||
}
|
||||
|
||||
/// Moves the clock forward by the given amount.
|
||||
pub fn advance(&self, by: Duration) {
|
||||
let step = SignedDuration::try_from(by).unwrap_or(SignedDuration::MAX);
|
||||
if let Ok(mut guard) = self.now.lock() {
|
||||
*guard = guard.checked_add(step).unwrap_or(Timestamp::MAX);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TestClock {
|
||||
fn default() -> Self {
|
||||
Self::at_epoch()
|
||||
}
|
||||
}
|
||||
|
||||
impl Clock for TestClock {
|
||||
fn now(&self) -> Timestamp {
|
||||
// A poisoned lock means a test panicked while holding it. Reporting the epoch keeps the
|
||||
// failing test's output readable instead of cascading a second panic.
|
||||
self.now.lock().map(|t| *t).unwrap_or(Timestamp::UNIX_EPOCH)
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a duration to a timestamp, saturating at the representable maximum.
|
||||
pub fn saturating_add(at: Timestamp, delta: Duration) -> Timestamp {
|
||||
let step = SignedDuration::try_from(delta).unwrap_or(SignedDuration::MAX);
|
||||
at.checked_add(step).unwrap_or(Timestamp::MAX)
|
||||
}
|
||||
|
||||
/// Returns how long has elapsed between two timestamps, or zero if `later` precedes `earlier`.
|
||||
pub fn elapsed(earlier: Timestamp, later: Timestamp) -> Duration {
|
||||
let delta = later
|
||||
.as_nanosecond()
|
||||
.saturating_sub(earlier.as_nanosecond());
|
||||
if delta <= 0 {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
let nanos = u128::try_from(delta).unwrap_or(0);
|
||||
// Splitting nanoseconds into whole seconds and a remainder is exactly what integer division
|
||||
// is for here; the divisor is a constant and cannot be zero.
|
||||
#[allow(clippy::integer_division)]
|
||||
let secs = u64::try_from(nanos / NANOS_PER_SECOND).unwrap_or(u64::MAX);
|
||||
let rem = u32::try_from(nanos % NANOS_PER_SECOND).unwrap_or(0);
|
||||
Duration::new(secs, rem)
|
||||
}
|
||||
36
crates/daemon/Cargo.toml
Normal file
36
crates/daemon/Cargo.toml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
[package]
|
||||
name = "midi-harbor-daemon"
|
||||
description = "The engine that owns every endpoint and connection"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde_json.workspace = true
|
||||
gethostname.workspace = true
|
||||
rand.workspace = true
|
||||
socket2.workspace = true
|
||||
mdns-sd.workspace = true
|
||||
if-addrs.workspace = true
|
||||
midi-harbor-blemidi.workspace = true
|
||||
midi-harbor-core.workspace = true
|
||||
midi-harbor-ipc.workspace = true
|
||||
midi-harbor-platform.workspace = true
|
||||
midi-harbor-rtpmidi.workspace = true
|
||||
midi-harbor-service.workspace = true
|
||||
jiff.workspace = true
|
||||
rtrb.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
tonic.workspace = true
|
||||
prost-types.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
midi-harbor-platform.workspace = true
|
||||
uuid.workspace = true
|
||||
43
crates/daemon/examples/discover.rs
Normal file
43
crates/daemon/examples/discover.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
//! Browses the local network for RTP-MIDI peers and advertises this machine.
|
||||
//!
|
||||
//! Run with `cargo run -p midi-harbor-daemon --example discover`.
|
||||
|
||||
use midi_harbor_daemon::discovery::Discovery;
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() {
|
||||
let discovery = match Discovery::start() {
|
||||
Ok(discovery) => discovery,
|
||||
Err(error) => {
|
||||
eprintln!("could not start discovery: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Advertise on a port nothing else is using, so this cannot disturb a real session.
|
||||
if let Err(error) = discovery.advertise("Midi Harbor Example", 5104) {
|
||||
eprintln!("could not advertise: {error}");
|
||||
}
|
||||
|
||||
println!(
|
||||
"browsing for {} ...",
|
||||
midi_harbor_daemon::discovery::SERVICE_TYPE
|
||||
);
|
||||
std::thread::sleep(Duration::from_secs(6));
|
||||
|
||||
let peers = discovery.peers();
|
||||
if peers.is_empty() {
|
||||
println!("no peers found");
|
||||
}
|
||||
for (peer, label) in &peers {
|
||||
match peer.address() {
|
||||
Some(address) => println!(" {label} -> {address}"),
|
||||
None => println!(" {label} -> no routable address"),
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{} peer(s) offered, own advertisement excluded",
|
||||
peers.len()
|
||||
);
|
||||
discovery.withdraw("Midi Harbor Example");
|
||||
}
|
||||
191
crates/daemon/src/automatic.rs
Normal file
191
crates/daemon/src/automatic.rs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
//! The automatic port a network port shows to other applications on this computer (FR-015h).
|
||||
//!
|
||||
//! It is the network port's own, the way macOS presents its network sessions: MIDI another
|
||||
//! application sends it goes out over the network, and MIDI arriving over the network comes out
|
||||
//! of it as well as going wherever the network port is routed. It is not an endpoint in its own
|
||||
//! right, so no route names it and nothing lists it apart from the network port.
|
||||
|
||||
use crate::dataplane;
|
||||
use crate::state::{Daemon, Runtime};
|
||||
use midi_harbor_core::config;
|
||||
use midi_harbor_core::endpoint::{Endpoint, EndpointKind};
|
||||
use midi_harbor_core::ids::EndpointId;
|
||||
use midi_harbor_core::state::{ConnectionState, Event};
|
||||
use midi_harbor_platform::midi::{PortHandle, VirtualPortSpec};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Returns the identifier the automatic port of a network port carries in the data path.
|
||||
///
|
||||
/// Derived rather than stored, so it is the same on every run and needs no place in the file.
|
||||
pub fn port_id(session: EndpointId) -> EndpointId {
|
||||
session.derived("automatic port")
|
||||
}
|
||||
|
||||
/// A network port's automatic port while it is open.
|
||||
pub struct AutomaticPort {
|
||||
/// The network port it belongs to.
|
||||
pub session: EndpointId,
|
||||
/// Its platform handle, counters and the notes sounding on it.
|
||||
pub runtime: Runtime,
|
||||
}
|
||||
|
||||
impl Daemon {
|
||||
/// Opens a network port's automatic port, when it has one switched on and it is not open.
|
||||
pub(crate) async fn open_automatic_port(self: &Arc<Self>, session: EndpointId) {
|
||||
let id = port_id(session);
|
||||
|
||||
// Decide whether it is wanted.
|
||||
let wanted = {
|
||||
let inner = self.inner.read().await;
|
||||
if inner.automatic_ports.contains_key(&id) {
|
||||
return;
|
||||
}
|
||||
inner
|
||||
.config
|
||||
.endpoint(session)
|
||||
.and_then(|endpoint| match &endpoint.kind {
|
||||
EndpointKind::NetworkSession(held)
|
||||
if held.automatic_port && endpoint.enabled =>
|
||||
{
|
||||
Some(VirtualPortSpec {
|
||||
name: endpoint.name.as_str().to_owned(),
|
||||
inputs: 1,
|
||||
outputs: 1,
|
||||
pinned_inputs: held.port_input_id.into_iter().collect(),
|
||||
pinned_outputs: held.port_output_id.into_iter().collect(),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let Some(spec) = wanted else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Create it on the platform, with nothing held.
|
||||
let (producer, consumer) = dataplane::connector_channel(id, 0);
|
||||
let (handle, ids) = match self.midi.create_virtual_port(&spec, vec![producer]) {
|
||||
Ok(created) => created,
|
||||
Err(error) => {
|
||||
warn!(port = %spec.name, error = %error, "could not open a network port's automatic port");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Keep it, unless another start opened one meanwhile.
|
||||
let mut inner = self.inner.write().await;
|
||||
if inner.automatic_ports.contains_key(&id) {
|
||||
drop(inner);
|
||||
let _ = self.midi.destroy_virtual_port(handle);
|
||||
return;
|
||||
}
|
||||
let now = self.clock.now();
|
||||
let mut state = ConnectionState::enabled(now);
|
||||
let _ = state.apply_now(Event::Attempting, self.clock.as_ref());
|
||||
let _ = state.apply_now(Event::Established, self.clock.as_ref());
|
||||
let _ = inner.automatic_ports.insert(
|
||||
id,
|
||||
AutomaticPort {
|
||||
session,
|
||||
runtime: Runtime::new(state, Some(handle)),
|
||||
},
|
||||
);
|
||||
// The identifiers are kept so other applications see the same port after a restart.
|
||||
if remember_ids(
|
||||
&mut inner.config.endpoints,
|
||||
session,
|
||||
&ids.inputs,
|
||||
&ids.outputs,
|
||||
) && let Err(error) = config::save(&self.paths, &inner.config)
|
||||
{
|
||||
warn!(error = %error, "could not persist an automatic port's identifiers; it may change identity on restart");
|
||||
}
|
||||
drop(inner);
|
||||
self.start_dispatch(consumer);
|
||||
info!(port = %spec.name, "automatic port opened");
|
||||
}
|
||||
|
||||
/// Closes a network port's automatic port, stopping what it has sounding first.
|
||||
pub(crate) async fn close_automatic_port(&self, session: EndpointId) {
|
||||
let id = port_id(session);
|
||||
// Silenced while it is still open, since afterwards nothing reaches the applications
|
||||
// listening to it.
|
||||
self.silence_endpoint(id).await;
|
||||
let Some(port) = self.inner.write().await.automatic_ports.remove(&id) else {
|
||||
return;
|
||||
};
|
||||
if let Some(handle) = port.runtime.handle
|
||||
&& let Err(error) = self.midi.destroy_virtual_port(handle)
|
||||
{
|
||||
debug!(error = %error, "could not remove an automatic port");
|
||||
}
|
||||
info!(session = %session, "automatic port closed");
|
||||
}
|
||||
|
||||
/// Opens or closes a network port's automatic port to match its configuration, and opens it
|
||||
/// again under a new name after a rename.
|
||||
pub(crate) async fn settle_automatic_port(
|
||||
self: &Arc<Self>,
|
||||
endpoint: &Endpoint,
|
||||
renamed: bool,
|
||||
) {
|
||||
let EndpointKind::NetworkSession(session) = &endpoint.kind else {
|
||||
return;
|
||||
};
|
||||
let running = self.sessions.read().await.contains_key(&endpoint.id);
|
||||
if renamed || !session.automatic_port || !running {
|
||||
self.close_automatic_port(endpoint.id).await;
|
||||
}
|
||||
if session.automatic_port && running {
|
||||
self.open_automatic_port(endpoint.id).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the network port an automatic port belongs to, when `id` is one.
|
||||
pub(crate) async fn automatic_port_owner(&self, id: EndpointId) -> Option<EndpointId> {
|
||||
let inner = self.inner.read().await;
|
||||
inner.automatic_ports.get(&id).map(|port| port.session)
|
||||
}
|
||||
|
||||
/// Returns a network port's open automatic port, with its platform handle.
|
||||
pub(crate) async fn automatic_port_of(
|
||||
&self,
|
||||
session: EndpointId,
|
||||
) -> Option<(EndpointId, PortHandle)> {
|
||||
let id = port_id(session);
|
||||
let inner = self.inner.read().await;
|
||||
inner
|
||||
.automatic_ports
|
||||
.get(&id)
|
||||
.and_then(|port| port.runtime.handle)
|
||||
.map(|handle| (id, handle))
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the platform identifiers an automatic port was given, reporting whether any changed.
|
||||
fn remember_ids(
|
||||
endpoints: &mut [Endpoint],
|
||||
session: EndpointId,
|
||||
inputs: &[u32],
|
||||
outputs: &[u32],
|
||||
) -> bool {
|
||||
let Some(Endpoint {
|
||||
kind: EndpointKind::NetworkSession(held),
|
||||
..
|
||||
}) = endpoints.iter_mut().find(|endpoint| endpoint.id == session)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let (input, output) = (inputs.first().copied(), outputs.first().copied());
|
||||
let mut changed = false;
|
||||
if input.is_some() && held.port_input_id != input {
|
||||
held.port_input_id = input;
|
||||
changed = true;
|
||||
}
|
||||
if output.is_some() && held.port_output_id != output {
|
||||
held.port_output_id = output;
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
1157
crates/daemon/src/bluetooth.rs
Normal file
1157
crates/daemon/src/bluetooth.rs
Normal file
File diff suppressed because it is too large
Load diff
326
crates/daemon/src/dataplane.rs
Normal file
326
crates/daemon/src/dataplane.rs
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
//! The path MIDI bytes actually travel.
|
||||
//!
|
||||
//! The ring buffers themselves live in `midi-harbor-core` so the platform layer can hold a
|
||||
//! producer. What lives here is the consuming side: draining those rings and fanning messages out
|
||||
//! along the route graph.
|
||||
//!
|
||||
//! Buffers are sized once at setup. When one fills, the event is dropped and counted — never
|
||||
//! queued by growing the buffer, because a buffer that grows under load is a buffer that
|
||||
//! allocates on the real-time path.
|
||||
|
||||
use midi_harbor_core::counters::TrafficCounters;
|
||||
use midi_harbor_core::stream::SysExEnd;
|
||||
|
||||
use midi_harbor_core::ids::EndpointId;
|
||||
use midi_harbor_core::rtchannel::{self, Doorbell};
|
||||
pub use midi_harbor_core::rtchannel::{Drained, RtConsumer, RtProducer};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long the doorbell's listener waits before looking again when nothing rings.
|
||||
///
|
||||
/// Only a backstop: every push rings, so this bounds nothing on the data path.
|
||||
const DOORBELL_PATIENCE: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Rung by every endpoint's producer when something arrives.
|
||||
static DOORBELL: LazyLock<Arc<Doorbell>> = LazyLock::new(|| Arc::new(Doorbell::new()));
|
||||
|
||||
/// Wakes the drain tasks when the doorbell rings.
|
||||
///
|
||||
/// A real-time callback cannot reach this directly, because waking a task takes a lock. A plain
|
||||
/// thread listens to the doorbell, which the callback can ring, and passes the news on here.
|
||||
static ARRIVED: LazyLock<Arc<tokio::sync::Notify>> = LazyLock::new(|| {
|
||||
let arrived = Arc::new(tokio::sync::Notify::new());
|
||||
let notify = Arc::clone(&arrived);
|
||||
let listening = std::thread::Builder::new()
|
||||
.name("harbor-doorbell".to_owned())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
if DOORBELL.wait(DOORBELL_PATIENCE) {
|
||||
notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(error) = listening {
|
||||
tracing::error!(error = %error, "could not start the doorbell listener; midi will wait for the fallback check");
|
||||
}
|
||||
arrived
|
||||
});
|
||||
|
||||
/// Creates the two halves of one endpoint's data path, wired to wake the drain when MIDI arrives.
|
||||
pub fn channel(source: EndpointId) -> (RtProducer, RtConsumer) {
|
||||
connector_channel(source, 0)
|
||||
}
|
||||
|
||||
/// Creates the data path for one of an endpoint's MIDI In connectors, counting from zero, wired to
|
||||
/// wake the drain when MIDI arrives.
|
||||
pub fn connector_channel(source: EndpointId, connector: u8) -> (RtProducer, RtConsumer) {
|
||||
LazyLock::force(&ARRIVED);
|
||||
rtchannel::connector_channel(source, connector, Arc::clone(&DOORBELL))
|
||||
}
|
||||
|
||||
/// Returns what wakes the drain tasks when MIDI arrives on any endpoint.
|
||||
pub fn arrived() -> Arc<tokio::sync::Notify> {
|
||||
Arc::clone(&ARRIVED)
|
||||
}
|
||||
|
||||
/// The largest system-exclusive message that will be rebuilt.
|
||||
///
|
||||
/// Comfortably larger than a full patch bank from any instrument, and bounded so a source that
|
||||
/// never sends a terminator cannot grow this buffer without limit.
|
||||
pub const MAX_SYSEX_BYTES: usize = 262_144;
|
||||
|
||||
/// Rebuilds whole system-exclusive messages from the runs the data path delivers.
|
||||
///
|
||||
/// A dump arrives in as many pieces as the platform felt like splitting it into. It is rebuilt
|
||||
/// rather than forwarded piecemeal because a partial dump still frames correctly to a receiver,
|
||||
/// which will act on whatever arrived — so nothing is forwarded until the terminator says the
|
||||
/// message is whole.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SysExAssembler {
|
||||
buffer: Vec<u8>,
|
||||
/// Set when the message outgrew the limit, so the remainder is read and thrown away rather
|
||||
/// than leaving the next message spliced onto this one.
|
||||
overflowed: bool,
|
||||
discarded: u64,
|
||||
}
|
||||
|
||||
impl SysExAssembler {
|
||||
/// Creates an assembler with nothing in progress.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Adds a run, returning the message once it is whole.
|
||||
pub fn push(&mut self, bytes: &[u8], end: SysExEnd) -> Option<Vec<u8>> {
|
||||
if self.buffer.len().saturating_add(bytes.len()) > MAX_SYSEX_BYTES {
|
||||
self.overflowed = true;
|
||||
self.buffer.clear();
|
||||
}
|
||||
if !self.overflowed {
|
||||
self.buffer.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
match end {
|
||||
SysExEnd::Open => None,
|
||||
SysExEnd::Complete if self.overflowed => {
|
||||
self.discard();
|
||||
None
|
||||
}
|
||||
SysExEnd::Complete => Some(std::mem::take(&mut self.buffer)),
|
||||
SysExEnd::Abandoned => {
|
||||
self.discard();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws away any message in progress, reporting whether there was one.
|
||||
///
|
||||
/// Called when the source goes away mid-dump, so the next thing that endpoint sends does not
|
||||
/// arrive spliced onto the tail of a message nobody finished.
|
||||
pub fn abandon(&mut self) -> bool {
|
||||
let had_one = !self.buffer.is_empty() || self.overflowed;
|
||||
if had_one {
|
||||
self.discard();
|
||||
}
|
||||
had_one
|
||||
}
|
||||
|
||||
/// Returns how many messages were thrown away rather than forwarded.
|
||||
pub fn discarded(&self) -> u64 {
|
||||
self.discarded
|
||||
}
|
||||
|
||||
/// Clears the buffer and counts one message lost.
|
||||
fn discard(&mut self) {
|
||||
self.buffer.clear();
|
||||
self.overflowed = false;
|
||||
self.discarded = self.discarded.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Records what a drained batch did to an endpoint's counters.
|
||||
pub fn record(counters: &TrafficCounters, batch: &[Drained], dropped: u64, at: jiff::Timestamp) {
|
||||
for item in batch {
|
||||
let bytes = match item {
|
||||
Drained::Message { message, .. } => message.len() as u64,
|
||||
Drained::SysEx { bytes, .. } => bytes.len() as u64,
|
||||
};
|
||||
counters.record_received(bytes, at);
|
||||
}
|
||||
if dropped > 0 {
|
||||
counters.record_dropped(dropped);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use midi_harbor_core::ids::EndpointId;
|
||||
use midi_harbor_core::midi::{Channel, MidiMessage};
|
||||
|
||||
/// Proves that pushing onto an endpoint's ring allocates nothing, the rule of AGENTS.md's
|
||||
/// real-time discipline that is easiest to break by accident: the push runs inside a CoreMIDI
|
||||
/// or ALSA read callback, where an allocation can block on the allocator's lock and stall the
|
||||
/// MIDI thread. Asserted with a counting allocator rather than trusted.
|
||||
#[test]
|
||||
fn the_hot_path_does_not_allocate() {
|
||||
let (mut producer, mut consumer) = channel(EndpointId::new());
|
||||
let channel = Channel::new(0).expect("channel 0 is a valid MIDI channel");
|
||||
let note = |number: u8| MidiMessage::NoteOn {
|
||||
channel,
|
||||
note: number,
|
||||
velocity: 100,
|
||||
};
|
||||
|
||||
// Warm the rings, since the first push may touch pages the allocator has not yet faulted.
|
||||
for _ in 0..64 {
|
||||
let _ = producer.push(note(60), 0);
|
||||
}
|
||||
let _ = consumer.drain(64);
|
||||
|
||||
// Counted per thread, because the test runner executes other tests in parallel and a
|
||||
// global count would attribute their allocations to this one. A thousand pushes overflow
|
||||
// the ring as well, so the drop-and-count path is measured too.
|
||||
let before = allocations();
|
||||
for n in 0..1000u32 {
|
||||
let note_number = u8::try_from(n % 128).unwrap_or(60);
|
||||
let _ = producer.push(note(note_number), u64::from(n));
|
||||
}
|
||||
let after = allocations();
|
||||
|
||||
assert_eq!(
|
||||
after,
|
||||
before,
|
||||
"pushing onto the real-time path allocated {} times, and an allocation can stall the MIDI thread",
|
||||
after.saturating_sub(before)
|
||||
);
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// THREAD_ALLOCATIONS counts allocations made by this thread, so a parallel test cannot
|
||||
/// pollute the count.
|
||||
static THREAD_ALLOCATIONS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Returns how many allocations this thread has made.
|
||||
fn allocations() -> u64 {
|
||||
THREAD_ALLOCATIONS.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// Records one allocation against the calling thread.
|
||||
///
|
||||
/// Reading a thread local can itself allocate during lazy initialisation, so a failure to
|
||||
/// access it is ignored rather than counted.
|
||||
fn note_allocation() {
|
||||
let _ = THREAD_ALLOCATIONS.try_with(|count| count.set(count.get().saturating_add(1)));
|
||||
}
|
||||
|
||||
/// Wraps the system allocator and counts calls.
|
||||
struct Counting;
|
||||
|
||||
// SAFETY: every method forwards directly to the system allocator with the layout it was
|
||||
// given, adding only a relaxed counter increment, so the allocator contract is unchanged.
|
||||
unsafe impl std::alloc::GlobalAlloc for Counting {
|
||||
unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 {
|
||||
note_allocation();
|
||||
unsafe { std::alloc::System.alloc(layout) }
|
||||
}
|
||||
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: std::alloc::Layout) {
|
||||
unsafe { std::alloc::System.dealloc(ptr, layout) }
|
||||
}
|
||||
|
||||
unsafe fn realloc(
|
||||
&self,
|
||||
ptr: *mut u8,
|
||||
layout: std::alloc::Layout,
|
||||
new_size: usize,
|
||||
) -> *mut u8 {
|
||||
note_allocation();
|
||||
unsafe { std::alloc::System.realloc(ptr, layout, new_size) }
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static ALLOCATOR: Counting = Counting;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sysex_tests {
|
||||
use super::*;
|
||||
|
||||
/// A short universal device inquiry, the smallest realistic whole message.
|
||||
const INQUIRY: [u8; 3] = [0xF0, 0x7E, 0xF7];
|
||||
|
||||
/// Proves that a dump thrown away before it finished is counted once and leaves nothing
|
||||
/// behind, so the next message from that endpoint arrives clean rather than spliced onto the
|
||||
/// tail of one nobody finished. Two ways a dump is thrown away: its source goes away mid-dump,
|
||||
/// and a source that never sends a terminator outgrows MAX_SYSEX_BYTES (262,144), which bounds
|
||||
/// the buffer against hostile input. Sixty-eight runs of 4,096 bytes are 278,528 bytes: the
|
||||
/// 64 runs that fill the limit exactly, plus four more to cross it.
|
||||
#[test]
|
||||
fn a_thrown_away_dump_is_counted_once_and_leaves_the_next_message_clean() {
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
throw_away: fn(&mut SysExAssembler),
|
||||
}
|
||||
let cases = [
|
||||
Case {
|
||||
name: "the source goes away mid-dump",
|
||||
throw_away: |assembler| {
|
||||
assert_eq!(
|
||||
assembler.push(&[0xF0, 0x43, 0x10], SysExEnd::Open),
|
||||
None,
|
||||
"an unfinished dump must not be forwarded"
|
||||
);
|
||||
assert!(
|
||||
assembler.abandon(),
|
||||
"abandoning must report the partial dump it threw away"
|
||||
);
|
||||
assert!(
|
||||
!assembler.abandon(),
|
||||
"a second abandon has nothing left to throw away"
|
||||
);
|
||||
},
|
||||
},
|
||||
Case {
|
||||
name: "the dump never ends and outgrows the limit",
|
||||
throw_away: |assembler| {
|
||||
let run = vec![0x00; 4096];
|
||||
let past_the_limit = MAX_SYSEX_BYTES.div_ceil(run.len()).saturating_add(4);
|
||||
for _ in 0..past_the_limit {
|
||||
assert_eq!(
|
||||
assembler.push(&run, SysExEnd::Open),
|
||||
None,
|
||||
"an unfinished dump must not be forwarded"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
assembler.push(&[0xF7], SysExEnd::Complete),
|
||||
None,
|
||||
"a dump cut down to the limit must be discarded rather than delivered truncated"
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
let mut assembler = SysExAssembler::new();
|
||||
(case.throw_away)(&mut assembler);
|
||||
assert_eq!(
|
||||
assembler.discarded(),
|
||||
1,
|
||||
"{}: the thrown-away dump must be counted exactly once",
|
||||
case.name
|
||||
);
|
||||
assert_eq!(
|
||||
assembler.push(&INQUIRY, SysExEnd::Complete),
|
||||
Some(INQUIRY.to_vec()),
|
||||
"{}: the next message must arrive whole and unspliced",
|
||||
case.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
668
crates/daemon/src/devices.rs
Normal file
668
crates/daemon/src/devices.rs
Normal file
|
|
@ -0,0 +1,668 @@
|
|||
//! Attached MIDI hardware, and keeping routes bound to it across replugging.
|
||||
//!
|
||||
//! Devices are discovered rather than created. The problem this solves is identity: a device
|
||||
//! unplugged and plugged back in — possibly into a different socket — must be recognised as the
|
||||
//! same device, or every route referring to it breaks. Two identical devices attached at once
|
||||
//! must stay distinct, or a route addresses the wrong hardware.
|
||||
|
||||
use midi_harbor_core::config::RouteConfig;
|
||||
use midi_harbor_core::endpoint::{Endpoint, EndpointKind, EndpointName, PhysicalDevice};
|
||||
use midi_harbor_core::fingerprint::{DeviceFingerprint, MatchConfidence};
|
||||
use midi_harbor_platform::midi::DiscoveredDevice;
|
||||
|
||||
/// What matching attached hardware against a stored entry concluded.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DeviceMatch {
|
||||
/// The stored entry and this hardware are the same device.
|
||||
Same {
|
||||
/// How confident the match is.
|
||||
confidence: MatchConfidence,
|
||||
},
|
||||
/// Several attached devices matched equally well, so the user must choose.
|
||||
///
|
||||
/// Never resolved automatically: binding a route to the wrong instrument is worse than
|
||||
/// asking which one was meant.
|
||||
Ambiguous {
|
||||
/// How many attached devices matched.
|
||||
candidates: usize,
|
||||
},
|
||||
/// Nothing attached matches this entry.
|
||||
Absent,
|
||||
}
|
||||
|
||||
/// Matches stored device entries against what is currently attached.
|
||||
///
|
||||
/// Returns one decision per stored entry, in the order they were given.
|
||||
pub fn reconcile(stored: &[DeviceFingerprint], attached: &[DiscoveredDevice]) -> Vec<DeviceMatch> {
|
||||
let fingerprints: Vec<DeviceFingerprint> = attached
|
||||
.iter()
|
||||
.map(|device| device.fingerprint.clone())
|
||||
.collect();
|
||||
|
||||
stored
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let (found, confidence) = entry.best_match(&fingerprints);
|
||||
match (found, confidence) {
|
||||
(None, _) | (_, MatchConfidence::None) => DeviceMatch::Absent,
|
||||
(Some(_), MatchConfidence::Ambiguous) => DeviceMatch::Ambiguous {
|
||||
candidates: fingerprints
|
||||
.iter()
|
||||
.filter(|candidate| entry.compare(candidate) != MatchConfidence::None)
|
||||
.count(),
|
||||
},
|
||||
(Some(_), confidence) => DeviceMatch::Same { confidence },
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns a name for newly seen hardware that no other endpoint already answers to.
|
||||
///
|
||||
/// Two identical devices report identical names, and routes name their endpoints, so leaving them
|
||||
/// the same would let a route bind to whichever one happened to be looked up first — silently, and
|
||||
/// not necessarily the same one after a restart. Both get told apart rather than one keeping the
|
||||
/// plain name, because there is nothing to choose between them.
|
||||
fn unique_name(device: &DiscoveredDevice, attached: &[DiscoveredDevice], taken: &[&str]) -> String {
|
||||
let plain = device.fingerprint.name.clone();
|
||||
|
||||
let twins = attached
|
||||
.iter()
|
||||
.filter(|other| other.fingerprint.name == plain)
|
||||
.count();
|
||||
if twins <= 1 && !taken.contains(&plain.as_str()) {
|
||||
return plain;
|
||||
}
|
||||
|
||||
// Where it is plugged in is all that distinguishes hardware that is identical in every other
|
||||
// way, which is why moving the cable renames it. Nothing else can tell them apart.
|
||||
let Some(detail) = discriminator(&device.fingerprint) else {
|
||||
// Nothing to tell it apart by, but two endpoints answering to one name is still worse
|
||||
// than a number: a route would bind to whichever was looked up first.
|
||||
for attempt in 2..=u16::MAX {
|
||||
let numbered = format!("{plain} ({attempt})");
|
||||
if !taken.contains(&numbered.as_str()) {
|
||||
return numbered;
|
||||
}
|
||||
}
|
||||
return plain;
|
||||
};
|
||||
let named = format!("{plain} ({detail})");
|
||||
if !taken.contains(&named.as_str()) {
|
||||
return named;
|
||||
}
|
||||
|
||||
// Two devices reporting the same position is not something a name can fix, but two endpoints
|
||||
// answering to one name is worse: a route would bind to whichever was looked up first.
|
||||
for attempt in 2..=u16::MAX {
|
||||
let numbered = format!("{plain} ({detail}, {attempt})");
|
||||
if !taken.contains(&numbered.as_str()) {
|
||||
return numbered;
|
||||
}
|
||||
}
|
||||
named
|
||||
}
|
||||
|
||||
/// Returns the most stable thing that tells this hardware apart from another of its kind.
|
||||
fn discriminator(fingerprint: &DeviceFingerprint) -> Option<String> {
|
||||
if let Some(serial) = &fingerprint.usb_serial {
|
||||
// The port suffix makes the identity per port, but twins differ in the serial itself.
|
||||
let device = serial.split('#').next().unwrap_or(serial);
|
||||
return Some(format!("serial {device}"));
|
||||
}
|
||||
if let Some(path) = &fingerprint.topology_path {
|
||||
// The whole position, less the scheme that is the same for everything on the bus. The
|
||||
// last component alone is not enough: two ALSA clients both have a port zero.
|
||||
if let Some(socket) = path.strip_prefix("usb-") {
|
||||
return Some(format!("socket {socket}"));
|
||||
}
|
||||
let short = path.strip_prefix("alsa:").unwrap_or(path.as_str());
|
||||
return Some(format!("port {short}"));
|
||||
}
|
||||
fingerprint.unique_id.map(|id| format!("id {id:x}"))
|
||||
}
|
||||
|
||||
/// Builds the endpoint list for attached hardware, carrying over what was stored.
|
||||
///
|
||||
/// Hardware that is attached but was never seen before appears as a new endpoint; hardware that
|
||||
/// is stored but absent is kept, so its routes survive being unplugged.
|
||||
pub fn endpoints_for(stored: &[Endpoint], attached: &[DiscoveredDevice]) -> Vec<Endpoint> {
|
||||
let stored_devices: Vec<(&Endpoint, &DeviceFingerprint)> = stored
|
||||
.iter()
|
||||
.filter_map(|endpoint| match &endpoint.kind {
|
||||
EndpointKind::PhysicalDevice(device) => Some((endpoint, &device.fingerprint)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let fingerprints: Vec<DeviceFingerprint> = stored_devices
|
||||
.iter()
|
||||
.map(|(_, fingerprint)| (*fingerprint).clone())
|
||||
.collect();
|
||||
let decisions = reconcile(&fingerprints, attached);
|
||||
|
||||
// Carry each stored entry forward, marking whether its hardware is here.
|
||||
let mut endpoints: Vec<Endpoint> = Vec::new();
|
||||
let mut claimed: Vec<usize> = Vec::new();
|
||||
|
||||
for (index, (endpoint, fingerprint)) in stored_devices.iter().enumerate() {
|
||||
let decision = decisions.get(index).cloned().unwrap_or(DeviceMatch::Absent);
|
||||
// The best match, not the first that is merely possible. With two identical devices
|
||||
// every candidate compares as something, so taking the first bound each stored entry to
|
||||
// whichever device came earliest in the list and left the rest to be added again on every
|
||||
// pass. Already-claimed hardware is skipped, because two entries binding to one device
|
||||
// would both report it as theirs.
|
||||
let matched = matches!(decision, DeviceMatch::Same { .. })
|
||||
.then(|| {
|
||||
attached
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(position, _)| !claimed.contains(position))
|
||||
.map(|(position, device)| (position, fingerprint.compare(&device.fingerprint)))
|
||||
.filter(|(_, score)| *score != MatchConfidence::None)
|
||||
.max_by_key(|(_, score)| *score)
|
||||
.map(|(position, _)| position)
|
||||
})
|
||||
.flatten();
|
||||
if let Some(position) = matched {
|
||||
claimed.push(position);
|
||||
}
|
||||
|
||||
let (present, confidence, claimed_by) = match (&decision, matched) {
|
||||
(DeviceMatch::Same { confidence }, Some(position)) => (
|
||||
true,
|
||||
*confidence,
|
||||
attached
|
||||
.get(position)
|
||||
.and_then(|device| device.claimed_by.clone()),
|
||||
),
|
||||
(DeviceMatch::Ambiguous { .. }, _) => (false, MatchConfidence::Ambiguous, None),
|
||||
_ => (false, MatchConfidence::None, None),
|
||||
};
|
||||
|
||||
// Whether it is software is what the platform says of it now, or what was stored while
|
||||
// it is away.
|
||||
let stored_software =
|
||||
matches!(&endpoint.kind, EndpointKind::PhysicalDevice(device) if device.software);
|
||||
let software = matched
|
||||
.and_then(|position| attached.get(position))
|
||||
.map_or(stored_software, |device| device.software);
|
||||
|
||||
// A maker or model the stored entry lacks is taken from the hardware now matched to it,
|
||||
// so a device remembered before either was read is described like one seen today. What
|
||||
// is stored is never overwritten.
|
||||
let mut described = (*fingerprint).clone();
|
||||
if let Some(device) = matched.and_then(|position| attached.get(position)) {
|
||||
if described.manufacturer.is_none() {
|
||||
described.manufacturer = device.fingerprint.manufacturer.clone();
|
||||
}
|
||||
if described.model.is_none() {
|
||||
described.model = device.fingerprint.model.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let mut carried = (*endpoint).clone();
|
||||
carried.kind = EndpointKind::PhysicalDevice(PhysicalDevice {
|
||||
fingerprint: described,
|
||||
present,
|
||||
confidence,
|
||||
claimed_by,
|
||||
software,
|
||||
});
|
||||
endpoints.push(carried);
|
||||
}
|
||||
|
||||
// A software port that returns under a new identifier, as an IAC bus does after being
|
||||
// edited in Audio MIDI Setup, matches nothing by identifier. It is taken back by name, but
|
||||
// only when that settles it: one absent software entry of the name and one unclaimed
|
||||
// software port of it. Otherwise it appeared again beside the old entry the routes name.
|
||||
let absent_software = |endpoint: &Endpoint| match &endpoint.kind {
|
||||
EndpointKind::PhysicalDevice(device) if device.software && !device.present => {
|
||||
Some(device.fingerprint.name.clone())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let returning: Vec<(usize, usize)> = endpoints
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, endpoint)| {
|
||||
let name = absent_software(endpoint)?;
|
||||
let alone = endpoints
|
||||
.iter()
|
||||
.filter(|other| absent_software(other).as_deref() == Some(name.as_str()))
|
||||
.count()
|
||||
== 1;
|
||||
let mut candidates = attached.iter().enumerate().filter(|(position, device)| {
|
||||
device.software && device.fingerprint.name == name && !claimed.contains(position)
|
||||
});
|
||||
let (position, _) = candidates.next()?;
|
||||
(alone && candidates.next().is_none()).then_some((index, position))
|
||||
})
|
||||
.collect();
|
||||
for (index, position) in returning {
|
||||
let (Some(endpoint), Some(device)) = (endpoints.get_mut(index), attached.get(position))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let EndpointKind::PhysicalDevice(held) = &mut endpoint.kind {
|
||||
held.fingerprint.unique_id = device.fingerprint.unique_id;
|
||||
if held.fingerprint.manufacturer.is_none() {
|
||||
held.fingerprint.manufacturer = device.fingerprint.manufacturer.clone();
|
||||
}
|
||||
if held.fingerprint.model.is_none() {
|
||||
held.fingerprint.model = device.fingerprint.model.clone();
|
||||
}
|
||||
held.present = true;
|
||||
held.confidence = MatchConfidence::Probable;
|
||||
held.claimed_by = device.claimed_by.clone();
|
||||
}
|
||||
claimed.push(position);
|
||||
}
|
||||
|
||||
// Anything attached that no stored entry claimed is hardware we have not seen before.
|
||||
for (position, device) in attached.iter().enumerate() {
|
||||
if claimed.contains(&position) {
|
||||
continue;
|
||||
}
|
||||
let taken: Vec<&str> = endpoints
|
||||
.iter()
|
||||
.map(|endpoint| endpoint.name.as_str())
|
||||
.collect();
|
||||
let Ok(name) = EndpointName::new(unique_name(device, attached, &taken)) else {
|
||||
continue;
|
||||
};
|
||||
endpoints.push(Endpoint {
|
||||
direction: device.direction,
|
||||
..Endpoint::new(
|
||||
name,
|
||||
EndpointKind::PhysicalDevice(PhysicalDevice {
|
||||
fingerprint: device.fingerprint.clone(),
|
||||
present: true,
|
||||
confidence: MatchConfidence::Exact,
|
||||
claimed_by: device.claimed_by.clone(),
|
||||
software: device.software,
|
||||
}),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
endpoints
|
||||
}
|
||||
|
||||
/// Separates the entries worth keeping from applications' ports that have gone unused.
|
||||
///
|
||||
/// Hardware is kept while unplugged, so its routes resume when it returns. An application's port
|
||||
/// is kept while present, and after it goes only if a route names it: a synth someone routes to
|
||||
/// comes back with its routes, and a tool that opened a port once leaves nothing behind.
|
||||
pub fn forget_unused_software(
|
||||
endpoints: Vec<Endpoint>,
|
||||
routes: &[RouteConfig],
|
||||
) -> (Vec<Endpoint>, Vec<Endpoint>) {
|
||||
endpoints.into_iter().partition(|endpoint| {
|
||||
let EndpointKind::PhysicalDevice(device) = &endpoint.kind else {
|
||||
return true;
|
||||
};
|
||||
!device.software || device.present || routes.iter().any(|route| route.touches(endpoint))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use midi_harbor_core::endpoint::Direction;
|
||||
|
||||
/// Builds attached hardware or an application's port as the platform reports it.
|
||||
fn attached(fingerprint: DeviceFingerprint, software: bool) -> DiscoveredDevice {
|
||||
DiscoveredDevice {
|
||||
fingerprint,
|
||||
direction: Direction::Bidirectional,
|
||||
claimed_by: None,
|
||||
software,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a stored entry for hardware that is not attached, as the configuration holds it.
|
||||
fn stored(name: &str, fingerprint: DeviceFingerprint, software: bool) -> Endpoint {
|
||||
Endpoint::new(
|
||||
EndpointName::new(name).expect("the stored name is a valid endpoint name"),
|
||||
EndpointKind::PhysicalDevice(PhysicalDevice {
|
||||
fingerprint,
|
||||
present: false,
|
||||
confidence: MatchConfidence::None,
|
||||
claimed_by: None,
|
||||
software,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns a K61 keyboard's fingerprint as USB reports it, with an optional serial number.
|
||||
fn keyboard(serial: Option<&str>) -> DeviceFingerprint {
|
||||
DeviceFingerprint {
|
||||
usb_serial: serial.map(str::to_owned),
|
||||
manufacturer: Some("Acme".to_owned()),
|
||||
model: Some("K61".to_owned()),
|
||||
name: "Acme K61".to_owned(),
|
||||
..DeviceFingerprint::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a K61 keyboard's fingerprint as ALSA reports it at a client and port, which is all
|
||||
/// that tells two of them apart.
|
||||
fn keyboard_at(position: &str) -> DeviceFingerprint {
|
||||
DeviceFingerprint {
|
||||
topology_path: Some(format!("alsa:{position}")),
|
||||
..keyboard(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a CoreMIDI port's fingerprint, which carries a name and a unique identifier.
|
||||
fn port(name: &str, unique_id: u32) -> DeviceFingerprint {
|
||||
DeviceFingerprint {
|
||||
unique_id: Some(unique_id),
|
||||
name: name.to_owned(),
|
||||
..DeviceFingerprint::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns what an endpoint carries as a physical device.
|
||||
fn device(endpoint: &Endpoint) -> &PhysicalDevice {
|
||||
match &endpoint.kind {
|
||||
EndpointKind::PhysicalDevice(device) => device,
|
||||
other => panic!("expected a physical device, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns each endpoint's name and whether its hardware is attached, in list order.
|
||||
fn listed(endpoints: &[Endpoint]) -> Vec<(String, bool)> {
|
||||
endpoints
|
||||
.iter()
|
||||
.map(|endpoint| (endpoint.name.to_string(), device(endpoint).present))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Proves which attached ports a stored entry takes back when its identifier no longer
|
||||
/// matches. Editing an IAC bus in Audio MIDI Setup gives it a new CoreMIDI identifier, so a
|
||||
/// software port is taken back by name, but only when that settles it: one absent software
|
||||
/// entry of the name and one unclaimed software port of it. Hardware with a different
|
||||
/// identifier or serial is different hardware of the same model, and guessing between two
|
||||
/// candidates would bind a route to whichever was listed first.
|
||||
#[test]
|
||||
fn a_port_is_taken_back_by_name_only_when_that_settles_it() {
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
stored: Vec<Endpoint>,
|
||||
attached: Vec<DiscoveredDevice>,
|
||||
want: Vec<(&'static str, bool)>,
|
||||
want_first_id: Option<u32>,
|
||||
}
|
||||
let cases = [
|
||||
Case {
|
||||
name: "a software port back under a new identifier is the same port",
|
||||
stored: vec![stored("Bus", port("Bus", 1), true)],
|
||||
attached: vec![attached(port("Bus", 2), true)],
|
||||
want: vec![("Bus", true)],
|
||||
want_first_id: Some(2),
|
||||
},
|
||||
Case {
|
||||
name: "hardware under a new identifier is different hardware",
|
||||
stored: vec![stored("Keystation", port("Keystation", 1), false)],
|
||||
attached: vec![attached(port("Keystation", 2), false)],
|
||||
want: vec![("Keystation", false), ("Keystation (id 2)", true)],
|
||||
want_first_id: Some(1),
|
||||
},
|
||||
Case {
|
||||
name: "hardware with another serial is different hardware",
|
||||
stored: vec![stored("Acme K61", keyboard(Some("SN-001")), false)],
|
||||
attached: vec![attached(keyboard(Some("SN-999")), false)],
|
||||
want: vec![("Acme K61", false), ("Acme K61 (serial SN-999)", true)],
|
||||
want_first_id: None,
|
||||
},
|
||||
Case {
|
||||
name: "two ports of the name returning are not guessed between",
|
||||
stored: vec![stored("Bus", port("Bus", 1), true)],
|
||||
attached: vec![
|
||||
attached(port("Bus", 2), true),
|
||||
attached(port("Bus", 3), true),
|
||||
],
|
||||
want: vec![("Bus", false), ("Bus (id 2)", true), ("Bus (id 3)", true)],
|
||||
want_first_id: Some(1),
|
||||
},
|
||||
Case {
|
||||
name: "two remembered entries of the name are not guessed between",
|
||||
stored: vec![
|
||||
stored("Bus", port("Bus", 1), true),
|
||||
stored("Bus 2", port("Bus", 4), true),
|
||||
],
|
||||
attached: vec![attached(port("Bus", 2), true)],
|
||||
want: vec![("Bus", false), ("Bus 2", false), ("Bus (id 2)", true)],
|
||||
want_first_id: Some(1),
|
||||
},
|
||||
Case {
|
||||
name: "a port matched by its identifier is not handed to another entry",
|
||||
stored: vec![
|
||||
stored("Bus", port("Bus", 1), true),
|
||||
stored("Bus 2", port("Bus", 5), true),
|
||||
],
|
||||
attached: vec![attached(port("Bus", 1), true)],
|
||||
want: vec![("Bus", true), ("Bus 2", false)],
|
||||
want_first_id: Some(1),
|
||||
},
|
||||
Case {
|
||||
name: "a port still here keeps its identifier when another of its name appears",
|
||||
stored: vec![stored("Bus", port("Bus", 1), true)],
|
||||
attached: vec![
|
||||
attached(port("Bus", 1), true),
|
||||
attached(port("Bus", 2), true),
|
||||
],
|
||||
want: vec![("Bus", true), ("Bus (id 2)", true)],
|
||||
want_first_id: Some(1),
|
||||
},
|
||||
Case {
|
||||
name: "a hardware port does not take a software entry's place",
|
||||
stored: vec![stored("Bus", port("Bus", 1), true)],
|
||||
attached: vec![attached(port("Bus", 2), false)],
|
||||
want: vec![("Bus", false), ("Bus (id 2)", true)],
|
||||
want_first_id: Some(1),
|
||||
},
|
||||
Case {
|
||||
name: "a software port does not take a hardware entry's place",
|
||||
stored: vec![stored("Keystation", port("Keystation", 1), false)],
|
||||
attached: vec![attached(port("Keystation", 2), true)],
|
||||
want: vec![("Keystation", false), ("Keystation (id 2)", true)],
|
||||
want_first_id: Some(1),
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
let endpoints = endpoints_for(&case.stored, &case.attached);
|
||||
let want: Vec<(String, bool)> = case
|
||||
.want
|
||||
.iter()
|
||||
.map(|(name, present)| ((*name).to_owned(), *present))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
listed(&endpoints),
|
||||
want,
|
||||
"{}: the entries and which are attached are wrong",
|
||||
case.name
|
||||
);
|
||||
assert_eq!(
|
||||
endpoints
|
||||
.first()
|
||||
.and_then(|endpoint| device(endpoint).fingerprint.unique_id),
|
||||
case.want_first_id,
|
||||
"{}: the first stored entry must carry the identifier the next pass finds it by",
|
||||
case.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Proves that two identical devices each keep their own stored entry when where they are
|
||||
/// plugged in tells them apart, and that neither entry is bound when nothing does (FR-015g).
|
||||
/// Taking the first possible match bound both entries to one device and added the other again
|
||||
/// on every pass, a configuration that grew by an entry every refresh; and binding a route to
|
||||
/// the wrong instrument is worse than asking which was meant.
|
||||
#[test]
|
||||
fn identical_devices_bind_to_their_own_entry_or_to_none() {
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
stored: Vec<Endpoint>,
|
||||
want: Vec<(&'static str, bool)>,
|
||||
want_confidence: MatchConfidence,
|
||||
}
|
||||
let cases = [
|
||||
Case {
|
||||
name: "each entry remembered at a position binds to the twin there",
|
||||
stored: vec![
|
||||
stored("Acme K61", keyboard_at("128:0"), false),
|
||||
stored("Acme K61 (port 128:1)", keyboard_at("128:1"), false),
|
||||
],
|
||||
want: vec![("Acme K61", true), ("Acme K61 (port 128:1)", true)],
|
||||
want_confidence: MatchConfidence::Probable,
|
||||
},
|
||||
Case {
|
||||
name: "an entry with nothing to tell the twins apart is left unbound",
|
||||
stored: vec![stored("Acme K61", keyboard(None), false)],
|
||||
want: vec![
|
||||
("Acme K61", false),
|
||||
("Acme K61 (port 128:0)", true),
|
||||
("Acme K61 (port 128:1)", true),
|
||||
],
|
||||
want_confidence: MatchConfidence::Ambiguous,
|
||||
},
|
||||
];
|
||||
let twins = [
|
||||
attached(keyboard_at("128:0"), false),
|
||||
attached(keyboard_at("128:1"), false),
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
let endpoints = endpoints_for(&case.stored, &twins);
|
||||
let want: Vec<(String, bool)> = case
|
||||
.want
|
||||
.iter()
|
||||
.map(|(name, present)| ((*name).to_owned(), *present))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
listed(&endpoints),
|
||||
want,
|
||||
"{}: the entries and which are attached are wrong",
|
||||
case.name
|
||||
);
|
||||
assert_eq!(
|
||||
endpoints
|
||||
.first()
|
||||
.map(|endpoint| device(endpoint).confidence),
|
||||
Some(case.want_confidence),
|
||||
"{}: the first entry's match confidence is wrong",
|
||||
case.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Proves the names given to newly seen hardware. Routes name their endpoints, so two
|
||||
/// endpoints answering to one name let a route bind to whichever is looked up first, silently
|
||||
/// and not always the same one (FR-015g). Twins are told apart by position, then by a number
|
||||
/// when even the position is shared; a device on its own keeps its plain name, because the
|
||||
/// suffix is only worth its ugliness when something has to be told apart.
|
||||
#[test]
|
||||
fn newly_seen_hardware_is_named_so_no_two_endpoints_share_a_name() {
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
attached: Vec<DiscoveredDevice>,
|
||||
want: Vec<&'static str>,
|
||||
}
|
||||
let cases = [
|
||||
Case {
|
||||
name: "a device on its own keeps its plain name",
|
||||
attached: vec![attached(keyboard_at("128:0"), false)],
|
||||
want: vec!["Acme K61"],
|
||||
},
|
||||
Case {
|
||||
name: "twins are told apart by where they are plugged in",
|
||||
attached: vec![
|
||||
attached(keyboard_at("128:0"), false),
|
||||
attached(keyboard_at("128:1"), false),
|
||||
],
|
||||
want: vec!["Acme K61 (port 128:0)", "Acme K61 (port 128:1)"],
|
||||
},
|
||||
Case {
|
||||
name: "twins reporting one position are numbered",
|
||||
attached: vec![
|
||||
attached(keyboard_at("129:0"), false),
|
||||
attached(keyboard_at("129:0"), false),
|
||||
attached(keyboard_at("129:0"), false),
|
||||
],
|
||||
want: vec![
|
||||
"Acme K61 (port 129:0)",
|
||||
"Acme K61 (port 129:0, 2)",
|
||||
"Acme K61 (port 129:0, 3)",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
let names: Vec<String> = endpoints_for(&[], &case.attached)
|
||||
.iter()
|
||||
.map(|endpoint| endpoint.name.to_string())
|
||||
.collect();
|
||||
assert_eq!(names, case.want, "{}: the names given are wrong", case.name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Proves that a maker or model a stored entry lacks is taken from the hardware matched to
|
||||
/// it, that what is stored is never overwritten, and that hardware matched to nothing lends
|
||||
/// nothing. Devices remembered on macOS before maker and model were read kept none for good.
|
||||
#[test]
|
||||
fn a_stored_entry_takes_only_the_description_it_lacks_from_its_own_hardware() {
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
stored: (Option<&'static str>, Option<&'static str>),
|
||||
serial: &'static str,
|
||||
want: (Option<&'static str>, Option<&'static str>),
|
||||
}
|
||||
let cases = [
|
||||
Case {
|
||||
name: "an entry remembered without a maker takes the one reported now",
|
||||
stored: (None, None),
|
||||
serial: "SN-001",
|
||||
want: (Some("Acme"), Some("K61")),
|
||||
},
|
||||
Case {
|
||||
name: "a stored maker and model are not overwritten",
|
||||
stored: (Some("Acme Corp"), Some("K61 MkII")),
|
||||
serial: "SN-001",
|
||||
want: (Some("Acme Corp"), Some("K61 MkII")),
|
||||
},
|
||||
Case {
|
||||
name: "different hardware of the same name lends nothing",
|
||||
stored: (None, None),
|
||||
serial: "SN-002",
|
||||
want: (None, None),
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
let remembered = DeviceFingerprint {
|
||||
manufacturer: case.stored.0.map(str::to_owned),
|
||||
model: case.stored.1.map(str::to_owned),
|
||||
..keyboard(Some("SN-001"))
|
||||
};
|
||||
let endpoints = endpoints_for(
|
||||
&[stored("Acme K61", remembered, false)],
|
||||
&[attached(keyboard(Some(case.serial)), false)],
|
||||
);
|
||||
let described =
|
||||
&device(endpoints.first().expect("the stored entry is listed first")).fingerprint;
|
||||
assert_eq!(
|
||||
(
|
||||
described.manufacturer.as_deref(),
|
||||
described.model.as_deref()
|
||||
),
|
||||
case.want,
|
||||
"{}: the stored entry's maker and model are wrong",
|
||||
case.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
495
crates/daemon/src/discovery.rs
Normal file
495
crates/daemon/src/discovery.rs
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
//! Finding peers on the local network, and letting them find us.
|
||||
//!
|
||||
//! Two stacks, each used where it actually works — established by testing against a second
|
||||
//! machine, not by preference:
|
||||
//!
|
||||
//! - **Browsing** uses the pure-Rust `mdns-sd`. It reliably sees services advertised by any
|
||||
//! responder, including Apple's Network MIDI, and reports usable addresses.
|
||||
//! - **Advertising** uses the platform responder (Bonjour on macOS, Avahi on Linux, the DNS
|
||||
//! Client service on Windows), through `midi_harbor_platform::responder`. `mdns-sd`'s responder announces once at registration and then does not answer
|
||||
//! queries from other machines, so a service registered through it is visible for a moment and
|
||||
//! then invisible to every peer — including to Apple's own browser.
|
||||
//!
|
||||
//! The decisive test: the same machine advertising through Apple's `dns-sd -R` was seen
|
||||
//! immediately by both browsers; advertising the same service through `mdns-sd` was seen by
|
||||
//! neither. Browsing was never the problem.
|
||||
|
||||
use midi_harbor_core::ids::PeerId;
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// The service type Apple's Network MIDI advertises and browses for.
|
||||
pub const SERVICE_TYPE: &str = "_apple-midi._udp.local.";
|
||||
|
||||
/// How long a peer may go unseen before it is dropped from the list.
|
||||
pub const PEER_TTL: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Why discovery could not start.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DiscoveryError {
|
||||
/// The responder could not be started.
|
||||
#[error("could not start mdns: {0}")]
|
||||
Start(String),
|
||||
/// A service could not be registered.
|
||||
#[error("could not advertise as {name}: {detail}")]
|
||||
Advertise {
|
||||
/// The name that failed to register.
|
||||
name: String,
|
||||
/// What went wrong.
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A peer found on the network.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiscoveredPeer {
|
||||
/// Stable identity for this peer within this daemon run.
|
||||
pub id: PeerId,
|
||||
/// The name the peer advertises.
|
||||
pub name: String,
|
||||
/// The fully qualified service name, which is unique even when names collide.
|
||||
pub fullname: String,
|
||||
/// Every address the peer advertised.
|
||||
pub addresses: Vec<IpAddr>,
|
||||
/// The peer's control port.
|
||||
pub port: u16,
|
||||
/// Set when this is our own advertisement reflected back.
|
||||
pub is_self: bool,
|
||||
}
|
||||
|
||||
impl DiscoveredPeer {
|
||||
/// Returns the best address to reach this peer on.
|
||||
pub fn address(&self) -> Option<SocketAddr> {
|
||||
crate::net::choose_peer_address(&self.addresses, self.port)
|
||||
}
|
||||
|
||||
/// Returns a label that distinguishes this peer from another with the same name.
|
||||
///
|
||||
/// Two machines advertising the same name is ordinary on a network of identical laptops, and
|
||||
/// showing both as the same thing would make one of them unselectable.
|
||||
pub fn label(&self, ambiguous: bool) -> String {
|
||||
if !ambiguous {
|
||||
return self.name.clone();
|
||||
}
|
||||
match self.address() {
|
||||
Some(address) => format!("{} ({})", self.name, address.ip()),
|
||||
None => format!("{} ({})", self.name, self.fullname),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The set of peers currently visible.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PeerTable {
|
||||
peers: HashMap<String, DiscoveredPeer>,
|
||||
}
|
||||
|
||||
impl PeerTable {
|
||||
/// Records a peer, replacing any earlier record of the same service.
|
||||
pub fn insert(&mut self, peer: DiscoveredPeer) {
|
||||
// Keying on the fully qualified name rather than the display name is what keeps two
|
||||
// machines called the same thing apart.
|
||||
match self.peers.get_mut(&peer.fullname) {
|
||||
// Keep the identity stable across re-resolution, so a peer does not appear to be
|
||||
// replaced every time its record is refreshed.
|
||||
Some(existing) => {
|
||||
existing.addresses = peer.addresses;
|
||||
existing.port = peer.port;
|
||||
existing.name = peer.name;
|
||||
}
|
||||
None => {
|
||||
let _ = self.peers.insert(peer.fullname.clone(), peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a peer that has gone away.
|
||||
pub fn remove(&mut self, fullname: &str) {
|
||||
let _ = self.peers.remove(fullname);
|
||||
}
|
||||
|
||||
/// Returns the peers a user may connect to, with labels that distinguish duplicates.
|
||||
///
|
||||
/// Our own advertisement is excluded: offering to connect a machine to itself is never what
|
||||
/// was meant, and the responder does reflect it back.
|
||||
pub fn connectable(&self) -> Vec<(DiscoveredPeer, String)> {
|
||||
let mut counts: HashMap<&str, usize> = HashMap::new();
|
||||
for peer in self.peers.values().filter(|p| !p.is_self) {
|
||||
*counts.entry(peer.name.as_str()).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let mut listed: Vec<(DiscoveredPeer, String)> = self
|
||||
.peers
|
||||
.values()
|
||||
.filter(|peer| !peer.is_self)
|
||||
.map(|peer| {
|
||||
let ambiguous = counts
|
||||
.get(peer.name.as_str())
|
||||
.is_some_and(|count| *count > 1);
|
||||
(peer.clone(), peer.label(ambiguous))
|
||||
})
|
||||
.collect();
|
||||
listed.sort_by(|a, b| a.1.cmp(&b.1));
|
||||
listed
|
||||
}
|
||||
}
|
||||
|
||||
/// One advertisement, running on its own thread until dropped.
|
||||
struct Advertisement {
|
||||
/// Cleared to ask the thread to withdraw the service and stop.
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Drop for Advertisement {
|
||||
fn drop(&mut self) {
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Browses for peers and advertises this machine.
|
||||
pub struct Discovery {
|
||||
daemon: mdns_sd::ServiceDaemon,
|
||||
table: Arc<Mutex<PeerTable>>,
|
||||
/// Every name and control port this machine advertises, so its own records are recognised
|
||||
/// coming back.
|
||||
advertised: Arc<Mutex<Vec<(String, u16)>>>,
|
||||
/// The running advertisements, keyed by the name each publishes.
|
||||
services: Mutex<HashMap<String, Advertisement>>,
|
||||
}
|
||||
|
||||
impl Discovery {
|
||||
/// Starts the responder and begins browsing.
|
||||
pub fn start() -> Result<Arc<Self>, DiscoveryError> {
|
||||
let daemon = mdns_sd::ServiceDaemon::new()
|
||||
.map_err(|error| DiscoveryError::Start(error.to_string()))?;
|
||||
|
||||
let discovery = Arc::new(Self {
|
||||
daemon,
|
||||
table: Arc::new(Mutex::new(PeerTable::default())),
|
||||
advertised: Arc::new(Mutex::new(Vec::new())),
|
||||
services: Mutex::new(HashMap::new()),
|
||||
});
|
||||
|
||||
let receiver = discovery
|
||||
.daemon
|
||||
.browse(SERVICE_TYPE)
|
||||
.map_err(|error| DiscoveryError::Start(error.to_string()))?;
|
||||
|
||||
let table = Arc::clone(&discovery.table);
|
||||
let advertised = Arc::clone(&discovery.advertised);
|
||||
std::thread::Builder::new()
|
||||
.name("mdns-browse".to_owned())
|
||||
.spawn(move || browse_loop(receiver, table, advertised))
|
||||
.map_err(|error| DiscoveryError::Start(error.to_string()))?;
|
||||
|
||||
Ok(discovery)
|
||||
}
|
||||
|
||||
/// Advertises a session so other machines can find it.
|
||||
///
|
||||
/// Registered through the platform responder rather than the pure-Rust one, because the
|
||||
/// latter announces once and then stops answering queries from other machines.
|
||||
pub fn advertise(&self, name: &str, port: u16) -> Result<(), DiscoveryError> {
|
||||
// Remembering what we publish is what lets our own records be recognised coming back.
|
||||
// Comparing against the machine name would not: a session advertises its own name.
|
||||
if let Ok(mut advertised) = self.advertised.lock() {
|
||||
advertised.retain(|(existing, _)| existing != name);
|
||||
advertised.push((name.to_owned(), port));
|
||||
}
|
||||
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let (ready, started) = std::sync::mpsc::channel();
|
||||
|
||||
let thread_running = Arc::clone(&running);
|
||||
let thread_name = name.to_owned();
|
||||
std::thread::Builder::new()
|
||||
.name(format!("mdns-advertise-{name}"))
|
||||
.spawn(move || {
|
||||
midi_harbor_platform::responder::advertise(
|
||||
&thread_name,
|
||||
port,
|
||||
&thread_running,
|
||||
&ready,
|
||||
);
|
||||
})
|
||||
.map_err(|error| DiscoveryError::Advertise {
|
||||
name: name.to_owned(),
|
||||
detail: error.to_string(),
|
||||
})?;
|
||||
|
||||
// Waiting for the responder to accept the registration turns a silent failure into a
|
||||
// reported one, which matters because an unadvertised session looks perfectly healthy.
|
||||
match started.recv_timeout(Duration::from_secs(5)) {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(detail)) => {
|
||||
return Err(DiscoveryError::Advertise {
|
||||
name: name.to_owned(),
|
||||
detail,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(DiscoveryError::Advertise {
|
||||
name: name.to_owned(),
|
||||
detail: "the platform responder did not answer".to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(mut services) = self.services.lock() {
|
||||
let _ = services.insert(name.to_owned(), Advertisement { running });
|
||||
}
|
||||
info!(name, port, "advertising network session");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops advertising a session.
|
||||
pub fn withdraw(&self, name: &str) {
|
||||
if let Ok(mut services) = self.services.lock() {
|
||||
// Dropping the handle stops the thread, which withdraws the service.
|
||||
let _ = services.remove(name);
|
||||
}
|
||||
if let Ok(mut advertised) = self.advertised.lock() {
|
||||
advertised.retain(|(existing, _)| existing != name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the names this machine is announcing now.
|
||||
pub fn announcing(&self) -> Vec<String> {
|
||||
let mut names: Vec<String> = self
|
||||
.services
|
||||
.lock()
|
||||
.map(|services| services.keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// Returns the peers a user may connect to.
|
||||
pub fn peers(&self) -> Vec<(DiscoveredPeer, String)> {
|
||||
match self.table.lock() {
|
||||
Ok(table) => table.connectable(),
|
||||
Err(poisoned) => poisoned.into_inner().connectable(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads discovery events until the daemon stops.
|
||||
fn browse_loop(
|
||||
receiver: mdns_sd::Receiver<mdns_sd::ServiceEvent>,
|
||||
table: Arc<Mutex<PeerTable>>,
|
||||
advertised: Arc<Mutex<Vec<(String, u16)>>>,
|
||||
) {
|
||||
while let Ok(event) = receiver.recv() {
|
||||
match event {
|
||||
mdns_sd::ServiceEvent::ServiceResolved(info) => {
|
||||
let addresses: Vec<IpAddr> = info
|
||||
.get_addresses()
|
||||
.iter()
|
||||
.map(|a| a.to_ip_addr())
|
||||
.collect();
|
||||
let port = info.get_port();
|
||||
// Our own records come back to us. Read the machine's addresses on each one,
|
||||
// because they change as the machine moves between networks.
|
||||
let local = local_addresses();
|
||||
let is_self = match advertised.lock() {
|
||||
Ok(own) => is_own_record(&own, &local, &addresses, port),
|
||||
Err(poisoned) => {
|
||||
is_own_record(&poisoned.into_inner(), &local, &addresses, port)
|
||||
}
|
||||
};
|
||||
let peer = DiscoveredPeer {
|
||||
id: PeerId::new(),
|
||||
is_self,
|
||||
name: instance_name(info.get_fullname()).to_owned(),
|
||||
fullname: info.get_fullname().to_owned(),
|
||||
addresses,
|
||||
port,
|
||||
};
|
||||
debug!(peer = %peer.fullname, own = peer.is_self, "resolved network peer");
|
||||
|
||||
match table.lock() {
|
||||
Ok(mut table) => table.insert(peer),
|
||||
Err(poisoned) => poisoned.into_inner().insert(peer),
|
||||
}
|
||||
}
|
||||
mdns_sd::ServiceEvent::ServiceRemoved(_, fullname) => match table.lock() {
|
||||
Ok(mut table) => table.remove(&fullname),
|
||||
Err(poisoned) => poisoned.into_inner().remove(&fullname),
|
||||
},
|
||||
// A service that is found but never resolves is invisible to the user, so the two
|
||||
// stages are logged separately: they fail for different reasons.
|
||||
mdns_sd::ServiceEvent::ServiceFound(kind, fullname) => {
|
||||
debug!(%kind, %fullname, "service found, awaiting resolution");
|
||||
}
|
||||
other => debug!(event = ?other, "discovery event"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the instance name of a service's full name, the part before the service type.
|
||||
///
|
||||
/// Splitting at the first dot instead cut "Mr. Keys" down to "Mr".
|
||||
fn instance_name(fullname: &str) -> &str {
|
||||
fullname
|
||||
.strip_suffix(SERVICE_TYPE)
|
||||
.and_then(|instance| instance.strip_suffix('.'))
|
||||
.unwrap_or(fullname)
|
||||
}
|
||||
|
||||
/// Returns every address this machine's interfaces hold, loopback included.
|
||||
fn local_addresses() -> Vec<IpAddr> {
|
||||
match if_addrs::get_if_addrs() {
|
||||
Ok(interfaces) => interfaces.iter().map(if_addrs::Interface::ip).collect(),
|
||||
Err(error) => {
|
||||
debug!(%error, "could not list this machine's addresses");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether a record is one this machine advertises: a port it advertises on, at an
|
||||
/// address it holds.
|
||||
///
|
||||
/// Matching by name hid another machine's session that shared a name with one of ours, and
|
||||
/// missed our own record once the responder renamed it over a conflict ("Stage (2)").
|
||||
fn is_own_record(
|
||||
advertised: &[(String, u16)],
|
||||
local: &[IpAddr],
|
||||
addresses: &[IpAddr],
|
||||
port: u16,
|
||||
) -> bool {
|
||||
advertised.iter().any(|(_, own)| *own == port)
|
||||
&& addresses.iter().any(|address| local.contains(address))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
/// Returns 192.0.2.x, an address from the range reserved for documentation.
|
||||
fn at(last_octet: u8) -> IpAddr {
|
||||
IpAddr::V4(Ipv4Addr::new(192, 0, 2, last_octet))
|
||||
}
|
||||
|
||||
/// Builds a resolved record for an Apple MIDI service as mdns-sd reports one.
|
||||
fn peer(name: &str, instance: &str, last_octet: u8, is_self: bool) -> DiscoveredPeer {
|
||||
DiscoveredPeer {
|
||||
id: PeerId::new(),
|
||||
name: name.to_owned(),
|
||||
fullname: format!("{instance}._apple-midi._udp.local."),
|
||||
addresses: vec![at(last_octet)],
|
||||
port: 5004,
|
||||
is_self,
|
||||
}
|
||||
}
|
||||
|
||||
/// Proves the instance name is everything before the service type, because DNS-SD instance
|
||||
/// names may contain dots (RFC 6763 section 4.3). Splitting at the first dot cut "Mr. Keys"
|
||||
/// down to "Mr".
|
||||
#[test]
|
||||
fn an_instance_name_is_everything_before_the_service_type() {
|
||||
let cases = [
|
||||
("Stage._apple-midi._udp.local.", "Stage"),
|
||||
("Mr. Keys._apple-midi._udp.local.", "Mr. Keys"),
|
||||
];
|
||||
for (fullname, want) in cases {
|
||||
assert_eq!(
|
||||
instance_name(fullname),
|
||||
want,
|
||||
"{fullname}: the instance name must keep any dots of its own"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Proves our own advertisement is recognised by an advertised port on one of this machine's
|
||||
/// addresses, and by nothing less: two laptops each with a session called "Stage" are peers of
|
||||
/// each other, and another session on this machine uses a port we do not.
|
||||
#[test]
|
||||
fn our_own_record_is_the_one_on_our_port_at_our_address() {
|
||||
let advertised = vec![("Stage".to_owned(), 5004)];
|
||||
let local = vec![at(10)];
|
||||
let cases = [
|
||||
("our port at our address", at(10), 5004, true),
|
||||
(
|
||||
"another machine advertising a name of ours",
|
||||
at(13),
|
||||
5004,
|
||||
false,
|
||||
),
|
||||
("another session on this machine", at(10), 5006, false),
|
||||
];
|
||||
for (name, address, port, want) in cases {
|
||||
assert_eq!(
|
||||
is_own_record(&advertised, &local, &[address], port),
|
||||
want,
|
||||
"{name}: recognised wrongly as our own record or as a peer"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Proves the peer list leaves out our own advertisement, which the responder reflects back,
|
||||
/// and labels two machines advertising one name apart by address while a unique name is shown
|
||||
/// plainly. Identical laptops advertise identical names, and two identical labels make one
|
||||
/// of them unselectable.
|
||||
#[test]
|
||||
fn the_peer_list_offers_each_other_machine_under_a_label_that_tells_it_apart() {
|
||||
let cases = [
|
||||
(
|
||||
"our own advertisement is left out",
|
||||
vec![
|
||||
peer("Studio Mac", "Studio Mac", 1, true),
|
||||
peer("Stage Laptop", "Stage Laptop", 2, false),
|
||||
],
|
||||
vec!["Stage Laptop"],
|
||||
),
|
||||
(
|
||||
"two machines of one name are labelled by address",
|
||||
vec![
|
||||
peer("MacBook Pro", "MacBook Pro", 5, false),
|
||||
peer("MacBook Pro", "MacBook Pro (2)", 6, false),
|
||||
],
|
||||
vec!["MacBook Pro (192.0.2.5)", "MacBook Pro (192.0.2.6)"],
|
||||
),
|
||||
];
|
||||
for (name, records, want) in cases {
|
||||
let mut table = PeerTable::default();
|
||||
for record in records {
|
||||
table.insert(record);
|
||||
}
|
||||
let labels: Vec<String> = table
|
||||
.connectable()
|
||||
.into_iter()
|
||||
.map(|(_, label)| label)
|
||||
.collect();
|
||||
assert_eq!(labels, want, "{name}: the peer list is wrong");
|
||||
}
|
||||
}
|
||||
|
||||
/// Proves a peer resolved again keeps the identity it was first given while taking its new
|
||||
/// address, because a peer that appeared replaced on every refresh could not be held as a
|
||||
/// stored connection, and the address is what actually changed.
|
||||
#[test]
|
||||
fn re_resolving_a_peer_keeps_its_identity() {
|
||||
let mut table = PeerTable::default();
|
||||
let first = peer("Stage", "Stage", 2, false);
|
||||
let id = first.id;
|
||||
table.insert(first);
|
||||
table.insert(peer("Stage", "Stage", 3, false));
|
||||
|
||||
let held: Vec<(PeerId, Vec<IpAddr>)> = table
|
||||
.connectable()
|
||||
.into_iter()
|
||||
.map(|(peer, _)| (peer.id, peer.addresses))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
held,
|
||||
vec![(id, vec![at(3)])],
|
||||
"the refreshed record must replace the first in place, under its identity"
|
||||
);
|
||||
}
|
||||
}
|
||||
25
crates/daemon/src/lib.rs
Normal file
25
crates/daemon/src/lib.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
//! The engine that owns every endpoint and connection.
|
||||
//!
|
||||
//! Everything MIDI-related lives here and nowhere else. Clients reach it over the gRPC contract,
|
||||
//! and no client's lifetime is tied to any resource it owns: opening, closing or crashing a
|
||||
//! window must not disturb a connection.
|
||||
|
||||
pub mod automatic;
|
||||
pub mod bluetooth;
|
||||
pub mod dataplane;
|
||||
pub mod devices;
|
||||
pub mod discovery;
|
||||
pub mod log_file;
|
||||
pub mod net;
|
||||
pub mod network_port;
|
||||
pub mod reconcile;
|
||||
pub mod server;
|
||||
pub mod service;
|
||||
pub mod session;
|
||||
pub mod state;
|
||||
pub mod supervisor;
|
||||
mod traffic_log;
|
||||
|
||||
pub use network_port::NetworkPortChange;
|
||||
pub use server::{Stopped, already_serving, run};
|
||||
pub use state::{Daemon, DaemonError, RouteRequest};
|
||||
177
crates/daemon/src/log_file.rs
Normal file
177
crates/daemon/src/log_file.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
//! The daemon's own log file, for service managers that keep none.
|
||||
//!
|
||||
//! systemd sends a unit's output to the journal, but launchd discards it unless the agent names a
|
||||
//! file, and a file launchd writes to grows without limit. The daemon writes the file itself
|
||||
//! instead, and rolls it over at a size cap, keeping one previous file beside it.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// How large the log grows before it is rolled over.
|
||||
///
|
||||
/// With the previous file kept beside it, the log takes at most twice this on disk.
|
||||
pub const LOG_CAP: u64 = 10 * 1024 * 1024;
|
||||
|
||||
/// A log file that is rolled over to `<name>.1` once it passes a size cap.
|
||||
#[derive(Debug)]
|
||||
pub struct RollingFile {
|
||||
path: PathBuf,
|
||||
file: File,
|
||||
written: u64,
|
||||
cap: u64,
|
||||
}
|
||||
|
||||
impl RollingFile {
|
||||
/// Opens the log for appending, creating it and its directory when missing.
|
||||
pub fn open(path: &Path, cap: u64) -> io::Result<Self> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let file = append(path)?;
|
||||
let written = file.metadata()?.len();
|
||||
Ok(Self {
|
||||
path: path.to_path_buf(),
|
||||
file,
|
||||
written,
|
||||
cap,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns where the previous log is kept once rolled over.
|
||||
pub fn previous(path: &Path) -> PathBuf {
|
||||
let mut name = path.as_os_str().to_owned();
|
||||
name.push(".1");
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
/// Moves the current log aside, replacing the previous one, and starts an empty log.
|
||||
fn roll_over(&mut self) -> io::Result<()> {
|
||||
self.file.flush()?;
|
||||
std::fs::rename(&self.path, Self::previous(&self.path))?;
|
||||
self.file = append(&self.path)?;
|
||||
self.written = 0;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for RollingFile {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
// A line that would take the log past its cap starts the next file, so no line is split
|
||||
// across two. A failed roll-over keeps writing to the current file rather than losing
|
||||
// the line.
|
||||
let incoming = u64::try_from(buf.len()).unwrap_or(u64::MAX);
|
||||
if self.written > 0 && self.written.saturating_add(incoming) > self.cap {
|
||||
let _ = self.roll_over();
|
||||
}
|
||||
let wrote = self.file.write(buf)?;
|
||||
self.written = self
|
||||
.written
|
||||
.saturating_add(u64::try_from(wrote).unwrap_or(u64::MAX));
|
||||
Ok(wrote)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.file.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a file for appending, creating it when missing.
|
||||
fn append(path: &Path) -> io::Result<File> {
|
||||
OpenOptions::new().create(true).append(true).open(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Proves the on-disk shape of the log: it and its directory are created and appended to
|
||||
/// across opens, a line that would take it past the cap starts a new file with exactly one
|
||||
/// previous file kept as `<name>.1`, the size already on disk counts after a restart, and no
|
||||
/// line is ever split or lost. An empty log is never rolled over, which would replace the
|
||||
/// previous file with nothing.
|
||||
#[test]
|
||||
fn the_log_rolls_over_at_its_cap_keeping_one_previous_file() {
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
file: &'static str,
|
||||
left_on_disk: Option<&'static str>,
|
||||
cap: u64,
|
||||
opens: Vec<Vec<&'static str>>,
|
||||
want: &'static str,
|
||||
want_previous: Option<&'static str>,
|
||||
}
|
||||
let cases = [
|
||||
Case {
|
||||
name: "a missing log and directory are created and appended to across opens",
|
||||
file: "nested/daemon.log",
|
||||
left_on_disk: None,
|
||||
cap: 1024,
|
||||
opens: vec![vec!["first\n"], vec!["second\n"]],
|
||||
want: "first\nsecond\n",
|
||||
want_previous: None,
|
||||
},
|
||||
Case {
|
||||
// Each line is 7 bytes, and 7 + 7 = 14 passes the cap of 10, so every line after
|
||||
// the first starts a new file.
|
||||
name: "a full log rolls over keeping only the one before the current",
|
||||
file: "daemon.log",
|
||||
left_on_disk: None,
|
||||
cap: 10,
|
||||
opens: vec![vec!["aaaaaa\n", "bbbbbb\n", "cccccc\n"]],
|
||||
want: "cccccc\n",
|
||||
want_previous: Some("bbbbbb\n"),
|
||||
},
|
||||
Case {
|
||||
// 18 bytes from the last run plus 9 now is 27, past the cap of 20.
|
||||
name: "the size already on disk counts after a restart",
|
||||
file: "daemon.log",
|
||||
left_on_disk: Some("from the last run\n"),
|
||||
cap: 20,
|
||||
opens: vec![vec!["this run\n"]],
|
||||
want: "this run\n",
|
||||
want_previous: Some("from the last run\n"),
|
||||
},
|
||||
Case {
|
||||
name: "a line longer than the cap is written whole into an empty log",
|
||||
file: "daemon.log",
|
||||
left_on_disk: None,
|
||||
cap: 4,
|
||||
opens: vec![vec!["longer than four\n"]],
|
||||
want: "longer than four\n",
|
||||
want_previous: None,
|
||||
},
|
||||
];
|
||||
|
||||
for (index, case) in cases.into_iter().enumerate() {
|
||||
let dir = std::env::temp_dir().join(format!("mh-log-{index}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = dir.join(case.file);
|
||||
if let Some(content) = case.left_on_disk {
|
||||
std::fs::create_dir_all(&dir).expect("the scratch directory is created");
|
||||
std::fs::write(&path, content).expect("the last run's log is written");
|
||||
}
|
||||
for lines in &case.opens {
|
||||
let mut log = RollingFile::open(&path, case.cap).expect("the log opens");
|
||||
for line in lines {
|
||||
log.write_all(line.as_bytes()).expect("the line is written");
|
||||
}
|
||||
log.flush().expect("the log flushes");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).expect("the current log is readable"),
|
||||
case.want,
|
||||
"{}: the current log holds the wrong lines",
|
||||
case.name
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(RollingFile::previous(&path)).ok(),
|
||||
case.want_previous.map(str::to_owned),
|
||||
"{}: the previous log holds the wrong lines",
|
||||
case.name
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
644
crates/daemon/src/net.rs
Normal file
644
crates/daemon/src/net.rs
Normal file
|
|
@ -0,0 +1,644 @@
|
|||
//! The UDP transport a network session runs over.
|
||||
//!
|
||||
//! A session needs two adjacent ports: control on `n` and data on `n + 1`. Binding them
|
||||
//! separately would sometimes get a non-adjacent pair, so they are acquired together and the
|
||||
//! attempt is retried until a usable pair is found.
|
||||
|
||||
use midi_harbor_rtpmidi::session::Port;
|
||||
use std::io;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use tokio::net::UdpSocket;
|
||||
use tracing::debug;
|
||||
|
||||
/// Largest datagram accepted, which is comfortably above any RTP-MIDI packet with a journal.
|
||||
pub const MAX_DATAGRAM: usize = 1500;
|
||||
|
||||
/// How many port pairs to try before giving up.
|
||||
const BIND_ATTEMPTS: u32 = 32;
|
||||
|
||||
/// How many times a port pair asked for by number is tried while it is held, before another
|
||||
/// pair is chosen.
|
||||
///
|
||||
/// Nine waits of `HELD_PORT_DELAY` come to 225 ms, far longer than a starting child holds a copy
|
||||
/// of a socket, and short enough that a port another program keeps delays a session's start only
|
||||
/// briefly.
|
||||
const HELD_PORT_RETRIES: u32 = 10;
|
||||
|
||||
/// How long to wait between tries of a held port pair.
|
||||
const HELD_PORT_DELAY: std::time::Duration = std::time::Duration::from_millis(25);
|
||||
|
||||
/// The port Apple's implementation uses by default.
|
||||
pub const DEFAULT_CONTROL_PORT: u16 = 5004;
|
||||
|
||||
/// Why the transport could not be established.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum NetError {
|
||||
/// Neither the requested port pair nor any nearby pair was available.
|
||||
#[error("could not bind an adjacent udp port pair after {BIND_ATTEMPTS} attempts")]
|
||||
NoPortPair,
|
||||
/// A socket operation failed.
|
||||
#[error("{operation} failed: {source}")]
|
||||
Io {
|
||||
/// What was attempted.
|
||||
operation: &'static str,
|
||||
/// The underlying failure.
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
impl NetError {
|
||||
/// Reports whether this failure means the machine has no route to send by.
|
||||
///
|
||||
/// The kernel refuses such a send at once, before anything reaches the wire, which is how "no
|
||||
/// network" is told apart from a peer that is simply not answering. Checking the machine's
|
||||
/// addresses instead is no good: an interface that is up carries a link-local address with no
|
||||
/// network behind it, and on macOS tunnel interfaces carry one even with Wi-Fi off.
|
||||
pub fn is_no_route(&self) -> bool {
|
||||
match self {
|
||||
Self::Io { source, .. } => matches!(
|
||||
source.kind(),
|
||||
io::ErrorKind::NetworkUnreachable
|
||||
| io::ErrorKind::HostUnreachable
|
||||
| io::ErrorKind::AddrNotAvailable
|
||||
),
|
||||
Self::NoPortPair => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A datagram received on one of the session's two ports.
|
||||
#[derive(Debug)]
|
||||
pub struct Datagram {
|
||||
/// Which port it arrived on.
|
||||
pub port: Port,
|
||||
/// Who sent it.
|
||||
pub from: SocketAddr,
|
||||
/// The bytes received.
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The adjacent socket pair one session runs over.
|
||||
#[derive(Debug)]
|
||||
pub struct SessionSockets {
|
||||
control: UdpSocket,
|
||||
data: UdpSocket,
|
||||
control_port: u16,
|
||||
/// Whether the sockets are dual-stack, which changes how an IPv4 peer is addressed.
|
||||
dual_stack: bool,
|
||||
}
|
||||
|
||||
impl SessionSockets {
|
||||
/// Binds an adjacent control and data port pair.
|
||||
///
|
||||
/// A requested port of zero asks the system to choose. Both sockets are dual-stack where the
|
||||
/// platform allows it, so a peer reachable only over IPv6 is still reachable.
|
||||
pub async fn bind(requested: u16) -> Result<Self, NetError> {
|
||||
let mut candidate = requested;
|
||||
|
||||
for attempt in 0..BIND_ATTEMPTS {
|
||||
// An odd control port would put the data port on an even one, which some
|
||||
// implementations refuse, so only even ports are tried.
|
||||
// Past the top of the range there is no even port to move to, so the system is asked
|
||||
// again. Saturating stayed on 65535 for every attempt.
|
||||
if candidate != 0 && !candidate.is_multiple_of(2) {
|
||||
candidate = candidate.checked_add(1).unwrap_or(0);
|
||||
}
|
||||
|
||||
// The port asked for is waited for while it is held, since moving leaves behind every
|
||||
// peer that knew it. Whatever is tried after it is not.
|
||||
let bound = if attempt == 0 && candidate != 0 {
|
||||
bind_pair_waiting(candidate).await
|
||||
} else {
|
||||
bind_pair(candidate).await
|
||||
};
|
||||
match bound {
|
||||
Ok(sockets) => return Ok(sockets),
|
||||
// The system's choice is held to the same rule. An odd port it chose was kept,
|
||||
// then recorded, and on the next start moved up one to satisfy the rule above,
|
||||
// so a peer that had connected to it found nothing there.
|
||||
Err(Refusal::Odd(port)) => candidate = port.checked_add(1).unwrap_or(0),
|
||||
Err(Refusal::Taken { control, .. }) => {
|
||||
candidate = next_candidate(control, attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(NetError::NoPortPair)
|
||||
}
|
||||
|
||||
/// Reports whether the pair starting at `control` could be bound now, releasing it again.
|
||||
///
|
||||
/// Asked before a running session is moved, so a pair that is taken refuses the move while
|
||||
/// the session still holds its old one. Stopping it first and then finding the new pair taken
|
||||
/// meant winning the old one back, and a daemon starting beside it could hold that for longer
|
||||
/// than the wait allows (R-082), leaving the session on a third pair.
|
||||
pub async fn pair_free(control: u16) -> bool {
|
||||
control.is_multiple_of(2) && bind_pair_waiting(control).await.is_ok()
|
||||
}
|
||||
|
||||
/// Returns the control port, which is what a session advertises.
|
||||
pub fn control_port(&self) -> u16 {
|
||||
self.control_port
|
||||
}
|
||||
|
||||
/// Sends a datagram on one of the two ports.
|
||||
pub async fn send(&self, port: Port, to: SocketAddr, bytes: &[u8]) -> Result<(), NetError> {
|
||||
let socket = match port {
|
||||
Port::Control => &self.control,
|
||||
Port::Data => &self.data,
|
||||
};
|
||||
socket
|
||||
.send_to(bytes, self.address_for(to))
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|source| NetError::Io {
|
||||
operation: "send",
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Waits for a datagram on either port.
|
||||
pub async fn recv(&self) -> Result<Datagram, NetError> {
|
||||
let mut control_buffer = [0u8; MAX_DATAGRAM];
|
||||
let mut data_buffer = [0u8; MAX_DATAGRAM];
|
||||
|
||||
// Both ports are watched together, because a session cannot make progress if either is
|
||||
// ignored while the other is read.
|
||||
tokio::select! {
|
||||
result = self.control.recv_from(&mut control_buffer) => {
|
||||
let (len, from) = result
|
||||
.map_err(|source| NetError::Io { operation: "receive", source })?;
|
||||
Ok(Datagram {
|
||||
port: Port::Control,
|
||||
from,
|
||||
bytes: control_buffer.get(..len).unwrap_or_default().to_vec(),
|
||||
})
|
||||
}
|
||||
result = self.data.recv_from(&mut data_buffer) => {
|
||||
let (len, from) = result
|
||||
.map_err(|source| NetError::Io { operation: "receive", source })?;
|
||||
Ok(Datagram {
|
||||
port: Port::Data,
|
||||
from,
|
||||
bytes: data_buffer.get(..len).unwrap_or_default().to_vec(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionSockets {
|
||||
/// Rewrites a target address into the form these sockets can actually send to.
|
||||
///
|
||||
/// A dual-stack socket refuses a plain IPv4 address outright: the send fails with an invalid
|
||||
/// argument rather than going nowhere quietly. IPv4 peers are the common case on a local
|
||||
/// network, so getting this wrong makes almost every peer unreachable.
|
||||
fn address_for(&self, to: SocketAddr) -> SocketAddr {
|
||||
match (self.dual_stack, to.ip()) {
|
||||
(true, IpAddr::V4(v4)) => SocketAddr::new(IpAddr::V6(v4.to_ipv6_mapped()), to.port()),
|
||||
_ => to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a port pair could not be bound.
|
||||
#[derive(Debug)]
|
||||
enum Refusal {
|
||||
/// The system chose an odd control port, which a pair may not start on.
|
||||
Odd(u16),
|
||||
/// A port of the pair could not be bound.
|
||||
Taken {
|
||||
/// The control port tried, or zero when the system was asked to choose one.
|
||||
control: u16,
|
||||
/// Whether a socket held the port, rather than the bind failing some other way.
|
||||
in_use: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Binds the pair starting at `control`, waiting out a hold on either port for up to
|
||||
/// `HELD_PORT_RETRIES` tries.
|
||||
///
|
||||
/// On Linux a process that starts another program hands the child a copy of every socket it
|
||||
/// holds, and the copies stay open until the child has started, a few milliseconds later. A
|
||||
/// session switched off and straight back on, or a daemon restarted, found its own port held by
|
||||
/// such a copy and moved to another pair, away from every peer that knew the first (R-082).
|
||||
async fn bind_pair_waiting(control: u16) -> Result<SessionSockets, Refusal> {
|
||||
let mut tries: u32 = 1;
|
||||
loop {
|
||||
match bind_pair(control).await {
|
||||
Err(Refusal::Taken { in_use: true, .. }) if tries < HELD_PORT_RETRIES => {
|
||||
tries = tries.saturating_add(1);
|
||||
tokio::time::sleep(HELD_PORT_DELAY).await;
|
||||
}
|
||||
bound => return bound,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Binds the pair starting at `control`, or at a port the system chooses for zero.
|
||||
async fn bind_pair(control: u16) -> Result<SessionSockets, Refusal> {
|
||||
let taken = |control: u16, error: &io::Error| Refusal::Taken {
|
||||
control,
|
||||
in_use: error.kind() == io::ErrorKind::AddrInUse,
|
||||
};
|
||||
let socket = bind_one(control)
|
||||
.await
|
||||
.map_err(|error| taken(control, &error))?;
|
||||
let local = socket
|
||||
.local_addr()
|
||||
.map_err(|error| taken(control, &error))?;
|
||||
let control_port = local.port();
|
||||
if !control_port.is_multiple_of(2) {
|
||||
return Err(Refusal::Odd(control_port));
|
||||
}
|
||||
|
||||
// The data port must be exactly one above, so a failure here means this pair is unusable
|
||||
// however well the control port bound.
|
||||
let data = bind_one(control_port.saturating_add(1))
|
||||
.await
|
||||
.map_err(|error| taken(control_port, &error))?;
|
||||
let dual_stack = local.is_ipv6();
|
||||
debug!(
|
||||
control = control_port,
|
||||
dual_stack, "bound session port pair"
|
||||
);
|
||||
Ok(SessionSockets {
|
||||
control: socket,
|
||||
data,
|
||||
control_port,
|
||||
dual_stack,
|
||||
})
|
||||
}
|
||||
|
||||
/// Binds one dual-stack UDP socket on the given port, or on one the system chooses for zero.
|
||||
async fn bind_one(port: u16) -> io::Result<UdpSocket> {
|
||||
// Claim the port over IPv4 first. macOS binds a dual-stack socket to a port an IPv4 socket
|
||||
// already holds, whether the port is named or chosen by the system, and the IPv4 socket
|
||||
// then receives everything sent to the port over IPv4. Binding over IPv4 is refused when
|
||||
// any IPv4 socket holds the port, and the system's IPv4 choice avoids ports in use.
|
||||
let port = {
|
||||
let claim = UdpSocket::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, port))).await?;
|
||||
claim.local_addr()?.port()
|
||||
};
|
||||
|
||||
// Binding the unspecified IPv6 address gives IPv4 too, once asked for, so one socket serves
|
||||
// peers on either family.
|
||||
match bind_dual_stack(port) {
|
||||
Ok(socket) => Ok(socket),
|
||||
// A port held over IPv6 is refused, not bound over IPv4 alone. The other socket of the
|
||||
// pair is dual-stack, sends to it are addressed as IPv6, and an IPv4 socket refuses
|
||||
// every one: the session never got past inviting the data port.
|
||||
Err(error) if error.kind() == io::ErrorKind::AddrInUse => Err(error),
|
||||
// A system with IPv6 disabled still has to work.
|
||||
Err(_) => UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0], port))).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Binds a UDP socket on the unspecified IPv6 address that takes IPv4 as well.
|
||||
///
|
||||
/// Linux and macOS make such a socket dual-stack by default; Windows makes it IPv6-only, and a
|
||||
/// Windows daemon's every send to an IPv4 peer failed with "the requested address is not valid in
|
||||
/// its context" (research R-085). Asking explicitly gives the same socket everywhere, and so does
|
||||
/// claiming the port exclusively, which Windows otherwise shares (R-088).
|
||||
fn bind_dual_stack(port: u16) -> io::Result<UdpSocket> {
|
||||
let socket = socket2::Socket::new(
|
||||
socket2::Domain::IPV6,
|
||||
socket2::Type::DGRAM,
|
||||
Some(socket2::Protocol::UDP),
|
||||
)?;
|
||||
socket.set_only_v6(false)?;
|
||||
midi_harbor_platform::socket::claim_exclusively(&socket)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
socket.bind(&SocketAddr::from((Ipv6Addr::UNSPECIFIED, port)).into())?;
|
||||
UdpSocket::from_std(socket.into())
|
||||
}
|
||||
|
||||
/// Picks the next port pair to try after a failed attempt.
|
||||
///
|
||||
/// The first retry asks the system to choose, which almost always succeeds; later retries step
|
||||
/// upward in case the system keeps handing back an unusable neighbour.
|
||||
fn next_candidate(previous: u16, attempt: u32) -> u16 {
|
||||
if attempt == 0 {
|
||||
return 0;
|
||||
}
|
||||
previous.checked_add(2).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Chooses the address to reach a peer on, from everything it advertised.
|
||||
///
|
||||
/// Discovery commonly reports a dozen addresses across bridge, loopback and link-local
|
||||
/// interfaces. Taking the first would often pick one that cannot route to the peer at all.
|
||||
pub fn choose_peer_address(addresses: &[IpAddr], port: u16) -> Option<SocketAddr> {
|
||||
let score = |address: &IpAddr| match address {
|
||||
// A routable address on a real interface is always the right answer.
|
||||
IpAddr::V4(v4) if !v4.is_loopback() && !v4.is_link_local() => 4,
|
||||
IpAddr::V6(v6) if !v6.is_loopback() && !is_link_local_v6(v6) => 3,
|
||||
// Link-local works within one segment, so it beats loopback.
|
||||
IpAddr::V4(v4) if !v4.is_loopback() => 2,
|
||||
IpAddr::V6(v6) if !v6.is_loopback() => 2,
|
||||
// Loopback only reaches this machine, which is almost never what was meant.
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
addresses
|
||||
.iter()
|
||||
.max_by_key(|address| score(address))
|
||||
.map(|address| SocketAddr::new(*address, port))
|
||||
}
|
||||
|
||||
/// Reports whether an IPv6 address is link-local, which needs a scope to be usable.
|
||||
fn is_link_local_v6(address: &Ipv6Addr) -> bool {
|
||||
address
|
||||
.segments()
|
||||
.first()
|
||||
.is_some_and(|first| (first & 0xFFC0) == 0xFE80)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
/// Proves which socket errors count as having no network, the distinction that lets a lost
|
||||
/// link blame the network rather than the peer (R-069). An unreachable network or host, or an
|
||||
/// address that went away with its interface, means no route; a peer refusing or a full
|
||||
/// buffer says nothing about whether there is a network.
|
||||
#[test]
|
||||
fn only_a_missing_route_counts_as_no_network() {
|
||||
let cases = [
|
||||
(io::ErrorKind::NetworkUnreachable, true),
|
||||
(io::ErrorKind::HostUnreachable, true),
|
||||
(io::ErrorKind::AddrNotAvailable, true),
|
||||
(io::ErrorKind::ConnectionRefused, false),
|
||||
(io::ErrorKind::WouldBlock, false),
|
||||
];
|
||||
for (kind, want) in cases {
|
||||
let failure = NetError::Io {
|
||||
operation: "send",
|
||||
source: io::Error::from(kind),
|
||||
};
|
||||
assert_eq!(
|
||||
failure.is_no_route(),
|
||||
want,
|
||||
"{kind:?}: classified wrongly as having or lacking a network"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks for a pair in a shape a caller can request.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Request {
|
||||
/// Port zero, sixteen times with every pair held, so the system chooses sixteen.
|
||||
SystemChoice,
|
||||
/// 65535, which is odd and has nothing above it.
|
||||
Highest,
|
||||
/// The control port of a pair another session already holds.
|
||||
Held,
|
||||
}
|
||||
|
||||
/// Proves that however a pair is asked for, binding yields an even control port with the data
|
||||
/// port directly above it, as RTP-MIDI requires. An odd port the system chose moved the next
|
||||
/// time the session started, away from any peer that knew it; 65535 once kept binding on
|
||||
/// itself for every attempt and gave up; and a held port must fall back to another working
|
||||
/// pair rather than fail the session.
|
||||
#[tokio::test]
|
||||
async fn binding_always_yields_an_even_adjacent_pair() {
|
||||
for request in [Request::SystemChoice, Request::Highest, Request::Held] {
|
||||
let occupied = SessionSockets::bind(0)
|
||||
.await
|
||||
.expect("a first pair binds to be held");
|
||||
let (asked, times) = match request {
|
||||
Request::SystemChoice => (0, 16),
|
||||
Request::Highest => (u16::MAX, 1),
|
||||
Request::Held => (occupied.control_port(), 1),
|
||||
};
|
||||
let mut held = Vec::new();
|
||||
for _ in 0..times {
|
||||
let sockets = SessionSockets::bind(asked)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{request:?}: no pair was bound: {error}"));
|
||||
let control = sockets.control_port();
|
||||
assert!(
|
||||
control != 0 && control.is_multiple_of(2),
|
||||
"{request:?}: the control port {control} must be even and real"
|
||||
);
|
||||
assert_eq!(
|
||||
sockets.data.local_addr().map(|a| a.port()).ok(),
|
||||
control.checked_add(1),
|
||||
"{request:?}: the data port must sit directly above the control port"
|
||||
);
|
||||
if matches!(request, Request::Held) {
|
||||
assert_ne!(
|
||||
control,
|
||||
occupied.control_port(),
|
||||
"a held pair must not be shared"
|
||||
);
|
||||
}
|
||||
held.push(sockets);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Proves no other socket can bind either port of a bound pair, over IPv4 or IPv6. Windows let
|
||||
/// a plain socket bind the port of a dual-stack socket beside it, and take its datagrams,
|
||||
/// until the pair claimed its ports exclusively.
|
||||
#[tokio::test]
|
||||
async fn no_other_socket_can_take_a_bound_pair() {
|
||||
let sockets = SessionSockets::bind(0)
|
||||
.await
|
||||
.expect("a pair binds on a system-chosen port");
|
||||
for port in [sockets.control_port(), sockets.control_port() + 1] {
|
||||
assert!(
|
||||
std::net::UdpSocket::bind((Ipv4Addr::UNSPECIFIED, port)).is_err(),
|
||||
"an IPv4 socket bound {port}, and would take the session's datagrams"
|
||||
);
|
||||
assert!(
|
||||
std::net::UdpSocket::bind((Ipv6Addr::UNSPECIFIED, port)).is_err(),
|
||||
"an IPv6 socket bound {port}, and would take the session's datagrams"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a datagram over IPv4 to each of the pair's ports and checks the pair receives it.
|
||||
async fn assert_reachable_over_ipv4(sockets: &SessionSockets, held: Port) {
|
||||
let control_port = sockets.control_port();
|
||||
let sender = UdpSocket::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("a sender binds on loopback");
|
||||
|
||||
for (port, target) in [
|
||||
(Port::Control, control_port),
|
||||
(Port::Data, control_port + 1),
|
||||
] {
|
||||
let to = SocketAddr::from((Ipv4Addr::LOCALHOST, target));
|
||||
sender
|
||||
.send_to(b"hello", to)
|
||||
.await
|
||||
.expect("the datagram is sent");
|
||||
|
||||
let received = tokio::time::timeout(std::time::Duration::from_secs(2), sockets.recv())
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"with the {held:?} port held over IPv4, nothing arrived on the {port:?} port {target}"
|
||||
)
|
||||
})
|
||||
.expect("a datagram is received");
|
||||
assert_eq!(
|
||||
(received.port, received.bytes.as_slice()),
|
||||
(port, b"hello".as_slice()),
|
||||
"with the {held:?} port held over IPv4, the datagram arrived on the wrong port"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Proves a pair is never bound on a port an IPv4-only socket already holds. macOS lets a
|
||||
/// dual-stack socket bind such a port while the IPv4 socket goes on receiving everything sent
|
||||
/// to it over IPv4, so a session given that port never heard its peer.
|
||||
#[tokio::test]
|
||||
async fn a_port_held_over_ipv4_is_not_shared() {
|
||||
for held in [Port::Control, Port::Data] {
|
||||
// Hold the control or the data port of some even pair, over IPv4 only.
|
||||
let (holder, pair) = loop {
|
||||
let holder = std::net::UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))
|
||||
.expect("a system-chosen IPv4 port binds");
|
||||
let port = holder
|
||||
.local_addr()
|
||||
.expect("a bound socket has an address")
|
||||
.port();
|
||||
match (held, port % 2) {
|
||||
(Port::Control, 0) => break (holder, port),
|
||||
(Port::Data, 1) => break (holder, port - 1),
|
||||
_ => continue,
|
||||
}
|
||||
};
|
||||
|
||||
let sockets = SessionSockets::bind(pair)
|
||||
.await
|
||||
.expect("a pair binds beside the held port");
|
||||
assert_reachable_over_ipv4(&sockets, held).await;
|
||||
drop(holder);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an even port that is free over IPv4, with the one above it free too.
|
||||
fn free_pair() -> u16 {
|
||||
loop {
|
||||
let probe = std::net::UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))
|
||||
.expect("a system-chosen IPv4 port binds");
|
||||
let port = probe
|
||||
.local_addr()
|
||||
.expect("a bound socket has an address")
|
||||
.port();
|
||||
drop(probe);
|
||||
if port.is_multiple_of(2)
|
||||
&& port < u16::MAX
|
||||
&& std::net::UdpSocket::bind((Ipv4Addr::UNSPECIFIED, port + 1)).is_ok()
|
||||
{
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Proves a port held only for a moment is waited for rather than abandoned. A child process
|
||||
/// holds a copy of every socket until it has started, so a session switched off and on while
|
||||
/// the daemon started a program found its own port held, moved to another, and left its peers
|
||||
/// retrying the old one.
|
||||
#[tokio::test]
|
||||
async fn a_port_held_for_a_moment_is_waited_for() {
|
||||
let pair = free_pair();
|
||||
let holder = std::net::UdpSocket::bind((Ipv4Addr::UNSPECIFIED, pair))
|
||||
.expect("the free port binds to be held");
|
||||
let released = tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
drop(holder);
|
||||
});
|
||||
|
||||
let sockets = SessionSockets::bind(pair)
|
||||
.await
|
||||
.expect("the pair binds once released");
|
||||
assert_eq!(
|
||||
sockets.control_port(),
|
||||
pair,
|
||||
"the session moved off its port instead of waiting for the brief holder"
|
||||
);
|
||||
released.await.expect("the holder is released");
|
||||
}
|
||||
|
||||
/// Proves a data port held over IPv6 leaves no IPv4-only socket in the pair. The claim over
|
||||
/// IPv4 succeeds beside an IPv6 socket on the loopback address and the dual-stack bind then
|
||||
/// fails; falling back to IPv4 alone put an IPv4 data socket beside a dual-stack control
|
||||
/// socket, which refused every send to an IPv4 peer.
|
||||
#[tokio::test]
|
||||
async fn a_port_held_over_ipv6_leaves_no_ipv4_only_socket_in_the_pair() {
|
||||
let pair = free_pair();
|
||||
let _holder = std::net::UdpSocket::bind((Ipv6Addr::LOCALHOST, pair + 1))
|
||||
.expect("the data port binds over IPv6 to be held");
|
||||
|
||||
let sockets = SessionSockets::bind(pair)
|
||||
.await
|
||||
.expect("a pair binds beside the IPv6 holder");
|
||||
let peer = UdpSocket::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("an IPv4 peer binds on loopback");
|
||||
let to = peer.local_addr().expect("a bound socket has an address");
|
||||
sockets
|
||||
.send(Port::Data, to, b"hello")
|
||||
.await
|
||||
.expect("the data port sends to an IPv4 peer");
|
||||
let mut buffer = [0u8; 16];
|
||||
let received = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
peer.recv_from(&mut buffer),
|
||||
)
|
||||
.await
|
||||
.expect("the datagram arrives within two seconds")
|
||||
.expect("a datagram is received");
|
||||
assert_eq!(
|
||||
buffer.get(..received.0),
|
||||
Some(b"hello".as_slice()),
|
||||
"the IPv4 peer must receive exactly what the data port sent"
|
||||
);
|
||||
}
|
||||
|
||||
/// Proves which advertised address a peer is reached at. Discovery reports many addresses and
|
||||
/// the first is often one that cannot route, so a routable address beats link-local, which
|
||||
/// works within one segment and so beats loopback, which only reaches this machine; an
|
||||
/// IPv6-only peer is still reachable.
|
||||
#[test]
|
||||
fn a_peer_is_reached_at_its_most_routable_address() {
|
||||
let routable = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 4));
|
||||
let link_local = IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1));
|
||||
let global_v6 = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
|
||||
let cases = [
|
||||
(
|
||||
"routable beats loopback and link-local",
|
||||
vec![
|
||||
IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
link_local,
|
||||
routable,
|
||||
],
|
||||
Some(routable),
|
||||
),
|
||||
(
|
||||
"link-local beats loopback",
|
||||
vec![IpAddr::V4(Ipv4Addr::LOCALHOST), link_local],
|
||||
Some(link_local),
|
||||
),
|
||||
(
|
||||
"an IPv6-only peer is reachable",
|
||||
vec![global_v6],
|
||||
Some(global_v6),
|
||||
),
|
||||
("no address reaches nothing", vec![], None),
|
||||
];
|
||||
for (name, addresses, want) in cases {
|
||||
assert_eq!(
|
||||
choose_peer_address(&addresses, 5004),
|
||||
want.map(|address| SocketAddr::new(address, 5004)),
|
||||
"{name}: the wrong address was chosen"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
314
crates/daemon/src/network_port.rs
Normal file
314
crates/daemon/src/network_port.rs
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
//! Changing a network port after it is made: the name other machines see, its UDP port, who may
|
||||
//! join, and its automatic port (FR-015, R-078).
|
||||
|
||||
use crate::state::{Change, Daemon, DaemonError};
|
||||
use midi_harbor_core::config;
|
||||
use midi_harbor_core::endpoint::{
|
||||
Endpoint, EndpointKind, EndpointName, InvitationPolicy, NetworkSession,
|
||||
};
|
||||
use midi_harbor_core::failure::FailureReason;
|
||||
use midi_harbor_core::ids::EndpointId;
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// What to change about a network port. A field left `None` is left as it is.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct NetworkPortChange {
|
||||
/// The name other machines see it by, its Bonjour name.
|
||||
pub local_name: Option<String>,
|
||||
/// The UDP port it listens on; zero lets the system choose.
|
||||
pub control_port: Option<u16>,
|
||||
/// How it treats invitations.
|
||||
pub policy: Option<InvitationPolicy>,
|
||||
/// Whether other applications see it as a MIDI port of its name.
|
||||
pub automatic_port: Option<bool>,
|
||||
}
|
||||
|
||||
/// Refuses a UDP port a network port cannot listen on. Zero lets the system choose.
|
||||
///
|
||||
/// The data port is the next one up, and binding moves an odd control port up one because some
|
||||
/// implementations refuse an odd data port, so only an even port is taken as asked.
|
||||
pub(crate) fn check_control_port(port: u16) -> Result<(), DaemonError> {
|
||||
if !port.is_multiple_of(2) {
|
||||
return Err(FailureReason::ConfigInvalid {
|
||||
detail: format!(
|
||||
"UDP port {port} is odd, and RTP-MIDI needs an even one with the data port above \
|
||||
it; choose {} or {}",
|
||||
port.saturating_sub(1),
|
||||
port.saturating_add(1)
|
||||
),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Daemon {
|
||||
/// Changes a network port's settings and applies them to it while it runs.
|
||||
///
|
||||
/// A new name reaches the machines that connect from then on, and those already connected
|
||||
/// stay connected. A new UDP port means listening again, so the network port restarts: a
|
||||
/// machine it connected to is connected to again, and a machine that connected to it has to
|
||||
/// be told the new port.
|
||||
pub async fn update_network_port(
|
||||
self: &Arc<Self>,
|
||||
id: EndpointId,
|
||||
change: NetworkPortChange,
|
||||
) -> Result<Endpoint, DaemonError> {
|
||||
// Validate the change.
|
||||
let local_name = change
|
||||
.local_name
|
||||
.as_deref()
|
||||
.map(EndpointName::new)
|
||||
.transpose()?;
|
||||
if let Some(port) = change.control_port {
|
||||
check_control_port(port)?;
|
||||
// A running port moved to a pair that is taken is refused before it is touched. One
|
||||
// another network port has is left to the check below, which names it.
|
||||
let running = self.sessions.read().await.get(&id).map(Arc::clone);
|
||||
let configured = self
|
||||
.read(|config, _| {
|
||||
config.endpoints.iter().any(|other| {
|
||||
other.id != id
|
||||
&& matches!(&other.kind, EndpointKind::NetworkSession(session)
|
||||
if session.control_port == port)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
if let Some(session) = running
|
||||
&& port != 0
|
||||
&& port != session.control_port()
|
||||
&& !configured
|
||||
&& !crate::net::SessionSockets::pair_free(port).await
|
||||
{
|
||||
return Err(FailureReason::ConfigInvalid {
|
||||
detail: format!("UDP port {port} or the one above it is in use"),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Record it.
|
||||
let (before, updated) = {
|
||||
let mut inner = self.inner.write().await;
|
||||
for other in inner.config.endpoints.iter().filter(|e| e.id != id) {
|
||||
let EndpointKind::NetworkSession(session) = &other.kind else {
|
||||
continue;
|
||||
};
|
||||
// Two network ports advertising one name look like one machine to every peer.
|
||||
if local_name.as_ref() == Some(&session.local_name) {
|
||||
return Err(FailureReason::NameConflict {
|
||||
name: session.local_name.as_str().to_owned(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
if let Some(port) = change.control_port
|
||||
&& port != 0
|
||||
&& port == session.control_port
|
||||
{
|
||||
return Err(FailureReason::ConfigInvalid {
|
||||
detail: format!("UDP port {port} is already used by '{}'", other.name),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
}
|
||||
let Some(endpoint) = inner.config.endpoints.iter_mut().find(|e| e.id == id) else {
|
||||
return Err(DaemonError::NotFound(id.to_string()));
|
||||
};
|
||||
let EndpointKind::NetworkSession(held) = &mut endpoint.kind else {
|
||||
return Err(FailureReason::ConfigInvalid {
|
||||
detail: format!("{} is not a network port", endpoint.name),
|
||||
}
|
||||
.into());
|
||||
};
|
||||
let before = held.clone();
|
||||
if let Some(name) = local_name {
|
||||
held.local_name = name;
|
||||
}
|
||||
if let Some(port) = change.control_port {
|
||||
held.control_port = port;
|
||||
}
|
||||
if let Some(policy) = change.policy {
|
||||
held.invitation_policy = policy;
|
||||
}
|
||||
if let Some(on) = change.automatic_port {
|
||||
held.automatic_port = on;
|
||||
}
|
||||
let updated = endpoint.clone();
|
||||
config::save(&self.paths, &inner.config)?;
|
||||
(before, updated)
|
||||
};
|
||||
let EndpointKind::NetworkSession(after) = &updated.kind else {
|
||||
return Ok(updated);
|
||||
};
|
||||
|
||||
// Apply it to the running session.
|
||||
let running = self.sessions.read().await.get(&id).map(Arc::clone);
|
||||
if let Some(session) = running {
|
||||
if after.control_port != before.control_port {
|
||||
info!(endpoint = %updated.name, port = after.control_port, "network port moving to a new UDP port");
|
||||
self.stop_session(id, before.local_name.as_str()).await;
|
||||
// Binding moves to a nearby pair when the one asked for is taken, which keeps a
|
||||
// network port up after a restart but here would put it somewhere the user did
|
||||
// not choose.
|
||||
let started = self.start_session(&updated).await;
|
||||
let listening = self
|
||||
.session_status(id)
|
||||
.await
|
||||
.map(|status| status.control_port);
|
||||
let refused = match started {
|
||||
Err(error) => Some(error),
|
||||
Ok(()) if after.control_port != 0 && listening != Some(after.control_port) => {
|
||||
Some(
|
||||
FailureReason::ConfigInvalid {
|
||||
detail: format!(
|
||||
"UDP port {} or the one above it is in use",
|
||||
after.control_port
|
||||
),
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
Ok(()) => None,
|
||||
};
|
||||
if let Some(error) = refused {
|
||||
// Put it back as it was, so a port that could not be bound leaves the network
|
||||
// port neither down, nor somewhere else, nor half changed.
|
||||
warn!(endpoint = %updated.name, error = %error, "could not listen on the new UDP port; keeping the old settings");
|
||||
self.stop_session(id, after.local_name.as_str()).await;
|
||||
self.restore(id, before).await;
|
||||
return Err(error);
|
||||
}
|
||||
} else if after.local_name != before.local_name {
|
||||
let _ = session.rename(after.local_name.as_str().to_owned()).await;
|
||||
if let Some(discovery) = &self.discovery {
|
||||
discovery.withdraw(before.local_name.as_str());
|
||||
let announce = self
|
||||
.read(|config, _| config.preferences.advertise_sessions)
|
||||
.await;
|
||||
if announce
|
||||
&& let Err(error) =
|
||||
discovery.advertise(after.local_name.as_str(), session.control_port())
|
||||
{
|
||||
warn!(endpoint = %updated.name, error = %error, "could not advertise the new name");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if change.policy.is_some() {
|
||||
self.push_invitation_policy().await;
|
||||
}
|
||||
if change.automatic_port.is_some() {
|
||||
self.settle_automatic_port(&updated, false).await;
|
||||
}
|
||||
let _ = self.changes.send(Change::EndpointChanged(id));
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Puts a network port's settings back as they were, and starts it with them.
|
||||
async fn restore(self: &Arc<Self>, id: EndpointId, settings: NetworkSession) {
|
||||
let restored = {
|
||||
let mut inner = self.inner.write().await;
|
||||
let Some(endpoint) = inner.config.endpoints.iter_mut().find(|e| e.id == id) else {
|
||||
return;
|
||||
};
|
||||
if let EndpointKind::NetworkSession(held) = &mut endpoint.kind {
|
||||
*held = settings;
|
||||
}
|
||||
let restored = endpoint.clone();
|
||||
if let Err(error) = config::save(&self.paths, &inner.config) {
|
||||
warn!(error = %error, "could not persist a network port's restored settings");
|
||||
}
|
||||
restored
|
||||
};
|
||||
if let Err(error) = self.start_session(&restored).await {
|
||||
warn!(endpoint = %restored.name, error = %error, "could not listen on the old UDP port either");
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a network port, reporting the routes it leaves broken.
|
||||
///
|
||||
/// Everything it was carrying is stopped first: the notes it played here, and those it sent
|
||||
/// its machines, which have no way to stop them once it has gone. Its routes stay, broken,
|
||||
/// and mend if a network port of its name returns.
|
||||
pub async fn delete_network_port(
|
||||
self: &Arc<Self>,
|
||||
id: EndpointId,
|
||||
) -> Result<Vec<String>, DaemonError> {
|
||||
let local_name = match self.read(|config, _| config.endpoint(id).cloned()).await {
|
||||
Some(Endpoint {
|
||||
kind: EndpointKind::NetworkSession(session),
|
||||
..
|
||||
}) => session.local_name,
|
||||
Some(endpoint) => {
|
||||
return Err(FailureReason::ConfigInvalid {
|
||||
detail: format!("{} is not a network port", endpoint.name),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
None => return Err(DaemonError::NotFound(id.to_string())),
|
||||
};
|
||||
|
||||
// Stop it while it can still be heard.
|
||||
self.silence_routes_from(id).await;
|
||||
self.stop_session(id, local_name.as_str()).await;
|
||||
// An invitation waiting on it could only be answered for a network port that is gone.
|
||||
self.invitations
|
||||
.write()
|
||||
.await
|
||||
.retain(|_, invitation| invitation.session != id);
|
||||
|
||||
// Forget it.
|
||||
let (name, orphaned) = {
|
||||
let mut inner = self.inner.write().await;
|
||||
let Some(index) = inner.config.endpoints.iter().position(|e| e.id == id) else {
|
||||
return Err(DaemonError::NotFound(id.to_string()));
|
||||
};
|
||||
let endpoint = inner.config.endpoints.remove(index);
|
||||
let orphaned: Vec<String> = inner
|
||||
.config
|
||||
.routes
|
||||
.iter()
|
||||
.filter(|route| route.touches(&endpoint))
|
||||
.map(|route| format!("{} -> {}", route.from, route.to))
|
||||
.collect();
|
||||
config::save(&self.paths, &inner.config)?;
|
||||
(endpoint.name.to_string(), orphaned)
|
||||
};
|
||||
let _ = self.changes.send(Change::EndpointRemoved(id));
|
||||
info!(endpoint = %name, "network port deleted");
|
||||
Ok(orphaned)
|
||||
}
|
||||
|
||||
/// Switches a network port's automatic port on or off, and keeps the choice.
|
||||
pub async fn set_automatic_port(
|
||||
self: &Arc<Self>,
|
||||
id: EndpointId,
|
||||
on: bool,
|
||||
) -> Result<Endpoint, DaemonError> {
|
||||
self.update_network_port(
|
||||
id,
|
||||
NetworkPortChange {
|
||||
automatic_port: Some(on),
|
||||
..NetworkPortChange::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Changes how a network port treats invitations.
|
||||
pub async fn set_invitation_policy(
|
||||
self: &Arc<Self>,
|
||||
id: EndpointId,
|
||||
policy: InvitationPolicy,
|
||||
) -> Result<Endpoint, DaemonError> {
|
||||
self.update_network_port(
|
||||
id,
|
||||
NetworkPortChange {
|
||||
policy: Some(policy),
|
||||
..NetworkPortChange::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
422
crates/daemon/src/reconcile.rs
Normal file
422
crates/daemon/src/reconcile.rs
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
//! Applying a changed configuration to a running daemon.
|
||||
//!
|
||||
//! Reloading the file and importing a setup both end here, and both have to honour FR-050: only
|
||||
//! the connections whose configuration actually changed are disturbed. Each endpoint in the new
|
||||
//! document is compared with the one running, and what differs decides how much is done to it:
|
||||
//! nothing, a change made in place, a switch on or off, or closing and reopening it.
|
||||
|
||||
use crate::state::{Change, Daemon, DaemonError};
|
||||
use midi_harbor_core::config::{self, ConfigError, Configuration};
|
||||
use midi_harbor_core::endpoint::{Endpoint, EndpointKind};
|
||||
use midi_harbor_core::events::{EventKind, Severity};
|
||||
use midi_harbor_core::ids::EndpointId;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// What applying a configuration did, by endpoint name.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Applied {
|
||||
/// Endpoints that did not exist before.
|
||||
pub added: Vec<String>,
|
||||
/// Endpoints that no longer exist.
|
||||
pub removed: Vec<String>,
|
||||
/// Endpoints closed and opened again because something they depend on changed.
|
||||
pub restarted: Vec<String>,
|
||||
/// Routes that did not exist before.
|
||||
pub routes_added: usize,
|
||||
/// Settings accepted but only used from the next start, named so the caller can say so.
|
||||
pub pending_restart: Vec<String>,
|
||||
}
|
||||
|
||||
/// How much applying a change to one endpoint disturbs it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Disturbance {
|
||||
/// Nothing that is stored differs.
|
||||
None,
|
||||
/// Only something that can change while it runs, such as a label or an invitation policy.
|
||||
InPlace,
|
||||
/// Switched on or off, and nothing else that would need a restart.
|
||||
Toggled,
|
||||
/// Something it was opened with differs, so it is closed and opened again.
|
||||
Restart,
|
||||
}
|
||||
|
||||
impl Daemon {
|
||||
/// Returns the configuration as the document `save` would write.
|
||||
pub async fn export_configuration(&self) -> Result<String, DaemonError> {
|
||||
let inner = self.inner.read().await;
|
||||
Ok(config::to_text(&inner.config)?)
|
||||
}
|
||||
|
||||
/// Reads the configuration file again and applies what changed in it.
|
||||
///
|
||||
/// A file that cannot be read is refused and left where it is, with everything still
|
||||
/// running. At startup the daemon falls back to defaults instead, because it has nothing to
|
||||
/// lose; here, falling back would tear down a working setup over a typo.
|
||||
pub async fn reload_configuration(self: &Arc<Self>) -> Result<Applied, DaemonError> {
|
||||
let path = self.paths.config_file();
|
||||
let text = std::fs::read_to_string(&path).map_err(|source| {
|
||||
DaemonError::Config(ConfigError::Io {
|
||||
operation: "read",
|
||||
path: path.clone(),
|
||||
source,
|
||||
})
|
||||
})?;
|
||||
let incoming = config::parse(&text)?;
|
||||
self.apply(incoming).await
|
||||
}
|
||||
|
||||
/// Applies a setup exported from this machine or another.
|
||||
///
|
||||
/// Merging adds what this machine lacks and changes nothing it has. Replacing makes the setup
|
||||
/// match the document. Either way an endpoint that matches one here by kind and name keeps
|
||||
/// this machine's identity for it, so a setup moved between machines lines up with what is
|
||||
/// already running instead of restarting it, and this machine keeps its own name.
|
||||
pub async fn import_configuration(
|
||||
self: &Arc<Self>,
|
||||
text: &str,
|
||||
replace: bool,
|
||||
) -> Result<Applied, DaemonError> {
|
||||
let offered = config::parse(text)?;
|
||||
let current = self.inner.read().await.config.clone();
|
||||
let incoming = if replace {
|
||||
replaced(¤t, offered)
|
||||
} else {
|
||||
merged(¤t, offered)
|
||||
};
|
||||
// A merge matches endpoints by kind and name, so a network port offered under a virtual
|
||||
// port's name here, or the other way round, would be added beside it.
|
||||
config::check_names(&incoming)?;
|
||||
self.apply(incoming).await
|
||||
}
|
||||
|
||||
/// Makes the running setup match `incoming`, disturbing only what differs.
|
||||
async fn apply(self: &Arc<Self>, mut incoming: Configuration) -> Result<Applied, DaemonError> {
|
||||
let current = self.inner.read().await.config.clone();
|
||||
let mut applied = Applied::default();
|
||||
|
||||
// Decide what happens to every endpoint before touching any of them.
|
||||
let mut taken_down: Vec<Endpoint> = Vec::new();
|
||||
let mut changed: Vec<EndpointId> = Vec::new();
|
||||
for old in ¤t.endpoints {
|
||||
match incoming.endpoints.iter().find(|new| new.id == old.id) {
|
||||
None => {
|
||||
applied.removed.push(old.name.to_string());
|
||||
taken_down.push(old.clone());
|
||||
}
|
||||
Some(new) => match disturbance(old, new) {
|
||||
Disturbance::None => {}
|
||||
Disturbance::InPlace => changed.push(old.id),
|
||||
Disturbance::Toggled => {
|
||||
changed.push(old.id);
|
||||
taken_down.push(old.clone());
|
||||
}
|
||||
Disturbance::Restart => {
|
||||
applied.restarted.push(new.name.to_string());
|
||||
changed.push(old.id);
|
||||
taken_down.push(old.clone());
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
let existing: HashSet<EndpointId> = current.endpoints.iter().map(|e| e.id).collect();
|
||||
let added: Vec<EndpointId> = incoming
|
||||
.endpoints
|
||||
.iter()
|
||||
.filter(|endpoint| !existing.contains(&endpoint.id))
|
||||
.map(|endpoint| {
|
||||
applied.added.push(endpoint.name.to_string());
|
||||
endpoint.id
|
||||
})
|
||||
.collect();
|
||||
applied.routes_added = incoming
|
||||
.routes
|
||||
.iter()
|
||||
.filter(|route| !current.routes.iter().any(|held| held.same_ends(route)))
|
||||
.count();
|
||||
|
||||
// Tear down while the old routes still exist, because silencing what an endpoint was
|
||||
// sending finds the destinations through them.
|
||||
for endpoint in &taken_down {
|
||||
self.take_down(endpoint).await;
|
||||
}
|
||||
|
||||
// Swap the document in. What the daemon observes about hardware and radios is not
|
||||
// stored, so it is carried over. The device refresh below would work it out again, but
|
||||
// until then every device would read as absent to anyone looking, and the retry loop
|
||||
// skips absent hardware.
|
||||
carry_observations(¤t, &mut incoming);
|
||||
let advertising_changed =
|
||||
current.preferences.bluetooth_advertising != incoming.preferences.bluetooth_advertising;
|
||||
let announcing_changed =
|
||||
current.preferences.advertise_sessions != incoming.preferences.advertise_sessions;
|
||||
{
|
||||
let mut inner = self.inner.write().await;
|
||||
inner.config = incoming.clone();
|
||||
crate::state::sync_route_counters(&mut inner);
|
||||
config::save(&self.paths, &inner.config)?;
|
||||
}
|
||||
|
||||
// Bring up whatever should be running and is not.
|
||||
self.reconcile().await;
|
||||
self.start_configured_sessions().await;
|
||||
self.push_invitation_policy().await;
|
||||
self.refresh_devices().await;
|
||||
if advertising_changed {
|
||||
let enabled = incoming.preferences.bluetooth_advertising;
|
||||
if let Err(error) = self.set_peripheral_advertising(enabled, None).await {
|
||||
warn!(error = %error, "could not apply the bluetooth advertising preference");
|
||||
}
|
||||
}
|
||||
if announcing_changed {
|
||||
self.apply_session_advertising(incoming.preferences.advertise_sessions)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Tell clients, and keep a record of what the change did.
|
||||
for endpoint in ¤t.endpoints {
|
||||
if !incoming.endpoints.iter().any(|new| new.id == endpoint.id) {
|
||||
let _ = self.changes.send(Change::EndpointRemoved(endpoint.id));
|
||||
}
|
||||
}
|
||||
for id in added {
|
||||
let _ = self.changes.send(Change::EndpointAdded(id));
|
||||
}
|
||||
for id in changed {
|
||||
let _ = self.changes.send(Change::EndpointChanged(id));
|
||||
}
|
||||
let summary = format!(
|
||||
"configuration applied: {} added, {} removed, {} restarted, {} new routes",
|
||||
applied.added.len(),
|
||||
applied.removed.len(),
|
||||
applied.restarted.len(),
|
||||
applied.routes_added
|
||||
);
|
||||
info!(
|
||||
added = applied.added.len(),
|
||||
removed = applied.removed.len(),
|
||||
restarted = applied.restarted.len(),
|
||||
"configuration applied"
|
||||
);
|
||||
{
|
||||
let mut inner = self.inner.write().await;
|
||||
let now = self.clock.now();
|
||||
let _ = inner.events.record(midi_harbor_core::events::event(
|
||||
EventKind::ConfigurationChanged,
|
||||
Severity::Info,
|
||||
now,
|
||||
summary,
|
||||
));
|
||||
}
|
||||
Ok(applied)
|
||||
}
|
||||
|
||||
/// Stops an endpoint so the new configuration can decide whether and how it runs again.
|
||||
///
|
||||
/// Silences in both directions first: what it was playing, and what it was sending
|
||||
/// elsewhere, since both lose their only source of note offs when it stops.
|
||||
pub(crate) async fn take_down(self: &Arc<Self>, endpoint: &Endpoint) {
|
||||
let id = endpoint.id;
|
||||
self.silence_endpoint(id).await;
|
||||
self.silence_routes_from(id).await;
|
||||
|
||||
match &endpoint.kind {
|
||||
EndpointKind::NetworkSession(session) => {
|
||||
self.stop_session(id, session.local_name.as_str()).await;
|
||||
}
|
||||
EndpointKind::BluetoothDevice(_) => {
|
||||
if let Some(link) = self.bt_links.write().await.remove(&id)
|
||||
&& let Err(error) = self.bluetooth.disconnect(link)
|
||||
{
|
||||
debug!(endpoint = %endpoint.name, error = %error, "could not close the bluetooth link");
|
||||
}
|
||||
}
|
||||
EndpointKind::VirtualPort(_) | EndpointKind::PhysicalDevice(_) => {}
|
||||
}
|
||||
|
||||
// Removing the runtime entry is what lets reconcile open it again from scratch.
|
||||
let mut inner = self.inner.write().await;
|
||||
if let Some(runtime) = inner.runtime.remove(&id)
|
||||
&& let Some(handle) = runtime.handle
|
||||
{
|
||||
let closed = match &endpoint.kind {
|
||||
EndpointKind::PhysicalDevice(_) => self.midi.close_device(handle),
|
||||
EndpointKind::VirtualPort(_) => self.midi.destroy_virtual_port(handle),
|
||||
_ => Ok(()),
|
||||
};
|
||||
if let Err(error) = closed {
|
||||
warn!(endpoint = %endpoint.name, error = %error, "could not close endpoint");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decides how much a change to one endpoint disturbs it.
|
||||
fn disturbance(old: &Endpoint, new: &Endpoint) -> Disturbance {
|
||||
let (old, new) = (stored(old), stored(new));
|
||||
if old == new {
|
||||
return Disturbance::None;
|
||||
}
|
||||
|
||||
// Undo the changes that can be made in place, and see whether anything else is left.
|
||||
let mut rest = new.clone();
|
||||
rest.enabled = old.enabled;
|
||||
if !renames_on_the_platform(&old) {
|
||||
rest.name = old.name.clone();
|
||||
}
|
||||
if let (EndpointKind::NetworkSession(was), EndpointKind::NetworkSession(now)) =
|
||||
(&old.kind, &mut rest.kind)
|
||||
{
|
||||
now.invitation_policy = was.invitation_policy;
|
||||
}
|
||||
|
||||
if rest != old {
|
||||
Disturbance::Restart
|
||||
} else if old.enabled != new.enabled {
|
||||
Disturbance::Toggled
|
||||
} else {
|
||||
Disturbance::InPlace
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether an endpoint's name is what other software sees it by.
|
||||
///
|
||||
/// A virtual port's name is the port's name on the platform, so renaming one means creating it
|
||||
/// again, under the same platform identifier so connections to it survive. Everywhere else the
|
||||
/// name is a label this program keeps: a session advertises its local name, and hardware and
|
||||
/// radios have names of their own.
|
||||
fn renames_on_the_platform(endpoint: &Endpoint) -> bool {
|
||||
matches!(endpoint.kind, EndpointKind::VirtualPort(_))
|
||||
}
|
||||
|
||||
/// Returns the endpoint with everything observed rather than stored set to its default.
|
||||
fn stored(endpoint: &Endpoint) -> Endpoint {
|
||||
let mut endpoint = endpoint.clone();
|
||||
match &mut endpoint.kind {
|
||||
EndpointKind::PhysicalDevice(device) => {
|
||||
device.present = false;
|
||||
device.confidence = midi_harbor_core::fingerprint::MatchConfidence::None;
|
||||
device.claimed_by = None;
|
||||
}
|
||||
EndpointKind::BluetoothDevice(device) => device.rssi = None,
|
||||
EndpointKind::VirtualPort(_) | EndpointKind::NetworkSession(_) => {}
|
||||
}
|
||||
endpoint
|
||||
}
|
||||
|
||||
/// Copies what the daemon observes about each endpoint across to the incoming document.
|
||||
fn carry_observations(current: &Configuration, incoming: &mut Configuration) {
|
||||
for endpoint in &mut incoming.endpoints {
|
||||
let Some(held) = current.endpoints.iter().find(|held| held.id == endpoint.id) else {
|
||||
continue;
|
||||
};
|
||||
match (&mut endpoint.kind, &held.kind) {
|
||||
(EndpointKind::PhysicalDevice(new), EndpointKind::PhysicalDevice(old)) => {
|
||||
new.present = old.present;
|
||||
new.confidence = old.confidence;
|
||||
new.claimed_by.clone_from(&old.claimed_by);
|
||||
}
|
||||
(EndpointKind::BluetoothDevice(new), EndpointKind::BluetoothDevice(old)) => {
|
||||
new.rssi = old.rssi;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the setup a replacing import produces.
|
||||
fn replaced(current: &Configuration, offered: Configuration) -> Configuration {
|
||||
let mut next = offered;
|
||||
// Two machines advertising under one name cannot be told apart on the network.
|
||||
next.preferences
|
||||
.machine_name
|
||||
.clone_from(¤t.preferences.machine_name);
|
||||
for endpoint in &mut next.endpoints {
|
||||
adopt_local_identity(current, endpoint);
|
||||
}
|
||||
// Hardware attached here is found, not configured, so a file from another machine cannot
|
||||
// know about it. Dropping it would only have it rediscovered moments later under a new
|
||||
// identity, after being reported as removed.
|
||||
for held in ¤t.endpoints {
|
||||
let attached_here =
|
||||
matches!(&held.kind, EndpointKind::PhysicalDevice(device) if device.present);
|
||||
let named = next.endpoints.iter().any(|endpoint| {
|
||||
endpoint.id == held.id
|
||||
|| (endpoint.kind.slug() == held.kind.slug() && endpoint.name == held.name)
|
||||
});
|
||||
if attached_here && !named {
|
||||
next.endpoints.push(held.clone());
|
||||
}
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
/// Builds the setup a merging import produces.
|
||||
fn merged(current: &Configuration, offered: Configuration) -> Configuration {
|
||||
let mut next = current.clone();
|
||||
for mut endpoint in offered.endpoints {
|
||||
let here = next
|
||||
.endpoints
|
||||
.iter()
|
||||
.any(|held| held.kind.slug() == endpoint.kind.slug() && held.name == endpoint.name);
|
||||
if here {
|
||||
continue;
|
||||
}
|
||||
if next.endpoints.iter().any(|held| held.id == endpoint.id) {
|
||||
endpoint.id = EndpointId::new();
|
||||
}
|
||||
forget_foreign_identity(&mut endpoint);
|
||||
next.endpoints.push(endpoint);
|
||||
}
|
||||
for route in offered.routes {
|
||||
let here = next.routes.iter().any(|held| held.same_ends(&route));
|
||||
if !here {
|
||||
next.routes.push(route);
|
||||
}
|
||||
}
|
||||
for peer in offered.peers {
|
||||
let here = next
|
||||
.peers
|
||||
.iter()
|
||||
.any(|held| held.id == peer.id || held.name == peer.name);
|
||||
if !here {
|
||||
next.peers.push(peer);
|
||||
}
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
/// Gives an imported endpoint this machine's identity for the matching one, if there is one.
|
||||
fn adopt_local_identity(current: &Configuration, endpoint: &mut Endpoint) {
|
||||
if current.endpoints.iter().any(|held| held.id == endpoint.id) {
|
||||
return;
|
||||
}
|
||||
let Some(local) = current
|
||||
.endpoints
|
||||
.iter()
|
||||
.find(|held| held.kind.slug() == endpoint.kind.slug() && held.name == endpoint.name)
|
||||
else {
|
||||
forget_foreign_identity(endpoint);
|
||||
return;
|
||||
};
|
||||
endpoint.id = local.id;
|
||||
if let (EndpointKind::VirtualPort(new), EndpointKind::VirtualPort(old)) =
|
||||
(&mut endpoint.kind, &local.kind)
|
||||
{
|
||||
new.input_ids.clone_from(&old.input_ids);
|
||||
new.output_ids.clone_from(&old.output_ids);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops what only meant something on the machine an endpoint came from.
|
||||
///
|
||||
/// A virtual port's platform identifier was assigned by that machine's MIDI system. Pinning it
|
||||
/// here could collide with a port that already holds it.
|
||||
fn forget_foreign_identity(endpoint: &mut Endpoint) {
|
||||
if let EndpointKind::VirtualPort(port) = &mut endpoint.kind {
|
||||
port.platform_unique_id = None;
|
||||
port.input_ids.clear();
|
||||
port.output_ids.clear();
|
||||
}
|
||||
}
|
||||
165
crates/daemon/src/server.rs
Normal file
165
crates/daemon/src/server.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
//! Serving the contract on the daemon's socket.
|
||||
|
||||
use crate::service::HarborService;
|
||||
use crate::state::Daemon;
|
||||
use midi_harbor_ipc::HarborServer;
|
||||
use midi_harbor_ipc::transport;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
|
||||
/// Reports whether a daemon already answers on the socket these paths name.
|
||||
///
|
||||
/// Asked before starting anything, because a second daemon creates its own ports and sessions
|
||||
/// before it reaches the socket, and those collide with the first's.
|
||||
pub async fn already_serving(paths: &midi_harbor_core::paths::Paths) -> bool {
|
||||
transport::probe(&paths.socket_file()).await
|
||||
}
|
||||
|
||||
/// How long stopping waits for clients to finish before closing their connections.
|
||||
///
|
||||
/// Long enough for a request already being answered, such as a route being created, to finish.
|
||||
/// A stream a window is watching never finishes on its own, so waiting for it is pointless.
|
||||
const SHUTDOWN_GRACE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Why the daemon stopped serving.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Stopped {
|
||||
/// The process was asked to stop.
|
||||
Asked,
|
||||
/// This process can no longer reach the MIDI service, and a new one has to take its place.
|
||||
Replace,
|
||||
}
|
||||
|
||||
/// Runs the daemon until the process is asked to stop, or has to be replaced.
|
||||
///
|
||||
/// Returns only then, so callers can run this in the foreground under any supervisor.
|
||||
pub async fn run(daemon: Arc<Daemon>) -> Result<Stopped, Box<dyn std::error::Error>> {
|
||||
let socket = daemon.paths().socket_file();
|
||||
let incoming = transport::bind(&socket)?;
|
||||
info!(socket = %socket.display(), "daemon listening");
|
||||
let asked = stop_requests(&socket)?;
|
||||
|
||||
// Learn why serving has to end, and when.
|
||||
let (stopped_tx, stopped_rx) = tokio::sync::watch::channel(None);
|
||||
let replaced = Arc::clone(&daemon);
|
||||
tokio::spawn(async move {
|
||||
let why = tokio::select! {
|
||||
() = shutdown(asked) => Stopped::Asked,
|
||||
() = replaced.restart_requested() => Stopped::Replace,
|
||||
() = replaced.stop_requested() => Stopped::Asked,
|
||||
};
|
||||
let _ = stopped_tx.send(Some(why));
|
||||
});
|
||||
|
||||
// Serve until then. Stopping waits for open connections to close, and a client watching a
|
||||
// stream never closes its own, so an open window kept the daemon running after it was asked
|
||||
// to stop, until the service manager killed it without silencing anything.
|
||||
let mut graceful = stopped_rx.clone();
|
||||
let mut forced = stopped_rx.clone();
|
||||
let service = HarborService::new(Arc::clone(&daemon));
|
||||
let serving = tonic::transport::Server::builder()
|
||||
.add_service(HarborServer::new(service))
|
||||
.serve_with_incoming_shutdown(incoming, async move {
|
||||
let _ = graceful.wait_for(Option::is_some).await;
|
||||
});
|
||||
let served = tokio::select! {
|
||||
served = serving => served,
|
||||
() = async move {
|
||||
let _ = forced.wait_for(Option::is_some).await;
|
||||
tokio::time::sleep(SHUTDOWN_GRACE).await;
|
||||
} => {
|
||||
info!("clients are still connected; closing their connections");
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
|
||||
// Both of these happen however this was reached. Notes held when the daemon stops would
|
||||
// sound until something else stops them, and nothing else will: the only thing that knew
|
||||
// they were playing is this process. Returning early on a serve error would skip that.
|
||||
daemon.silence_all().await;
|
||||
daemon.end_sessions().await;
|
||||
daemon.release_platform();
|
||||
|
||||
// Leaving the socket behind would make the next start look like a daemon is already running.
|
||||
let _ = std::fs::remove_file(&socket);
|
||||
served?;
|
||||
|
||||
let stopped = (*stopped_rx.borrow()).unwrap_or(Stopped::Asked);
|
||||
info!("daemon stopped");
|
||||
Ok(stopped)
|
||||
}
|
||||
|
||||
/// Starts listening for requests to stop other than signals, where the platform needs one.
|
||||
///
|
||||
/// Windows has no terminate signal for `service stop` to send, so the daemon waits on an event
|
||||
/// named after its pipe instead. Unix has the signal, and nothing else.
|
||||
fn stop_requests(
|
||||
socket: &std::path::Path,
|
||||
) -> Result<Option<Arc<tokio::sync::Notify>>, Box<dyn std::error::Error>> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let pipe = transport::pipe_name(socket).ok_or("the daemon's pipe was not recorded")?;
|
||||
Ok(Some(midi_harbor_platform::stop::listen(&pipe)?))
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = socket;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves when a request from `stop_requests` arrives, and never if there is nothing to ask.
|
||||
async fn requested(asked: Option<Arc<tokio::sync::Notify>>) {
|
||||
match asked {
|
||||
Some(asked) => asked.notified().await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves when the process is asked to stop.
|
||||
#[cfg(unix)]
|
||||
async fn shutdown(asked: Option<Arc<tokio::sync::Notify>>) {
|
||||
let interrupt = tokio::signal::ctrl_c();
|
||||
let mut term = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||
Ok(signal) => signal,
|
||||
// Without a terminate handler the daemon still stops on interrupt, which is enough to
|
||||
// avoid leaving it unstoppable.
|
||||
Err(_) => {
|
||||
let _ = interrupt.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
_ = interrupt => {}
|
||||
_ = term.recv() => {}
|
||||
() = requested(asked) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves when the process is asked to stop: by `service stop`, by Ctrl-C, or by its console
|
||||
/// closing or the user signing out.
|
||||
#[cfg(windows)]
|
||||
async fn shutdown(asked: Option<Arc<tokio::sync::Notify>>) {
|
||||
use tokio::signal::windows;
|
||||
|
||||
let interrupt = tokio::signal::ctrl_c();
|
||||
let (Ok(mut close), Ok(mut logoff), Ok(mut system)) = (
|
||||
windows::ctrl_close(),
|
||||
windows::ctrl_logoff(),
|
||||
windows::ctrl_shutdown(),
|
||||
) else {
|
||||
tokio::select! {
|
||||
_ = interrupt => {}
|
||||
() = requested(asked) => {}
|
||||
}
|
||||
return;
|
||||
};
|
||||
tokio::select! {
|
||||
_ = interrupt => {}
|
||||
() = requested(asked) => {}
|
||||
_ = close.recv() => {}
|
||||
_ = logoff.recv() => {}
|
||||
_ = system.recv() => {}
|
||||
}
|
||||
}
|
||||
2064
crates/daemon/src/service.rs
Normal file
2064
crates/daemon/src/service.rs
Normal file
File diff suppressed because it is too large
Load diff
2464
crates/daemon/src/session.rs
Normal file
2464
crates/daemon/src/session.rs
Normal file
File diff suppressed because it is too large
Load diff
4240
crates/daemon/src/state.rs
Normal file
4240
crates/daemon/src/state.rs
Normal file
File diff suppressed because it is too large
Load diff
225
crates/daemon/src/supervisor.rs
Normal file
225
crates/daemon/src/supervisor.rs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
//! Retrying virtual ports and hardware that failed to open.
|
||||
//!
|
||||
//! Network sessions have their own supervisors, and those have always retried. A virtual port or
|
||||
//! a device that failed to open was given a retry time by the state machine and then left alone,
|
||||
//! so `status` announced a retry that never came: a port refused at startup by an endpoint limit
|
||||
//! stayed shut after the limit cleared, and a device held by another application stayed shut
|
||||
//! after that application quit. This delivers those retries.
|
||||
|
||||
use crate::dataplane;
|
||||
use crate::state::{Change, Daemon, OpenedPort, record_platform_ids};
|
||||
use midi_harbor_core::config;
|
||||
use midi_harbor_core::endpoint::{Endpoint, EndpointKind};
|
||||
use midi_harbor_core::events::{EventKind, Severity};
|
||||
use midi_harbor_core::failure::FailureReason;
|
||||
use midi_harbor_core::ids::EndpointId;
|
||||
use midi_harbor_core::state::{ConnectionPhase, ConnectionState, Event};
|
||||
use midi_harbor_platform::midi::ConnectorIds;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// How often waiting endpoints are checked for a retry that has come due.
|
||||
///
|
||||
/// The shortest backoff is 250 ms, so checking more often than that would find nothing new.
|
||||
pub const RETRY_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
impl Daemon {
|
||||
/// Retries each waiting virtual port and device when its backoff expires.
|
||||
pub(crate) fn watch_retries(self: &Arc<Self>) {
|
||||
let daemon = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(RETRY_INTERVAL);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
daemon.retry_due().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Makes one attempt at every endpoint whose retry has come due.
|
||||
async fn retry_due(self: &Arc<Self>) {
|
||||
let now = self.clock.now();
|
||||
let due: Vec<EndpointId> = {
|
||||
let inner = self.inner.read().await;
|
||||
inner
|
||||
.config
|
||||
.endpoints
|
||||
.iter()
|
||||
.filter(|endpoint| endpoint.enabled && retried_here(endpoint))
|
||||
.filter(|endpoint| {
|
||||
inner
|
||||
.runtime
|
||||
.get(&endpoint.id)
|
||||
.is_some_and(|runtime| runtime.handle.is_none() && due(&runtime.state, now))
|
||||
})
|
||||
.map(|endpoint| endpoint.id)
|
||||
.collect()
|
||||
};
|
||||
// Each attempt on its own task, so one endpoint slow to answer does not hold up the
|
||||
// others waiting behind it, and a fault in one attempt ends that attempt alone. Claiming
|
||||
// an attempt moves the endpoint out of the waiting phases, so the next pass cannot start
|
||||
// a second one beside it.
|
||||
for id in due {
|
||||
let daemon = Arc::clone(self);
|
||||
tokio::spawn(async move { daemon.retry(id).await });
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts to open one endpoint again, carrying its backoff forward whatever happens.
|
||||
///
|
||||
/// The platform is asked without the daemon's lock held. Every route needs that lock to find
|
||||
/// its destinations, so holding it through an open stopped all MIDI for as long as the
|
||||
/// platform took to answer about one endpoint that was not working anyway (FR-029).
|
||||
async fn retry(self: &Arc<Self>, id: EndpointId) {
|
||||
// Claim the attempt. Checked again under the lock, because the endpoint may have been
|
||||
// disabled, deleted or opened some other way since the list was taken. Moving it to
|
||||
// connecting is what stops the next pass retrying it again while this one is out.
|
||||
let endpoint = {
|
||||
let mut inner = self.inner.write().await;
|
||||
let now = self.clock.now();
|
||||
let Some(endpoint) = inner.config.endpoint(id).cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(runtime) = inner.runtime.get_mut(&id) else {
|
||||
return;
|
||||
};
|
||||
if !endpoint.enabled
|
||||
|| !retried_here(&endpoint)
|
||||
|| runtime.handle.is_some()
|
||||
|| !due(&runtime.state, now)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let _ = runtime
|
||||
.state
|
||||
.apply_now(Event::RetryDue, self.clock.as_ref());
|
||||
endpoint
|
||||
};
|
||||
|
||||
// Open it, with nothing held, on a thread that may block. A backend waits for its
|
||||
// platform thread to answer, and waiting on a runtime worker held up whatever task was
|
||||
// queued behind it there, however many other workers were idle.
|
||||
let daemon = Arc::clone(self);
|
||||
let wanted = endpoint.clone();
|
||||
let opened = tokio::task::spawn_blocking(move || match &wanted.kind {
|
||||
EndpointKind::VirtualPort(_) => daemon.open_virtual_port(&wanted),
|
||||
EndpointKind::PhysicalDevice(device) => {
|
||||
let (producer, consumer) = dataplane::channel(id);
|
||||
daemon
|
||||
.midi
|
||||
.open_device_with_sink(&device.fingerprint, Some(producer))
|
||||
.map(|handle| OpenedPort {
|
||||
handle,
|
||||
ids: ConnectorIds::default(),
|
||||
consumers: vec![consumer],
|
||||
})
|
||||
.map_err(|error| error.as_failure_reason())
|
||||
}
|
||||
_ => Err(FailureReason::ConfigInvalid {
|
||||
detail: "not an endpoint this loop opens".to_owned(),
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
let opened: Result<OpenedPort, FailureReason> = match opened {
|
||||
Ok(opened) => opened,
|
||||
// Only a panic in the backend or the runtime shutting down gets here. Recorded as a
|
||||
// failure so the endpoint is retried rather than left connecting for good.
|
||||
Err(error) => {
|
||||
error!(endpoint = %endpoint.name, error = %error, "retry did not finish");
|
||||
Err(FailureReason::ProtocolError {
|
||||
detail: "the platform did not answer".to_owned(),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// Record the outcome, unless the endpoint changed while the platform was answering.
|
||||
// Switching it off or deleting it removes its runtime, and switching it back on starts
|
||||
// a new one, so this attempt's is found only if nothing else touched it.
|
||||
let mut inner = self.inner.write().await;
|
||||
let now = self.clock.now();
|
||||
let Some(runtime) = inner.runtime.get_mut(&id).filter(|runtime| {
|
||||
runtime.handle.is_none() && runtime.state.phase() == ConnectionPhase::Connecting
|
||||
}) else {
|
||||
// Switched off, deleted or opened elsewhere meanwhile: what was opened is not wanted.
|
||||
drop(inner);
|
||||
if let Ok(OpenedPort { handle, .. }) = opened {
|
||||
let _ = match endpoint.kind {
|
||||
EndpointKind::PhysicalDevice(_) => self.midi.close_device(handle),
|
||||
_ => self.midi.destroy_virtual_port(handle),
|
||||
};
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
match opened {
|
||||
Ok(OpenedPort {
|
||||
handle,
|
||||
ids,
|
||||
consumers,
|
||||
}) => {
|
||||
let attempts = runtime.state.attempt();
|
||||
let _ = runtime
|
||||
.state
|
||||
.apply_now(Event::Attempting, self.clock.as_ref());
|
||||
let _ = runtime
|
||||
.state
|
||||
.apply_now(Event::Established, self.clock.as_ref());
|
||||
runtime.handle = Some(handle);
|
||||
|
||||
// The identifier matters as much here as on a first open: without it, other
|
||||
// applications see a new port after the next restart.
|
||||
if record_platform_ids(&mut inner.config, id, &ids)
|
||||
&& let Err(error) = config::save(&self.paths, &inner.config)
|
||||
{
|
||||
warn!(error = %error, "could not persist a platform identifier; the port may change identity on restart");
|
||||
}
|
||||
info!(endpoint = %endpoint.name, attempts, "opened after retrying");
|
||||
let _ = inner.events.record(midi_harbor_core::events::event(
|
||||
EventKind::EndpointStateChanged,
|
||||
Severity::Info,
|
||||
now,
|
||||
format!(
|
||||
"{} connected after {}",
|
||||
endpoint.name,
|
||||
crate::state::failed_attempts(attempts)
|
||||
),
|
||||
));
|
||||
drop(inner);
|
||||
for consumer in consumers {
|
||||
self.start_dispatch(consumer);
|
||||
}
|
||||
}
|
||||
Err(reason) => {
|
||||
// Quiet on purpose: the first failure is in the history already, and one entry per
|
||||
// backoff step for as long as a device stays claimed says nothing new.
|
||||
debug!(endpoint = %endpoint.name, error = %reason, "retry failed");
|
||||
let _ = runtime
|
||||
.state
|
||||
.apply_now(Event::Failed(reason), self.clock.as_ref());
|
||||
drop(inner);
|
||||
}
|
||||
}
|
||||
let _ = self.changes.send(Change::EndpointChanged(id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether this loop is the one responsible for retrying the endpoint.
|
||||
///
|
||||
/// Network sessions and Bluetooth links retry through their own supervisors, and hardware that is
|
||||
/// not attached is waiting for the device to return, not for a backoff to expire.
|
||||
fn retried_here(endpoint: &Endpoint) -> bool {
|
||||
match &endpoint.kind {
|
||||
EndpointKind::VirtualPort(_) => true,
|
||||
EndpointKind::PhysicalDevice(device) => device.present,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether an endpoint is waiting and its retry time has passed.
|
||||
fn due(state: &ConnectionState, now: jiff::Timestamp) -> bool {
|
||||
matches!(
|
||||
state.phase(),
|
||||
ConnectionPhase::Retrying | ConnectionPhase::Unavailable
|
||||
) && state.next_retry().is_some_and(|at| at <= now)
|
||||
}
|
||||
130
crates/daemon/src/traffic_log.rs
Normal file
130
crates/daemon/src/traffic_log.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
//! A line in the log for each endpoint whose traffic moved.
|
||||
//!
|
||||
//! The history lives in memory and goes with the process, and the data path never logs, so
|
||||
//! nothing on disk said whether a message arrived at a given minute. These lines do, read from
|
||||
//! the counters by a normal task: the data path only increments atomics, as before.
|
||||
|
||||
use crate::state::Daemon;
|
||||
use midi_harbor_core::counters::CounterSnapshot;
|
||||
use midi_harbor_core::ids::EndpointId;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::info;
|
||||
|
||||
/// How often the counters are compared with the last look.
|
||||
const LOOK_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
/// The least time between two lines for an endpoint whose traffic keeps moving.
|
||||
///
|
||||
/// A cue arriving on a quiet endpoint is logged at the next look; a clock or a controller
|
||||
/// sweeping all the time is logged once a minute, not every ten seconds.
|
||||
const BUSY_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// One endpoint's traffic as last seen and last logged.
|
||||
struct Watched {
|
||||
/// The counts at the last look.
|
||||
seen: (u64, u64, u64),
|
||||
/// Whether they had moved at the last look.
|
||||
moving: bool,
|
||||
/// When a line was last written for it.
|
||||
logged: Option<Instant>,
|
||||
}
|
||||
|
||||
/// Returns the counts that decide whether traffic moved: received, sent and dropped.
|
||||
fn counts(snapshot: &CounterSnapshot) -> (u64, u64, u64) {
|
||||
(
|
||||
snapshot.messages_received,
|
||||
snapshot.messages_sent,
|
||||
snapshot.messages_dropped,
|
||||
)
|
||||
}
|
||||
|
||||
/// Formats a time the way the rest of the log does, or a dash for none.
|
||||
fn when(at: Option<jiff::Timestamp>) -> String {
|
||||
at.map_or_else(|| "-".to_owned(), |at| at.to_string())
|
||||
}
|
||||
|
||||
impl Daemon {
|
||||
/// Logs each endpoint's traffic when it moved, at most once a minute while it keeps moving.
|
||||
pub(crate) fn watch_traffic_log(self: &Arc<Self>) {
|
||||
let daemon = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(LOOK_INTERVAL);
|
||||
let mut watched: HashMap<EndpointId, Watched> = HashMap::new();
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let now = Instant::now();
|
||||
let current = daemon.traffic_by_name().await;
|
||||
|
||||
// Forget what is no longer running, so a port opened again starts afresh.
|
||||
watched.retain(|id, _| current.iter().any(|(held, _, _)| held == id));
|
||||
|
||||
for (id, name, snapshot) in current {
|
||||
let seen = counts(&snapshot);
|
||||
let Some(entry) = watched.get_mut(&id) else {
|
||||
// The first look sets the baseline: traffic before it is not news.
|
||||
watched.insert(
|
||||
id,
|
||||
Watched {
|
||||
seen,
|
||||
moving: false,
|
||||
logged: None,
|
||||
},
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
// Decide whether this look earns a line.
|
||||
let moved = seen != entry.seen;
|
||||
let was_quiet = !entry.moving;
|
||||
let due = entry
|
||||
.logged
|
||||
.is_none_or(|at| now.duration_since(at) >= BUSY_INTERVAL);
|
||||
entry.seen = seen;
|
||||
entry.moving = moved;
|
||||
if !moved || !(was_quiet || due) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.logged = Some(now);
|
||||
info!(
|
||||
endpoint = %name,
|
||||
received = snapshot.messages_received,
|
||||
sent = snapshot.messages_sent,
|
||||
dropped = snapshot.messages_dropped,
|
||||
last_received = %when(snapshot.last_received),
|
||||
last_sent = %when(snapshot.last_sent),
|
||||
"traffic"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns the counters of every running endpoint and automatic port, with the name each is
|
||||
/// logged under.
|
||||
async fn traffic_by_name(&self) -> Vec<(EndpointId, String, CounterSnapshot)> {
|
||||
let inner = self.inner.read().await;
|
||||
let name = |id: &EndpointId| {
|
||||
inner
|
||||
.config
|
||||
.endpoint(*id)
|
||||
.map(|endpoint| endpoint.name.as_str().to_owned())
|
||||
};
|
||||
let endpoints = inner
|
||||
.runtime
|
||||
.iter()
|
||||
.chain(inner.session_runtime.iter())
|
||||
.filter_map(|(id, runtime)| Some((*id, name(id)?, runtime.counters.snapshot())));
|
||||
let automatic = inner.automatic_ports.iter().filter_map(|(id, port)| {
|
||||
let owner = name(&port.session)?;
|
||||
Some((
|
||||
*id,
|
||||
format!("{owner} (automatic port)"),
|
||||
port.runtime.counters.snapshot(),
|
||||
))
|
||||
});
|
||||
endpoints.chain(automatic).collect()
|
||||
}
|
||||
}
|
||||
506
crates/daemon/tests/automatic_port.rs
Normal file
506
crates/daemon/tests/automatic_port.rs
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
//! A network port's automatic port (FR-015h).
|
||||
//!
|
||||
//! Other applications see a network port as a MIDI port of its name, joined to it both ways: what
|
||||
//! they send the port goes out over the network, and what arrives over the network comes out of
|
||||
//! the port. It is the network port's own, renamed and removed with it and never listed apart.
|
||||
|
||||
#![allow(
|
||||
clippy::expect_used,
|
||||
clippy::indexing_slicing,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)]
|
||||
|
||||
mod common;
|
||||
|
||||
use midi_harbor_core::endpoint::{EndpointKind, InvitationPolicy};
|
||||
use midi_harbor_core::fingerprint::DeviceFingerprint;
|
||||
use midi_harbor_core::ids::EndpointId;
|
||||
use midi_harbor_core::midi::{CC_ALL_NOTES_OFF, Channel, MidiMessage};
|
||||
use midi_harbor_daemon::Daemon;
|
||||
use midi_harbor_platform::fake::FakeMidiPlatform;
|
||||
use midi_harbor_platform::midi::{DiscoveredDevice, MidiPlatform, PortHandle};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Starts a daemon standing in for one machine, over a scratch directory of its own, returning
|
||||
/// it with its fake platform.
|
||||
async fn machine(label: &str) -> (Arc<Daemon>, Arc<FakeMidiPlatform>) {
|
||||
let root = common::scratch("midi-harbor-automatic-port")
|
||||
.join(format!("{label}-{}", uuid::Uuid::new_v4()));
|
||||
let platform = Arc::new(FakeMidiPlatform::new());
|
||||
let daemon = Daemon::start(
|
||||
common::quiet(root),
|
||||
Arc::clone(&platform) as Arc<dyn MidiPlatform>,
|
||||
)
|
||||
.await
|
||||
.expect("the daemon starts over a scratch directory");
|
||||
(daemon, platform)
|
||||
}
|
||||
|
||||
/// Returns a note on channel 1 at velocity 100.
|
||||
fn note(n: u8) -> MidiMessage {
|
||||
MidiMessage::NoteOn {
|
||||
channel: Channel::new(0).expect("channel 0 is valid"),
|
||||
note: n,
|
||||
velocity: 100,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether an all-notes-off was sent among `sent`.
|
||||
fn notes_released(sent: &[MidiMessage]) -> bool {
|
||||
sent.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
MidiMessage::ControlChange {
|
||||
controller: CC_ALL_NOTES_OFF,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Waits for a condition, so the test does not depend on dispatch or network timing.
|
||||
async fn eventually(mut check: impl FnMut() -> bool) -> bool {
|
||||
for _ in 0..300 {
|
||||
if check() {
|
||||
return true;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns the platform identifiers stored for a network port's automatic port.
|
||||
async fn stored_ids(daemon: &Arc<Daemon>, id: EndpointId) -> (Option<u32>, Option<u32>) {
|
||||
daemon
|
||||
.read(|config, _| match config.endpoint(id).map(|e| &e.kind) {
|
||||
Some(EndpointKind::NetworkSession(session)) => {
|
||||
(session.port_input_id, session.port_output_id)
|
||||
}
|
||||
_ => panic!("not a network port"),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Builds two machines whose network ports are connected over loopback: Stage on the near one,
|
||||
/// Front of House on the far one.
|
||||
async fn joined() -> (
|
||||
Arc<Daemon>,
|
||||
Arc<FakeMidiPlatform>,
|
||||
Arc<Daemon>,
|
||||
Arc<FakeMidiPlatform>,
|
||||
) {
|
||||
let (far, far_platform) = machine("far").await;
|
||||
let accepting = far
|
||||
.create_network_session("Front of House", 0, InvitationPolicy::AcceptAll)
|
||||
.await
|
||||
.expect("the far network port is created");
|
||||
let port = far
|
||||
.session_status(accepting.id)
|
||||
.await
|
||||
.expect("the far network port is listening")
|
||||
.control_port;
|
||||
|
||||
let (near, near_platform) = machine("near").await;
|
||||
let stage = near
|
||||
.create_network_session("Stage", 0, InvitationPolicy::Prompt)
|
||||
.await
|
||||
.expect("the near network port is created");
|
||||
near.connect_peer(stage.id, SocketAddr::from(([127, 0, 0, 1], port)))
|
||||
.await
|
||||
.expect("the near network port invites the far one");
|
||||
(near, near_platform, far, far_platform)
|
||||
}
|
||||
|
||||
/// Returns the automatic ports of Stage on the near platform and Front of House on the far one.
|
||||
fn automatic_ports(near: &FakeMidiPlatform, far: &FakeMidiPlatform) -> (PortHandle, PortHandle) {
|
||||
(
|
||||
near.port_handle("Stage")
|
||||
.expect("Stage has an automatic port"),
|
||||
far.port_handle("Front of House")
|
||||
.expect("Front of House has an automatic port"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Proves that a network port shows to other applications as a port of its name, and that one
|
||||
/// made with its automatic port switched off does not.
|
||||
#[tokio::test]
|
||||
async fn a_network_port_shows_to_other_applications_as_a_port_of_its_name() {
|
||||
let (daemon, platform) = machine("shown").await;
|
||||
daemon
|
||||
.create_network_session("Stage", 0, InvitationPolicy::Prompt)
|
||||
.await
|
||||
.expect("the network port Stage is created");
|
||||
daemon
|
||||
.create_network_port("Booth", None, 0, InvitationPolicy::Prompt, false)
|
||||
.await
|
||||
.expect("the network port Booth is created without its automatic port");
|
||||
|
||||
assert!(
|
||||
platform.port_handle("Stage").is_some(),
|
||||
"a network port is not shown to other applications as a port of its name"
|
||||
);
|
||||
assert!(
|
||||
platform.port_handle("Booth").is_none(),
|
||||
"a network port with its automatic port switched off still made one"
|
||||
);
|
||||
}
|
||||
|
||||
/// Proves that what an application sends into one automatic port comes out of the far one, with
|
||||
/// no route on either machine, for channel messages and for system exclusive, and is not echoed
|
||||
/// back out of the port it was sent into.
|
||||
///
|
||||
/// System exclusive travels as a pool handle rather than inline (Principle III), so it takes a
|
||||
/// path of its own through the automatic port. Each message is sent again until it arrives,
|
||||
/// since the connection may still be settling; the note's number changes each time so a late
|
||||
/// arrival of an earlier try is not mistaken for the one looked for.
|
||||
#[tokio::test]
|
||||
async fn midi_crosses_the_network_between_automatic_ports() {
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
/// Sends the message for a try into the near automatic port.
|
||||
send: fn(&FakeMidiPlatform, PortHandle, u8),
|
||||
/// Reports whether the message for a try came out of the far automatic port.
|
||||
arrived: fn(&FakeMidiPlatform, PortHandle, u8) -> bool,
|
||||
/// Reports whether anything came back out of the near automatic port.
|
||||
echoed: fn(&FakeMidiPlatform, PortHandle) -> bool,
|
||||
}
|
||||
const DUMP: [u8; 6] = [0xF0, 0x7D, 0x01, 0x02, 0x03, 0xF7];
|
||||
let cases = [
|
||||
Case {
|
||||
name: "a note",
|
||||
send: |platform, port, try_| {
|
||||
platform.feed(port, &[note(try_)]);
|
||||
},
|
||||
arrived: |platform, port, try_| platform.sent(port).contains(¬e(try_)),
|
||||
echoed: |platform, port| !platform.sent(port).is_empty(),
|
||||
},
|
||||
Case {
|
||||
name: "a system exclusive dump",
|
||||
send: |platform, port, _| {
|
||||
platform.feed_bytes(port, &DUMP);
|
||||
},
|
||||
arrived: |platform, port, _| platform.sent_sysex(port).iter().any(|sent| sent == &DUMP),
|
||||
echoed: |platform, port| !platform.sent_sysex(port).is_empty(),
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
let (_near, near_platform, _far, far_platform) = joined().await;
|
||||
let (stage, front) = automatic_ports(&near_platform, &far_platform);
|
||||
|
||||
let mut try_ = 0u8;
|
||||
let arrived = eventually(|| {
|
||||
try_ = try_.wrapping_add(1) % 100;
|
||||
(case.send)(&near_platform, stage, try_);
|
||||
(case.arrived)(&far_platform, front, try_)
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
arrived,
|
||||
"{}: nothing came out of the far automatic port",
|
||||
case.name
|
||||
);
|
||||
assert!(
|
||||
!(case.echoed)(&near_platform, stage),
|
||||
"{}: what was sent into Stage came back out of it",
|
||||
case.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Proves that an automatic port switched off is removed from the platform and, switched on
|
||||
/// again, comes back under the same platform identifiers, so other applications see the same
|
||||
/// port rather than a new one.
|
||||
#[tokio::test]
|
||||
async fn switching_the_automatic_port_off_and_on_removes_and_restores_it() {
|
||||
let (daemon, platform) = machine("switch").await;
|
||||
let stage = daemon
|
||||
.create_network_session("Stage", 0, InvitationPolicy::Prompt)
|
||||
.await
|
||||
.expect("the network port is created");
|
||||
let ids = stored_ids(&daemon, stage.id).await;
|
||||
assert!(
|
||||
ids.0.is_some() && ids.1.is_some(),
|
||||
"its identifiers were not kept"
|
||||
);
|
||||
|
||||
daemon
|
||||
.set_automatic_port(stage.id, false)
|
||||
.await
|
||||
.expect("the automatic port is switched off");
|
||||
assert!(
|
||||
platform.port_handle("Stage").is_none(),
|
||||
"an automatic port switched off is still on the platform"
|
||||
);
|
||||
|
||||
daemon
|
||||
.set_automatic_port(stage.id, true)
|
||||
.await
|
||||
.expect("the automatic port is switched on again");
|
||||
assert!(
|
||||
platform.port_handle("Stage").is_some(),
|
||||
"an automatic port switched on again is not on the platform"
|
||||
);
|
||||
assert_eq!(
|
||||
stored_ids(&daemon, stage.id).await,
|
||||
ids,
|
||||
"it came back as a different port to other applications"
|
||||
);
|
||||
}
|
||||
|
||||
/// Proves that renaming a network port renames its automatic port on the platform, keeping its
|
||||
/// platform identifiers.
|
||||
#[tokio::test]
|
||||
async fn renaming_a_network_port_renames_its_automatic_port() {
|
||||
let (daemon, platform) = machine("rename").await;
|
||||
let stage = daemon
|
||||
.create_network_session("Stage", 0, InvitationPolicy::Prompt)
|
||||
.await
|
||||
.expect("the network port is created");
|
||||
let ids = stored_ids(&daemon, stage.id).await;
|
||||
|
||||
daemon
|
||||
.rename_endpoint(stage.id, "Main Stage", true)
|
||||
.await
|
||||
.expect("the network port is renamed");
|
||||
|
||||
assert!(
|
||||
platform.port_handle("Stage").is_none(),
|
||||
"the old name stayed"
|
||||
);
|
||||
assert!(
|
||||
platform.port_handle("Main Stage").is_some(),
|
||||
"the automatic port does not carry the new name"
|
||||
);
|
||||
assert_eq!(
|
||||
stored_ids(&daemon, stage.id).await,
|
||||
ids,
|
||||
"renaming made the automatic port a different port to other applications"
|
||||
);
|
||||
}
|
||||
|
||||
/// What ends a far automatic port's part in carrying a note.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Ending {
|
||||
/// Front of House is switched off.
|
||||
SwitchedOff,
|
||||
/// The far daemon stops, silencing everything on its way out.
|
||||
DaemonStopped,
|
||||
}
|
||||
|
||||
/// Proves that a note that came over the network and out of an automatic port is released there
|
||||
/// when the network port is switched off or the daemon stops (Principle I: no note is left
|
||||
/// sounding).
|
||||
///
|
||||
/// Once the network port is switched off its automatic port is gone, and once the daemon stops
|
||||
/// nothing is left to talk to the port, so either way nothing could release the note afterwards.
|
||||
#[tokio::test]
|
||||
async fn an_automatic_port_releases_what_it_has_sounding_when_its_network_port_ends() {
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
ending: Ending,
|
||||
/// Whether the automatic port is gone from the platform afterwards.
|
||||
removed: bool,
|
||||
}
|
||||
let cases = [
|
||||
Case {
|
||||
name: "switching the network port off",
|
||||
ending: Ending::SwitchedOff,
|
||||
removed: true,
|
||||
},
|
||||
Case {
|
||||
name: "stopping the daemon",
|
||||
ending: Ending::DaemonStopped,
|
||||
removed: false,
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
let (_near, near_platform, far, far_platform) = joined().await;
|
||||
let (stage, front) = automatic_ports(&near_platform, &far_platform);
|
||||
assert!(
|
||||
eventually(|| {
|
||||
near_platform.feed(stage, &[note(60)]);
|
||||
far_platform.sent(front).contains(¬e(60))
|
||||
})
|
||||
.await,
|
||||
"{}: the note never came out of the far automatic port",
|
||||
case.name
|
||||
);
|
||||
|
||||
match case.ending {
|
||||
Ending::SwitchedOff => {
|
||||
let id = far
|
||||
.resolve("Front of House")
|
||||
.await
|
||||
.expect("the far network port exists");
|
||||
far.set_enabled(id, false)
|
||||
.await
|
||||
.expect("the far network port is switched off");
|
||||
}
|
||||
Ending::DaemonStopped => far.silence_all().await,
|
||||
}
|
||||
|
||||
assert!(
|
||||
notes_released(&far_platform.sent(front)),
|
||||
"{}: the note that came over the network was left sounding",
|
||||
case.name
|
||||
);
|
||||
assert_eq!(
|
||||
far_platform.port_handle("Front of House").is_none(),
|
||||
case.removed,
|
||||
"{}: the automatic port should be removed only when its network port is switched off",
|
||||
case.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Proves that the daemon's own automatic port, listed by the platform beside everyone else's
|
||||
/// ports, is not adopted as another application's port, whether it is recognised by its
|
||||
/// platform identifiers or, before the platform has given it any, by its name alone.
|
||||
///
|
||||
/// CoreMIDI and ALSA list this daemon's own ports beside everyone else's, and adopting the
|
||||
/// automatic port would list it apart from its network port. The platform refusing the port once
|
||||
/// (an injected resource limit) leaves it without identifiers; until it has them, its name is all
|
||||
/// there is to recognise it by, as for a virtual port.
|
||||
#[tokio::test]
|
||||
async fn an_automatic_port_the_platform_lists_is_not_taken_for_an_application() {
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
/// Whether the platform has given the automatic port its identifiers.
|
||||
identified: bool,
|
||||
}
|
||||
let cases = [
|
||||
Case {
|
||||
name: "with its platform identifiers",
|
||||
identified: true,
|
||||
},
|
||||
Case {
|
||||
name: "without its platform identifiers",
|
||||
identified: false,
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
let (daemon, platform) = machine("listed").await;
|
||||
if !case.identified {
|
||||
platform.inject(Some(midi_harbor_platform::fake::Injected::ResourceLimit));
|
||||
}
|
||||
let stage = daemon
|
||||
.create_network_session("Stage", 0, InvitationPolicy::Prompt)
|
||||
.await
|
||||
.expect("the network port is created");
|
||||
let (input, output) = stored_ids(&daemon, stage.id).await;
|
||||
assert_eq!(
|
||||
(input.is_some(), output.is_some()),
|
||||
(case.identified, case.identified),
|
||||
"{}: the automatic port's identifiers are not as the row needs",
|
||||
case.name
|
||||
);
|
||||
|
||||
platform.attach(DiscoveredDevice {
|
||||
fingerprint: DeviceFingerprint {
|
||||
name: "Stage".to_owned(),
|
||||
unique_id: input,
|
||||
..DeviceFingerprint::default()
|
||||
},
|
||||
direction: midi_harbor_core::endpoint::Direction::Bidirectional,
|
||||
claimed_by: None,
|
||||
software: true,
|
||||
});
|
||||
daemon.refresh_devices().await;
|
||||
|
||||
let listed = daemon
|
||||
.read(|config, _| {
|
||||
config
|
||||
.endpoints
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, EndpointKind::PhysicalDevice(_)))
|
||||
.count()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
listed, 0,
|
||||
"{}: the automatic port was listed on its own",
|
||||
case.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Proves that deleting a network port releases the notes on both sides of it, reports the
|
||||
/// routes it leaves without an end, stops it and removes its automatic port.
|
||||
///
|
||||
/// A note played on the far machine sounds on Keys through the route from Stage, and a note sent
|
||||
/// into Stage sounds on the far machine; deleting Stage leaves nothing to release either, so the
|
||||
/// deletion must.
|
||||
#[tokio::test]
|
||||
async fn deleting_a_network_port_silences_its_machine_and_removes_its_port() {
|
||||
let (near, near_platform, _far, far_platform) = joined().await;
|
||||
let (stage, front) = automatic_ports(&near_platform, &far_platform);
|
||||
assert!(
|
||||
eventually(|| {
|
||||
near_platform.feed(stage, &[note(60)]);
|
||||
far_platform.sent(front).contains(¬e(60))
|
||||
})
|
||||
.await,
|
||||
"the note sent into Stage never reached the far machine"
|
||||
);
|
||||
near.create_virtual_port("Keys", 1, 1)
|
||||
.await
|
||||
.expect("the port Keys is created");
|
||||
near.create_route("Keys", "Stage")
|
||||
.await
|
||||
.expect("the route from Keys to Stage is created");
|
||||
near.create_route("Stage", "Keys")
|
||||
.await
|
||||
.expect("the route from Stage to Keys is created");
|
||||
let keys = near_platform
|
||||
.port_handle("Keys")
|
||||
.expect("Keys has a platform port");
|
||||
assert!(
|
||||
eventually(|| {
|
||||
far_platform.feed(front, &[note(64)]);
|
||||
near_platform.sent(keys).contains(¬e(64))
|
||||
})
|
||||
.await,
|
||||
"the note played on the far machine never reached Keys"
|
||||
);
|
||||
|
||||
let id = near
|
||||
.resolve("Stage")
|
||||
.await
|
||||
.expect("the near network port exists");
|
||||
let orphaned = near
|
||||
.delete_network_port(id)
|
||||
.await
|
||||
.expect("the near network port is deleted");
|
||||
|
||||
assert_eq!(
|
||||
orphaned,
|
||||
vec!["Keys -> Stage".to_owned(), "Stage -> Keys".to_owned()],
|
||||
"both routes naming Stage are reported as left without an end"
|
||||
);
|
||||
assert!(
|
||||
notes_released(&near_platform.sent(keys)),
|
||||
"the note the far machine played here was left sounding"
|
||||
);
|
||||
assert!(
|
||||
eventually(|| notes_released(&far_platform.sent(front))).await,
|
||||
"the note sent to the other machine was left sounding"
|
||||
);
|
||||
assert!(
|
||||
near_platform.port_handle("Stage").is_none(),
|
||||
"the deleted network port's automatic port is still on the platform"
|
||||
);
|
||||
assert!(
|
||||
near.resolve("Stage").await.is_err(),
|
||||
"it is still configured"
|
||||
);
|
||||
assert!(
|
||||
near.session_status(id).await.is_none(),
|
||||
"it is still running"
|
||||
);
|
||||
}
|
||||
1232
crates/daemon/tests/bluetooth.rs
Normal file
1232
crates/daemon/tests/bluetooth.rs
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue