v0.1.1: fix .app bundle PATH for pyenv/nvm, detect yt-dlp pot-provider plugin

- fix_path_env() now adds ~/.pyenv/shims (with PYENV_ROOT) before
  Homebrew paths so pyenv-managed yt-dlp (with pip-installed plugins)
  takes priority over Homebrew's bare yt-dlp binary
- Also handles ~/.nvm, ~/.deno/bin, ~/.cargo/bin for broader coverage
- check_pot_plugin uses lightweight 'yt-dlp -v' instead of network
  simulation for reliable plugin detection
- Bump version to 0.1.1

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 14:09:23 -04:00
parent 1ea7f19668
commit d46973a719
10 changed files with 1001 additions and 17 deletions

View File

@@ -1 +1 @@
0.1.0
0.1.1

View File

@@ -0,0 +1,62 @@
# Build Cleanup & Release Packaging
**Date:** 2026-09-22 13:39
**Commits:** 4bf6545..1ea7f19 (5 commits)
## Task Description
Resolved all build warnings/errors across the frontend (svelte-check, Vite) and backend (cargo), fixed a failing test, and implemented a release build script that produces a distributable `GUI Video Clipper.app` bundle.
## Changes Made
### Task 1: TypeScript Error Fixes (`3eebe01`)
- Installed `@types/node` as dev dependency
- Added `"types": ["node"]` to `tsconfig.json`
- Changed `defineConfig` import in `vite.config.ts` from `vite` to `vitest/config` (extends config type with `test` property)
- Result: 4 TS errors → 0
### Task 2: Svelte A11y Warning Fixes (`9d1f1cf`)
- **CaptionSettingsPanel.svelte**: 9 label/control association fixes (id/for pairs and wrapping)
- **ClipList.svelte**: Added `tabindex="0"` and keyboard handler to listbox container
- **PreferencesPanel.svelte, ExportDialog.svelte, AboutDialog.svelte**: Added `tabindex="-1"` to dialog divs, keyboard handlers on overlays
- **VideoPlayer.svelte**: Added `<track kind="captions" />` to `<video>` element
- **App.svelte**: Added keyboard support to resize handle (`role="slider"`, arrow keys, aria-value attrs)
- Result: 19 a11y warnings → 0
### Task 3: Rust Dead Code Annotations (`d85fbda`)
- Added `#[allow(dead_code)]` with comments on 6 items across 4 files:
- `ExportEvent::Error`, `DownloadEvent::Error` (reserved for future use)
- `sanitize_vtt_for_ffmpeg`, `opacity_to_ass_back_colour`, `caption_style_to_force_style` (caption burn-in pipeline)
- `SubtitleFormat.ext`/`.url` (serde deserialization fields)
- Result: 6 warnings → 0
### Task 4: Test Fix (`30b04b4`)
- Changed `markInPoint(5)` to `markInPoint(12)` in clips.test.ts
- Time 5 was outside `EDIT_TOLERANCE` (0.5s) of clip boundary (10-20), so `isTimeInsideClip` returned false and the edit path didn't execute
- Result: 1 failing test → 31/31 pass
### Task 5: Release Build Script (`1ea7f19`)
- Created `scripts/build-release.sh`: builds, ad-hoc signs, copies to `dist/release/`
- Changed `tauri.conf.json` bundle targets from `"all"` to `["app", "dmg"]`
- Added `dist/` to `.gitignore`
- Added `"release"` npm script
- Updated README with Release Build section
- Script parses tauri build output for .app/.dmg paths (handles custom CARGO_TARGET_DIR)
- Result: `npm run release` → `GUI Video Clipper.app` (6.2M) + `.dmg` (2.8M)
## Verification Results
- `npm run check`: 0 errors, 0 warnings
- `npm test`: 31/31 pass
- `cargo build`: 0 warnings
- `npm run release`: produces signed .app + .dmg
## Lessons Learned
- **Cursor sandbox CARGO_TARGET_DIR**: The Tauri build outputs to a sandboxed cache directory, not `src-tauri/target/`. The build script must parse the actual output paths from `npx tauri build` output rather than hardcoding the expected location.
- **Svelte a11y linter strictness**: `role="separator"` with keyboard support still triggers `a11y_no_noninteractive_element_interactions`. Using `role="slider"` satisfies the linter for resize handles.
- **Dialog overlay keyboard patterns**: Adding `onkeydown` + `tabindex="-1"` to overlays and `e.stopPropagation()` to inner dialogs satisfies the click-needs-keyboard-equivalent rule, but the stopPropagation means Escape from inside the dialog won't bubble to the overlay handler. Not a regression since dialogs previously had no keyboard close.
- **Test EDIT_TOLERANCE awareness**: When testing functions that use proximity checks (`isTimeInsideClip`), test values must account for tolerance thresholds. Values outside tolerance trigger different code paths.
## Follow-up Items
- Push new commits to remote: `git push origin master`
- Consider re-tagging `v0.1.0` to include these cleanup commits, or tag as `v0.1.1`
- Dialog Escape-from-inside behavior could be improved in a future pass (add Escape handler on the dialog div itself, not just the overlay)

View File

@@ -0,0 +1,733 @@
# Build Cleanup & Release Packaging Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Eliminate all build warnings/errors across frontend and backend, fix the failing test, and implement a release build script that produces a distributable `GUI Video Clipper.app`.
**Architecture:** Direct fixes to existing files — no new components or modules except `scripts/build-release.sh`. TypeScript config fixes, Svelte a11y attribute additions, Rust `#[allow]` annotations, one test assertion update, and a new shell script.
**Tech Stack:** Svelte 5, TypeScript, Vite/Vitest, Rust/Cargo, Tauri v2, Bash
## Global Constraints
- macOS is the only target platform
- All changes must result in zero warnings from `npm run check`, `cargo build`, and `npm test`
- Preserve existing runtime behavior — these are lint/warning fixes, not behavioral changes
- Follow existing code patterns and styles in each file
---
### Task 1: TypeScript Error Fixes
**Files:**
- Modify: `tsconfig.json`
- Modify: `vite.config.ts:1` (import line only)
- Test: `npm run check` (svelte-check)
**Interfaces:**
- Consumes: nothing
- Produces: Clean `npm run check` (0 errors for T1–T4; a11y warnings will still be present until Task 2)
- [ ] **Step 1: Install `@types/node`**
Run:
```bash
npm install -D @types/node
```
Expected: Package added to `devDependencies` in `package.json`.
- [ ] **Step 2: Add `"types": ["node"]` to `tsconfig.json`**
Edit `tsconfig.json` — add `"types": ["node"]` inside `compilerOptions`:
```json
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"module": "ESNext",
"target": "ESNext",
"isolatedModules": true,
"types": ["node"],
"paths": {
"$lib/*": ["./src/lib/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.svelte", "tests/**/*.ts", "vite.config.ts"]
}
```
- [ ] **Step 3: Change `defineConfig` import in `vite.config.ts`**
Change line 4 from:
```typescript
import { defineConfig } from 'vite';
```
to:
```typescript
import { defineConfig } from 'vitest/config';
```
This re-export extends `UserConfigExport` with vitest's `test` property.
- [ ] **Step 4: Verify TS errors are resolved**
Run:
```bash
npm run check 2>&1 | grep -E "^(Error|.*Error:)"
```
Expected: No lines matching "Error" — only a11y warnings remain.
- [ ] **Step 5: Commit**
```bash
git add tsconfig.json vite.config.ts package.json package-lock.json
git commit -m "fix: resolve TypeScript errors in vite.config.ts
- Install @types/node for node:path, node:process, node:url
- Import defineConfig from vitest/config for test config type
- Add types: [\"node\"] to tsconfig.json"
```
---
### Task 2: Svelte A11y Warning Fixes
**Files:**
- Modify: `src/lib/components/CaptionSettingsPanel.svelte:48-209` (label associations)
- Modify: `src/lib/components/ClipList.svelte:46` (tabindex + keydown)
- Modify: `src/lib/components/PreferencesPanel.svelte:60-61` (tabindex + keydown)
- Modify: `src/lib/components/ExportDialog.svelte:139-140` (tabindex + keydown)
- Modify: `src/lib/components/AboutDialog.svelte:15-16` (tabindex + keydown)
- Modify: `src/lib/components/VideoPlayer.svelte:187-197` (track element)
- Modify: `src/App.svelte:188-193` (resize handle keyboard support)
- Test: `npm run check` (svelte-check)
**Interfaces:**
- Consumes: Task 1 completed (TS errors resolved)
- Produces: Zero a11y warnings from `npm run check` and `npm run build`
- [ ] **Step 1: Fix CaptionSettingsPanel labels (9 warnings)**
The labels at lines 48, 61, 74, 90, 108, 120, 165, 195, and 209 are not associated with their controls. Fix each by adding `id` attributes to controls and `for` attributes to labels. Use unique IDs prefixed with `caption-`.
**Line 48 — Font label + select:** Change:
```svelte
<label>Font</label>
<select
value={settings.fontFamily}
```
to:
```svelte
<label for="caption-font">Font</label>
<select
id="caption-font"
value={settings.fontFamily}
```
**Line 61 — Font Size label + range input:** The label wraps the text but not the input. Wrap the input inside the label:
```svelte
<label>
Font Size
<span class="value">{settings.fontSize}px</span>
<input
type="range" min="12" max="36" step="1"
value={settings.fontSize}
oninput={(e) => update({ fontSize: parseInt((e.target as HTMLInputElement).value, 10) })}
/>
</label>
```
Remove the standalone `<input>` that was a sibling below the closing `</label>`.
**Line 74 — Text Color label:** Change:
```svelte
<label>Text Color</label>
<div class="row-controls">
<input
type="color" value={settings.textColor}
```
to:
```svelte
<label for="caption-text-color">Text Color</label>
<div class="row-controls">
<input
id="caption-text-color"
type="color" value={settings.textColor}
```
**Line 90 — Dimmed Text label:** This is a heading-style label for a radio group. Use `id`/`for` pointing to the first radio:
```svelte
<label for="caption-dimmed-auto">Dimmed Text</label>
<div class="radio-row">
<label>
<input id="caption-dimmed-auto" type="radio" name="dimmed-mode" value="auto"
```
**Line 108 — Dim Opacity label + range:** Wrap the input inside the label (same pattern as Font Size):
```svelte
<label>
Dim Opacity
<span class="value">{Math.round(settings.dimmedOpacity * 100)}%</span>
<input
type="range" min="10" max="90" step="5"
value={Math.round(settings.dimmedOpacity * 100)}
oninput={(e) => update({ dimmedOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
/>
</label>
```
Remove the standalone `<input>` below.
**Line 120 — Dimmed Color label:** Change:
```svelte
<label>Dimmed Color</label>
<div class="row-controls">
<input
type="color" value={settings.dimmedColor}
```
to:
```svelte
<label for="caption-dimmed-color">Dimmed Color</label>
<div class="row-controls">
<input
id="caption-dimmed-color"
type="color" value={settings.dimmedColor}
```
**Line 165 — BG Opacity label + range:** Wrap input inside label (same as Font Size/Dim Opacity):
```svelte
<label>
BG Opacity
<span class="value">{Math.round(settings.backgroundOpacity * 100)}%</span>
<input
type="range" min="0" max="100" step="5"
value={Math.round(settings.backgroundOpacity * 100)}
oninput={(e) => update({ backgroundOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
/>
</label>
```
Remove the standalone `<input>` below.
**Line 195 — Shadow Depth label + range:** Wrap input inside label:
```svelte
<label>
Shadow Depth
<span class="value">{settings.shadowDepth}px</span>
<input
type="range" min="1" max="5" step="1"
value={settings.shadowDepth}
oninput={(e) => update({ shadowDepth: parseInt((e.target as HTMLInputElement).value, 10) })}
/>
</label>
```
Remove the standalone `<input>` below.
**Line 209 — Position label:** Same as Dimmed Text — heading for radio group:
```svelte
<label for="caption-pos-bottom">Position</label>
<div class="radio-row">
<label>
<input id="caption-pos-bottom" type="radio" name="caption-position" value="bottom"
```
- [ ] **Step 2: Fix ClipList (2 warnings: tabindex + key handler)**
In `ClipList.svelte` line 46, the `<div class="clip-list" role="listbox">` needs `tabindex="0"` and a `onkeydown` handler. Change:
```svelte
<div class="clip-list" role="listbox" onclick={handleContainerClick}>
```
to:
```svelte
<div
class="clip-list"
role="listbox"
tabindex="0"
onclick={handleContainerClick}
onkeydown={(e) => {
if (e.key === 'Escape') {
selectClip(null);
}
}}
>
```
- [ ] **Step 3: Fix PreferencesPanel (2 warnings: tabindex + key handler)**
In `PreferencesPanel.svelte`, change lines 60-61:
```svelte
<div class="overlay" role="presentation" onclick={onClose}>
<div class="panel" role="dialog" onclick={(e) => e.stopPropagation()}>
```
to:
```svelte
<div class="overlay" role="presentation" onclick={onClose} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
<div class="panel" role="dialog" tabindex="-1" onclick={(e) => e.stopPropagation()}>
```
- [ ] **Step 4: Fix ExportDialog (2 warnings: tabindex + key handler)**
In `ExportDialog.svelte`, change lines 139-140:
```svelte
<div class="overlay" role="presentation" onclick={onClose}>
<div class="dialog" role="dialog" onclick={(e) => e.stopPropagation()}>
```
to:
```svelte
<div class="overlay" role="presentation" onclick={onClose} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
<div class="dialog" role="dialog" tabindex="-1" onclick={(e) => e.stopPropagation()}>
```
- [ ] **Step 5: Fix AboutDialog (2 warnings: tabindex + key handler)**
In `AboutDialog.svelte`, change lines 15-16:
```svelte
<div class="overlay" role="presentation" onclick={onClose}>
<div class="dialog" role="dialog" onclick={(e) => e.stopPropagation()}>
```
to:
```svelte
<div class="overlay" role="presentation" onclick={onClose} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
<div class="dialog" role="dialog" tabindex="-1" onclick={(e) => e.stopPropagation()}>
```
- [ ] **Step 6: Fix VideoPlayer (1 warning: video without track)**
In `VideoPlayer.svelte`, add a `<track>` inside the `<video>` element after the `playsinline` attribute (around line 197):
```svelte
<video
bind:this={videoElement}
src={videoSrc}
ontimeupdate={handleTimeUpdate}
onplay={handlePlay}
onpause={handlePause}
onerror={handleError}
onloadeddata={handleLoadedData}
preload="metadata"
playsinline
>
<track kind="captions" />
</video>
```
Note: change the self-closing `></video>` to wrap the `<track>` element.
- [ ] **Step 7: Fix App.svelte resize handle (1 warning: non-interactive element with mouse handler)**
In `App.svelte`, update the resize handle div (lines 188-193) to add keyboard support:
```svelte
<div
class="resize-handle"
class:active={isResizing}
role="separator"
aria-orientation="horizontal"
aria-valuenow={timelineHeight}
aria-valuemin={40}
aria-valuemax={500}
tabindex="0"
onmousedown={handleResizeStart}
onkeydown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
timelineHeight = Math.min(500, timelineHeight + 10);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
timelineHeight = Math.max(40, timelineHeight - 10);
}
}}
></div>
```
- [ ] **Step 8: Verify all a11y warnings are resolved**
Run:
```bash
npm run check 2>&1
```
Expected: `svelte-check found 0 errors and 0 warnings`
Also verify the production build:
```bash
npm run build 2>&1 | grep -c "svelte"
```
Expected: No a11y warnings in the Vite build output (only the normal build success lines).
- [ ] **Step 9: Commit**
```bash
git add src/lib/components/CaptionSettingsPanel.svelte \
src/lib/components/ClipList.svelte \
src/lib/components/PreferencesPanel.svelte \
src/lib/components/ExportDialog.svelte \
src/lib/components/AboutDialog.svelte \
src/lib/components/VideoPlayer.svelte \
src/App.svelte
git commit -m "fix: resolve all Svelte a11y warnings
- Associate labels with controls in CaptionSettingsPanel (9 warnings)
- Add tabindex and keyboard handlers to dialog overlays (8 warnings)
- Add <track> to video element for caption accessibility
- Add keyboard support to resize handle separator"
```
---
### Task 3: Rust Dead Code Annotations
**Files:**
- Modify: `src-tauri/src/commands/export.rs:23`
- Modify: `src-tauri/src/commands/video.rs:23`
- Modify: `src-tauri/src/services/clip_exporter.rs:10,204,210`
- Modify: `src-tauri/src/services/video_resolver.rs:7`
- Test: `cargo build`
**Interfaces:**
- Consumes: nothing
- Produces: Zero warnings from `cargo build`
- [ ] **Step 1: Annotate `ExportEvent::Error` in `commands/export.rs`**
Add `#[allow(dead_code)]` on the `Error` variant (line 23):
```rust
Finished {
paths: Vec<String>,
},
#[allow(dead_code)] // Reserved for future error-channel reporting
Error {
message: String,
},
```
- [ ] **Step 2: Annotate `DownloadEvent::Error` in `commands/video.rs`**
Add `#[allow(dead_code)]` on the `Error` variant (line 23):
```rust
Finished { success: bool, path: String },
#[allow(dead_code)] // Reserved for future error-channel reporting
Error { message: String },
```
- [ ] **Step 3: Annotate unused functions in `services/clip_exporter.rs`**
Add `#[allow(dead_code)]` on each unused function:
Line 10:
```rust
/// Strip YouTube auto-generated VTT karaoke tags and positioning metadata
/// that confuse ffmpeg's VTT parser / mov_text conversion.
/// Returns the path to a cleaned temp VTT file.
#[allow(dead_code)] // Scaffolded for caption burn-in pipeline
pub fn sanitize_vtt_for_ffmpeg(caption_path: &str, temp_dir: &str) -> Result<String, String> {
```
Line 204:
```rust
#[allow(dead_code)] // Scaffolded for caption burn-in pipeline
fn opacity_to_ass_back_colour(opacity: f64) -> String {
```
Line 210:
```rust
#[allow(dead_code)] // Scaffolded for caption burn-in pipeline
fn caption_style_to_force_style(style: &CaptionStyle) -> String {
```
- [ ] **Step 4: Annotate `SubtitleFormat` in `services/video_resolver.rs`**
Add `#[allow(dead_code)]` on the struct (line 7):
```rust
#[derive(Debug, Deserialize)]
#[allow(dead_code)] // Fields used for serde deserialization
pub struct SubtitleFormat {
pub ext: Option<String>,
pub url: Option<String>,
}
```
- [ ] **Step 5: Verify zero Rust warnings**
Run:
```bash
cd src-tauri && cargo build 2>&1 | grep "warning:"
```
Expected: No output (zero warnings).
- [ ] **Step 6: Commit**
```bash
git add src-tauri/src/commands/export.rs \
src-tauri/src/commands/video.rs \
src-tauri/src/services/clip_exporter.rs \
src-tauri/src/services/video_resolver.rs
git commit -m "fix: suppress Rust dead code warnings with annotations
- ExportEvent::Error and DownloadEvent::Error reserved for future use
- clip_exporter functions scaffolded for caption burn-in pipeline
- SubtitleFormat fields required for serde deserialization"
```
---
### Task 4: Test Fix
**Files:**
- Modify: `tests/lib/stores/clips.test.ts:60-68`
- Test: `npm test`
**Interfaces:**
- Consumes: nothing (test-only change)
- Produces: 31/31 tests passing
- [ ] **Step 1: Update the failing test assertion**
In `tests/lib/stores/clips.test.ts`, change the test "updates selected clip start when I is pressed" (lines 60-68):
```typescript
it('updates selected clip start when I is pressed', () => {
addClip(10, 20);
const id = getClips()[0].id;
selectClip(id);
markInPoint(12);
expect(getClips()[0].startTime).toBe(12);
expect(getPendingInPoint()).toBeNull();
});
```
The change: `markInPoint(5)` → `markInPoint(12)` and `toBe(5)` → `toBe(12)`.
Time 12 is inside the clip (10–20) so `isTimeInsideClip` returns true and the edit path runs. Time 5 was outside the 0.5s tolerance (needed ≥ 9.5).
- [ ] **Step 2: Verify all tests pass**
Run:
```bash
npm test
```
Expected:
```
Test Files 5 passed (5)
Tests 31 passed (31)
```
- [ ] **Step 3: Commit**
```bash
git add tests/lib/stores/clips.test.ts
git commit -m "fix: correct clip in-point edit test for EDIT_TOLERANCE
markInPoint(5) is outside the clip (10-20) with 0.5s tolerance.
Use markInPoint(12) which is inside the clip and triggers the edit path."
```
---
### Task 5: Release Build Script & Packaging
**Files:**
- Create: `scripts/build-release.sh`
- Modify: `src-tauri/tauri.conf.json:38` (bundle targets)
- Modify: `.gitignore` (add `dist/`)
- Modify: `package.json` (add `release` script)
- Modify: `README.md` (add Release Build section)
- Test: `npm run release`
**Interfaces:**
- Consumes: Tasks 1–4 completed (clean builds)
- Produces: `dist/release/GUI Video Clipper.app` — a runnable, ad-hoc signed macOS app bundle
- [ ] **Step 1: Change bundle targets in `tauri.conf.json`**
In `src-tauri/tauri.conf.json`, change line 38 from:
```json
"targets": "all",
```
to:
```json
"targets": ["app", "dmg"],
```
- [ ] **Step 2: Add `dist/` to `.gitignore`**
Append to `.gitignore`:
```
dist/
```
- [ ] **Step 3: Add `release` script to `package.json`**
Add to the `"scripts"` section of `package.json`:
```json
"release": "bash scripts/build-release.sh"
```
Place it after the existing `"tauri"` script.
- [ ] **Step 4: Create `scripts/build-release.sh`**
```bash
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION_FILE="$PROJECT_ROOT/VERSION"
# ── Read and validate version ──────────────────────────────────────
if [ ! -f "$VERSION_FILE" ]; then
echo "ERROR: VERSION file not found" >&2
exit 1
fi
VERSION=$(cat "$VERSION_FILE" | sed 's/^[vV]//' | tr -d '[:space:]')
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "ERROR: '$VERSION' is not valid semver (expected X.Y.Z)" >&2
exit 1
fi
echo "=== Building GUI Video Clipper v$VERSION ==="
echo ""
# ── Install frontend dependencies ──────────────────────────────────
echo "→ Installing dependencies…"
cd "$PROJECT_ROOT"
npm install --silent
# ── Run tauri build ────────────────────────────────────────────────
echo "→ Building release (this may take a few minutes)…"
npx tauri build 2>&1 | tail -5
# ── Locate built artifacts ─────────────────────────────────────────
# Tauri outputs to src-tauri/target/release/bundle/
BUNDLE_DIR="$PROJECT_ROOT/src-tauri/target/release/bundle"
APP_SRC="$BUNDLE_DIR/macos/GUI Video Clipper.app"
DMG_GLOB="$BUNDLE_DIR/dmg/GUI Video Clipper_*.dmg"
if [ ! -d "$APP_SRC" ]; then
echo "ERROR: .app bundle not found at '$APP_SRC'" >&2
echo "Check the build output above for errors." >&2
exit 1
fi
# ── Ad-hoc code sign ──────────────────────────────────────────────
echo "→ Ad-hoc signing .app…"
codesign --force --deep -s - "$APP_SRC"
# ── Copy to dist/release/ ─────────────────────────────────────────
DIST_DIR="$PROJECT_ROOT/dist/release"
mkdir -p "$DIST_DIR"
echo "→ Copying to dist/release/…"
rm -rf "$DIST_DIR/GUI Video Clipper.app"
cp -R "$APP_SRC" "$DIST_DIR/"
# Copy DMG if it was built
DMG_FILE=$(ls $DMG_GLOB 2>/dev/null | head -1 || true)
if [ -n "$DMG_FILE" ]; then
cp "$DMG_FILE" "$DIST_DIR/"
echo " ✓ DMG: $(basename "$DMG_FILE")"
fi
# ── Summary ────────────────────────────────────────────────────────
APP_SIZE=$(du -sh "$DIST_DIR/GUI Video Clipper.app" | cut -f1)
echo ""
echo "=== Build Complete ==="
echo " Version: v$VERSION"
echo " App: dist/release/GUI Video Clipper.app ($APP_SIZE)"
if [ -n "$DMG_FILE" ]; then
DMG_SIZE=$(du -sh "$DIST_DIR/$(basename "$DMG_FILE")" | cut -f1)
echo " DMG: dist/release/$(basename "$DMG_FILE") ($DMG_SIZE)"
fi
echo ""
echo "To run: open \"dist/release/GUI Video Clipper.app\""
```
Make it executable:
```bash
chmod +x scripts/build-release.sh
```
- [ ] **Step 5: Update README with Release Build section**
Add a new section after "## Build & Run" in `README.md`:
```markdown
## Release Build
```bash
npm run release
```
This runs the full Tauri production build, ad-hoc code signs the `.app` bundle, and copies the output to `dist/release/`.
The resulting `GUI Video Clipper.app` can be launched directly or dragged to `/Applications`.
```
- [ ] **Step 6: Verify the release build**
Run:
```bash
npm run release
```
Expected:
- Script prints version, build progress, and summary
- `dist/release/GUI Video Clipper.app` exists
- App launches: `open "dist/release/GUI Video Clipper.app"`
- [ ] **Step 7: Commit**
```bash
git add scripts/build-release.sh \
src-tauri/tauri.conf.json \
.gitignore \
package.json \
README.md
git commit -m "feat: add release build script
- scripts/build-release.sh: builds, signs, and copies .app to dist/release/
- Bundle targets narrowed to app + dmg
- npm run release convenience script
- README documents the release build workflow"
```
---
## Final Verification
After all tasks, run the full check suite:
```bash
npm run check # 0 errors, 0 warnings
npm test # 31/31 pass
cd src-tauri && cargo build 2>&1 | grep "warning:" # no output
npm run release # produces dist/release/GUI Video Clipper.app
```

View File

@@ -0,0 +1,112 @@
# Build Cleanup & Release Packaging
**Date:** 2026-09-22
**Status:** Approved
## Overview
Resolve all build warnings and errors across the frontend (svelte-check, Vite) and backend (cargo), fix the failing test, and implement a release build script that produces a distributable `GUI Video Clipper.app` bundle.
## Scope
Two workstreams:
1. **Warning/Error Cleanup** — zero warnings from `npm run check`, `cargo build`, and `npm test`
2. **Release Build Packaging** — `scripts/build-release.sh` that produces a signed `.app` in `dist/release/`
## 1. TypeScript Errors (4)
All in `vite.config.ts`:
| Error | Root Cause | Fix |
|-------|-----------|-----|
| Cannot find `node:path` | Missing `@types/node` | `npm install -D @types/node`, add `"types": ["node"]` to `tsconfig.json` |
| Cannot find `node:process` | Same | Same |
| Cannot find `node:url` | Same | Same |
| `test` not in `UserConfigExport` | `defineConfig` from `vite` doesn't know vitest's `test` field | Import `defineConfig` from `vitest/config` instead |
## 2. Svelte A11y Warnings (19)
### 2a. Labels not associated with controls (9 in CaptionSettingsPanel.svelte)
Labels are siblings of their controls rather than wrapping them. Fix by either:
- Wrapping the `<input>`/`<select>` inside the `<label>` element, or
- Adding `id` to the control and `for` to the label
Prefer the wrapping approach where layout allows.
### 2b. Dialog divs missing tabindex (4 files)
Add `tabindex="-1"` to `<div role="dialog">` in `PreferencesPanel`, `ExportDialog`, `AboutDialog`, and `<div role="listbox">` in `ClipList`. Makes them programmatically focusable without entering the tab order.
### 2c. Click handlers without key handlers (4 files)
Dialog overlay `onclick={onClose}` divs need corresponding `onkeydown` handlers. Add Escape key handling on the overlay. For `ClipList`'s listbox container, add an `onkeydown` handler mirroring the container click logic.
### 2d. `<video>` without `<track>` (1 in VideoPlayer.svelte)
Add an empty `<track kind="captions" default />` to the `<video>` element. Our custom caption overlay handles actual rendering.
### 2e. Resize handle (1 in App.svelte)
The resize handle `<div>` with `role="separator"` and `onmousedown` needs:
- `tabindex="0"` for keyboard focusability
- `onkeydown` handler for arrow key resizing
- `aria-valuenow`, `aria-valuemin`, `aria-valuemax` attributes
## 3. Rust Dead Code Warnings (6)
Add `#[allow(dead_code)]` with explanatory comments:
| Warning | Location | Annotation Rationale |
|---------|----------|---------------------|
| `ExportEvent::Error` unused | `commands/export.rs:23` | Reserved for future error-channel reporting |
| `DownloadEvent::Error` unused | `commands/video.rs:23` | Reserved for future error-channel reporting |
| `sanitize_vtt_for_ffmpeg` unused | `clip_exporter.rs:10` | Scaffolded for caption burn-in pipeline |
| `opacity_to_ass_back_colour` unused | `clip_exporter.rs:204` | Scaffolded for caption burn-in pipeline |
| `caption_style_to_force_style` unused | `clip_exporter.rs:210` | Scaffolded for caption burn-in pipeline |
| `SubtitleFormat.ext`/`.url` unread | `video_resolver.rs:8-9` | Fields required for serde deserialization |
## 4. Test Fix
**File:** `tests/lib/stores/clips.test.ts`
**Test:** "updates selected clip start when I is pressed"
The test calls `markInPoint(5)` on a clip spanning 10–20. With `EDIT_TOLERANCE` of 0.5s, time 5 is outside the clip boundary (needs ≥ 9.5). The test was written before the tolerance check existed.
**Fix:** Change `markInPoint(5)` to `markInPoint(12)` (inside the clip, within tolerance). Update the assertion to `expect(getClips()[0].startTime).toBe(12)`. This verifies the edit actually changes the start time (unlike `markInPoint(10)` which would pass trivially since start is already 10).
After fix: 31/31 tests pass.
## 5. Release Build Script
### 5a. `scripts/build-release.sh`
A shell script that:
1. Reads `VERSION` from the project root
2. Runs `npm install`
3. Runs `npx tauri build` (builds frontend + Rust + bundles .app and .dmg)
4. Ad-hoc code signs the `.app`: `codesign --force --deep -s - <path>`
5. Copies `.app` (and `.dmg` if built) to `dist/release/`
6. Prints summary: version, output path, bundle size
### 5b. Configuration changes
- `tauri.conf.json`: Change `"targets": "all"` to `"targets": ["app", "dmg"]`
- `.gitignore`: Add `dist/`
- `package.json`: Add `"release": "bash scripts/build-release.sh"`
### 5c. README update
Add a "Release Build" section documenting `npm run release` and its outputs.
## Verification
After all changes:
- `npm run check` exits 0 with 0 errors, 0 warnings
- `cargo build 2>&1 | grep warning` returns nothing
- `npm test` shows 31/31 pass
- `npm run release` produces `dist/release/GUI Video Clipper.app`
- The `.app` launches without the Gatekeeper right-click workaround

View File

@@ -1,6 +1,6 @@
{
"name": "gui-video-clipper",
"version": "0.1.0",
"version": "0.1.1",
"description": "macOS GUI video clipper (Tauri + Svelte)",
"type": "module",
"scripts": {

2
src-tauri/Cargo.lock generated
View File

@@ -1281,7 +1281,7 @@ dependencies = [
[[package]]
name = "gui-video-clipper"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"axum",
"dirs",

View File

@@ -1,6 +1,6 @@
[package]
name = "gui-video-clipper"
version = "0.1.0"
version = "0.1.1"
description = "A macOS GUI app for clipping online videos"
authors = ["cottongin"]
edition = "2021"

View File

@@ -2,11 +2,89 @@ mod commands;
mod models;
mod services;
use std::collections::HashSet;
use std::path::PathBuf;
use commands::{dependencies, export, media_analysis, video};
use tauri::Manager;
struct MediaServerPort(u16);
/// Prepend well-known macOS tool directories to PATH so that Homebrew-installed
/// binaries (ffmpeg, yt-dlp, etc.) are discoverable when the app is launched as
/// a standalone .app bundle (which inherits only a minimal system PATH).
///
/// Also sets PYENV_ROOT when pyenv is present so that pyenv shims can resolve
/// to the correct Python (and its pip-installed yt-dlp plugins).
fn fix_path_env() {
let home = std::env::var("HOME").unwrap_or_default();
let mut extra_dirs: Vec<PathBuf> = Vec::new();
if !home.is_empty() {
let home_path = PathBuf::from(&home);
// pyenv shims — must come before /opt/homebrew/bin so the pyenv-managed
// yt-dlp (which knows about pip-installed plugins) wins over Homebrew's.
let pyenv_root = home_path.join(".pyenv");
if pyenv_root.is_dir() {
extra_dirs.push(pyenv_root.join("shims"));
if std::env::var("PYENV_ROOT").is_err() {
std::env::set_var("PYENV_ROOT", &pyenv_root);
}
}
// nvm — node/deno installed via nvm live under a versioned directory
let nvm_dir = home_path.join(".nvm/versions/node");
if nvm_dir.is_dir() {
if let Ok(entries) = std::fs::read_dir(&nvm_dir) {
// Pick the lexicographically last (highest) version
let mut versions: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path().join("bin"))
.filter(|p| p.is_dir())
.collect();
versions.sort();
if let Some(latest) = versions.pop() {
extra_dirs.push(latest);
}
}
}
// User-local installs
for rel in [".local/bin", ".cargo/bin", ".deno/bin"] {
extra_dirs.push(home_path.join(rel));
}
}
// System-wide Homebrew / MacPorts paths
for dir in [
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
"/usr/local/bin",
"/usr/local/sbin",
] {
extra_dirs.push(PathBuf::from(dir));
}
// Filter to directories that actually exist
let extra_dirs: Vec<PathBuf> = extra_dirs.into_iter().filter(|p| p.is_dir()).collect();
let current = std::env::var("PATH").unwrap_or_default();
let current_dirs: HashSet<&str> = current.split(':').collect();
let mut new_path = String::new();
for dir in &extra_dirs {
let s = dir.to_string_lossy();
if !current_dirs.contains(s.as_ref()) {
new_path.push_str(&s);
new_path.push(':');
}
}
new_path.push_str(&current);
std::env::set_var("PATH", &new_path);
}
#[tauri::command]
fn get_media_server_port(state: tauri::State<'_, MediaServerPort>) -> u16 {
state.0
@@ -20,6 +98,8 @@ pub fn run() {
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_process::init())
.setup(|app| {
fix_path_env();
// Bind synchronously (no async needed), then spawn on Tauri's runtime
let (port, listener) = services::media_server::bind_media_server()
.expect("Failed to bind media server");

View File

@@ -54,17 +54,11 @@ pub fn check_js_runtime() -> Option<String> {
check_tool_exists("deno").or_else(|| check_tool_exists("node"))
}
pub fn check_pot_plugin(cookie_source: &CookieSource) -> bool {
let mut command = Command::new("yt-dlp");
command
.arg("-v")
.arg("--simulate")
.arg("--remote-components")
.arg("ejs:github")
.args(cookie_source.to_ytdlp_args())
.arg("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
let output = command.output();
pub fn check_pot_plugin(_cookie_source: &CookieSource) -> bool {
// Use `yt-dlp -v` (no URL) which lists loaded plugins without network access.
// The old approach ran a full YouTube simulation (~9s, network-dependent) which
// failed silently in the .app bundle context.
let output = Command::new("yt-dlp").arg("-v").output();
match output {
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "GUI Video Clipper",
"version": "0.1.0",
"version": "0.1.1",
"identifier": "xyz.cottongin.gui-video-clipper",
"build": {
"beforeDevCommand": "npm run dev",
@@ -34,7 +34,10 @@
},
"bundle": {
"active": true,
"targets": ["app", "dmg"],
"targets": [
"app",
"dmg"
],
"icon": [
"icons/32x32.png",
"icons/128x128.png",