diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 4b3237e..e62a3fc 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -145,9 +145,19 @@ jobs: - name: Test every target with real CPU inference run: cargo test --locked --workspace --all-targets + # The vendors' hosts are outside this project's control, so an outage + # there is reported here without blocking a build. + - name: Download and run the managed yt-dlp and FFmpeg + continue-on-error: true + run: cargo test --locked --test native_cli managed_downloads -- --ignored + - name: Build release executable run: cargo build --locked --release -p justtools + - name: Build File Explorer extension + if: runner.os == 'Windows' + run: cargo build --locked --release -p justtools-shell + - name: Check release RMBG CPU inference shell: pwsh run: | @@ -167,12 +177,40 @@ jobs: - name: Package Windows artifact if: matrix.archive == 'zip' shell: pwsh + env: + JUSTTOOLS_SIGNING_PFX: ${{ secrets.JUSTTOOLS_SIGNING_PFX }} + JUSTTOOLS_SIGNING_PASSWORD: ${{ secrets.JUSTTOOLS_SIGNING_PASSWORD }} run: | $stage = "dist/${{ matrix.artifact }}" $archive = "dist/${{ matrix.artifact }}.zip" New-Item -ItemType Directory -Force -Path $stage | Out-Null Copy-Item "target/release/${{ matrix.executable }}", README.md, AGENTS.md, LICENSE, THIRD_PARTY_LICENSES.md -Destination $stage + Copy-Item "target/release/justtools_shell.dll" -Destination $stage Copy-Item docs, skills -Destination $stage -Recurse + if ($env:JUSTTOOLS_SIGNING_PFX) { + # A signed package identity is what admits the menu to the + # top-level Windows 11 context menu without Developer Mode. + $kit = Get-ChildItem "${env:ProgramFiles(x86)}/Windows Kits/10/bin/10.*/x64" -Directory | + Where-Object { Test-Path (Join-Path $_.FullName "makeappx.exe") } | + Sort-Object Name -Descending | Select-Object -First 1 + if ($null -eq $kit) { throw "The Windows SDK packaging tools were not found." } + $certificate = Join-Path $env:RUNNER_TEMP "justtools-signing.pfx" + [IO.File]::WriteAllBytes($certificate, [Convert]::FromBase64String($env:JUSTTOOLS_SIGNING_PFX)) + try { + $subject = (New-Object Security.Cryptography.X509Certificates.X509Certificate2( + $certificate, $env:JUSTTOOLS_SIGNING_PASSWORD)).Subject + $layout = Join-Path $env:RUNNER_TEMP "justtools-package" + & "target/release/${{ matrix.executable }}" context package --output $layout --publisher $subject + if ($LASTEXITCODE -ne 0) { throw "The package layout could not be written." } + $package = Join-Path $stage "justtools-shell.msix" + & (Join-Path $kit.FullName "makeappx.exe") pack /o /nv /d $layout /p $package + if ($LASTEXITCODE -ne 0) { throw "makeappx failed with exit code $LASTEXITCODE" } + & (Join-Path $kit.FullName "signtool.exe") sign /fd SHA256 /f $certificate /p $env:JUSTTOOLS_SIGNING_PASSWORD $package + if ($LASTEXITCODE -ne 0) { throw "signtool failed with exit code $LASTEXITCODE" } + } finally { + Remove-Item -LiteralPath $certificate -Force + } + } Compress-Archive -Path "$stage/*" -DestinationPath $archive $hash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() $checksum = "$hash $([IO.Path]::GetFileName($archive))`n" @@ -207,6 +245,11 @@ jobs: & ./ready.ps1 & "$env:JUSTTOOLS_BIN/justready.exe" --help & "$env:JUSTTOOLS_BIN/justcommit.exe" --help + if (-not (Test-Path -LiteralPath "$env:JUSTTOOLS_BIN/justtools_shell.dll" -PathType Leaf)) { + throw "The bootstrap did not install the File Explorer extension." + } + & "$env:JUSTTOOLS_BIN/just.exe" context status + if ($LASTEXITCODE -ne 0) { throw "just context status failed." } - name: Exercise Unix bootstrap against packaged release if: matrix.archive == 'tar.gz' diff --git a/AGENTS.md b/AGENTS.md index 661079e..0ce3f6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,8 @@ ## Product contract - Preserve one portable Rust binary with native `just*` aliases and short - dispatch through `just `. + dispatch through `just `. The only companion is the Windows File + Explorer extension, `justtools_shell.dll`, which holds no tool logic. - A supported command with no arguments opens its console UI. Any explicit argument or redirected input keeps deterministic headless behavior. - Keep launchers visually consistent: saved settings are labeled, destination @@ -12,8 +13,16 @@ - Persist changed defaults atomically. Never save inputs, payloads, credentials, confirmation bypasses, or one-run actions such as kill, push, repair, check, download, or dry run. +- Download only yt-dlp and FFmpeg without asking, only into the per-user + JustTools folder, and only after verifying the vendor's published SHA-256. + Every other dependency keeps its interactive confirmation. - Preserve source files unless replacement is explicit. Reject ambiguous output collisions before processing and install outputs atomically. +- Describe the File Explorer menu only in `explorer/menu`. A menu entry must be + an ordinary headless command or a launcher, must keep its source unless its + label says "in place", and must never pass `--yes`. Keep the menu one level + deep: Windows 11 leaves a nested flyout empty. The extension runs inside + Explorer and must not be able to panic. ## Changes @@ -38,9 +47,13 @@ cargo +1.90.0 check --locked --workspace --all-targets cargo build --locked --release -p justtools ``` +On Windows, also build `-p justtools-shell`. + Exercise changed commands through both their bare launcher and explicit -headless form. For image operations, verify paths, formats, dimensions, alpha, +headless form. After changing the context menu, run `just context install` and +right-click a matching file in both the Windows 11 menu and "Show more options". For image operations, verify paths, formats, dimensions, alpha, source retention, and overwrite behavior. Before pushing a version tag, require a green six-platform branch matrix. Then verify the tagged run publishes all six archives and their checksum sidecars, with `docs/`, `AGENTS.md`, and -`skills/justtools/` present in every archive. +`skills/justtools/` present in every archive and `justtools_shell.dll` in both +Windows archives. diff --git a/Cargo.lock b/Cargo.lock index 7f54612..852ad30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -995,6 +995,20 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.6+spec-1.1.0", + "vswhom", + "winreg", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1644,7 +1658,7 @@ dependencies = [ [[package]] name = "justtools" -version = "2.5.0" +version = "2.6.0" dependencies = [ "anyhow", "assert_cmd", @@ -1658,6 +1672,7 @@ dependencies = [ "image", "jpeg-encoder", "justtools-core", + "justtools-menu", "netstat2", "ort", "predicates", @@ -1670,7 +1685,7 @@ dependencies = [ "tar", "tempfile", "thiserror 2.0.19", - "toml", + "toml 0.9.12+spec-1.1.0", "ureq", "walkdir", "webp", @@ -1705,6 +1720,20 @@ dependencies = [ "zip", ] +[[package]] +name = "justtools-menu" +version = "2.6.0" + +[[package]] +name = "justtools-shell" +version = "2.6.0" +dependencies = [ + "embed-resource", + "justtools-menu", + "windows", + "windows-core 0.61.2", +] + [[package]] name = "kasuari" version = "0.4.12" @@ -3782,12 +3811,27 @@ dependencies = [ "indexmap 2.14.0", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -3797,6 +3841,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_parser" version = "1.1.3+spec-1.1.0" @@ -3976,6 +4029,26 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "vtparse" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index d7cb863..85cf6e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "justtools" -version = "2.5.0" +version = "2.6.0" edition = "2024" rust-version = "1.90" description = "Fast, opinionated, cross-platform just* command-line tools" license = "MIT" [workspace] -members = ["ports/core-tools"] +members = ["explorer/menu", "explorer/shell", "ports/core-tools"] default-members = ["."] resolver = "3" @@ -27,6 +27,7 @@ flate2 = "1.1" image = { version = "0.25.10", default-features = false, features = ["bmp", "gif", "ico", "jpeg", "png", "pnm", "qoi", "tiff", "webp"] } jpeg-encoder = "0.7.1" justtools-core = { path = "ports/core-tools" } +justtools-menu = { path = "explorer/menu" } netstat2 = "0.11.2" ort = { version = "=2.0.0-rc.13", default-features = false, features = ["api-22", "coreml", "cuda", "directml", "load-dynamic", "std"] } rayon = "1.10" @@ -45,7 +46,7 @@ webp = { version = "0.3.1", default-features = false } zip = { version = "4.3", default-features = false, features = ["deflate"] } [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_DataExchange", "Win32_System_Memory", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } winreg = "0.55" [dev-dependencies] diff --git a/README.md b/README.md index edcfc4b..fd0316a 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ Windows, macOS, and Linux on x64 and ARM64. just # browse every tool justvideo # interactive console justvideo clip.mov # direct/headless execution +justvideo clip.mov --resolution 1080p # 480p, 720p, 1080p, 1440p, 4k, source +justaudio https://example.com/watch?v=ID # yt-dlp download, then convert +justpaste --clipboard # download whatever the copied link is justoptimize hero.png --dry-run justrmbg portrait.jpg ``` @@ -46,11 +49,13 @@ and one-run actions never persist. The bottom line always shows the exact | Need | Commands | | --- | --- | | Images | `justoptimize`, `justcrop`, `justjpg`, `justpng`, `justwebp`, `justavif`, `justresize`, `justrmbg` | -| Audio and video | `justaudio`, `justmp3`, `justwav`, `justvideo` | +| Audio and video | `justaudio`, `justmp3`, `justwav`, `justvideo` (also accept `http(s)` URLs) | +| Downloads | `justpaste` saves any link: a file as-is, a page through yt-dlp | | Documents and data | `justjson`, `justpdf`, `justlinks`, `justsvg`, `justqr` | -| Development | `justports`, `justport`, `bunt`, `justcommit`, `justzip` | +| Development | `justports`, `justport`, `justip`, `bunt`, `justcommit`, `justzip` | | Setup | `justready` | | Shell helpers | `mkcd`, `claude_`, `codex_` (automatic in Windows PowerShell) | +| File Explorer | `just context install` adds a right-click menu on Windows | Use ` --help` for every option. Direct aliases also work as short dispatch, such as `just optimize`, `just ports`, and `just rmbg`. @@ -65,6 +70,29 @@ also creates missing parents. `claude_` runs `claude --dangerously-skip-permissi and `codex_` runs `codex --yolo`, forwarding additional arguments. See the [shell helpers guide](docs/mkcd.md). +## Windows right-click menu + +`just context install` adds **JustTools** to the File Explorer context menu for +the current user. Right-click a video for JustVideo at 480p, 720p, 1080p, +1440p, or 4K and JustMP3/JustAudio/JustWAV; an image for JustOptimize, +JustResize sizes, WebP/AVIF/JPEG conversion, JustCrop, and JustRMBG; a PDF, +SVG, JSON file, or document for the tools that read it; and a folder or its +background for **JustPaste**, which downloads the copied link into it. Each entry +opens a console showing its **Headless:** command, and sources are kept unless +an entry says "in place". On Windows 11 the top-level menu needs a signed +release package or Developer Mode; otherwise the menu sits under "Show more +options". See the [context menu guide](docs/context-menu.md). + +## Media downloads + +`justvideo`, `justaudio`, `justmp3`, and `justwav` accept an `http(s)` URL +wherever they accept a file. yt-dlp downloads it to a temporary folder and the +tool converts it with its usual settings into the current folder, or `--output`. +The download is never kept and `--playlist` opts into multi-entry links. +A missing yt-dlp or FFmpeg is downloaded automatically and verified, and +yt-dlp is updated whenever twelve hours have passed. See the +[downloads guide](docs/downloads.md). + ## Safe image output The console shows both the resolved output location and overwrite policy before @@ -95,8 +123,9 @@ short, Shorts, live, and embed URLs merge into one clean video link without timestamps, tracking, or playlist parameters. For complete behavior, see the [console UI](docs/console-ui.md), -[JustPDF](docs/pdf.md), [JustLinks](docs/links.md), [JustPorts](docs/ports.md), [bunt](docs/bunt.md), -[JustCommit](docs/commit.md), and [JustReady](docs/ready.md) guides. +[context menu](docs/context-menu.md), [JustPaste](docs/paste.md), [JustPDF](docs/pdf.md), [JustLinks](docs/links.md), [JustPorts](docs/ports.md), [JustIP](docs/ip.md), +[bunt](docs/bunt.md), [JustCommit](docs/commit.md), [media downloads](docs/downloads.md), and +[JustReady](docs/ready.md) guides. ## Agent skill @@ -120,10 +149,14 @@ cargo build --locked --release -p justtools ./target/release/just install ``` +On Windows, add `-p justtools-shell` to build the File Explorer extension that +`just install` copies beside the executable. + Rust 1.90 is the minimum supported toolchain. Version tags build and publish checksummed archives for all six supported OS/architecture targets through [`native.yml`](.github/workflows/native.yml). Each archive includes the brief -README, complete guides, screenshots, agent instructions, and installable skill. +README, complete guides, screenshots, agent instructions, and installable skill; +Windows archives also carry the File Explorer extension. MIT licensed. Third-party notices ship in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md). diff --git a/docs/console-ui.md b/docs/console-ui.md index 7201262..df9a237 100644 --- a/docs/console-ui.md +++ b/docs/console-ui.md @@ -93,13 +93,15 @@ The following settings are remembered when changed: | Tool group | Saved settings | | --- | --- | -| Image/media tools | Quality, speed/method, sample rate, bitrate, output, parallel jobs, recursion, target re-encoding, source handling | +| Image/media tools | Quality, speed/method, video resolution, sample rate, bitrate, output, parallel jobs, recursion, target re-encoding, source handling | | `justoptimize` | Web quality, output, replacement, parallel jobs, recursion | | `justresize` | Maximum/width/height, crop, upscale, JPEG quality, output, replacement, jobs, recursion | | `justcrop` | Bounds mode, alpha threshold, padding, output, replacement, jobs, recursion | | `justjson` / `justsvg` | Formatting/optimization choices, output, recursion | | `justpdf` | Operation, output, page range, rotation, recursion | | `justlinks` | Text or CSV format, output, recursion, parallel jobs | +| `justip` | Address family, output format, and lookup timeout | +| `justpaste` | Output folder and playlist downloads; links are never saved | | `justport` | Whether UDP endpoints are included | | `justqr` | Output, format, size, correction level, margin, colors | | `justrmbg` | Provider, output, and optional model path; pinned-dependency download permission is visible but one-run | diff --git a/docs/context-menu.md b/docs/context-menu.md new file mode 100644 index 0000000..628180b --- /dev/null +++ b/docs/context-menu.md @@ -0,0 +1,113 @@ +# File Explorer context menu (Windows) + +JustTools can add itself to the right-click menu in File Explorer. The menu +only appears on things a tool can read, and only lists the tools that apply to +the selection. + +```powershell +just context install # add the menu for the current user +just context status # which registration is active +just context uninstall # remove everything install added +``` + +No elevation is needed and nothing outside the current user is changed. + +![JustTools context-menu icons on dark and light menus](images/context-menu-icons.png) + +## What the menu shows + +| Right-click | Entries | +| --- | --- | +| Video (`.mp4 .mov .mkv .avi .webm` …) | **JustVideo** at 480p, 720p, 1080p, 1440p, 4K, or source resolution, plus more options…; **JustMP3**, **JustAudio**, **JustWAV** to extract the soundtrack | +| Audio (`.mp3 .wav .flac .m4a .ogg` …) | **JustMP3**, **JustAudio**, **JustWAV**, minus the format the file already has | +| Image (`.jpg .png .webp .bmp .tif .qoi`) | **JustOptimize**; **JustResize** to fit 1024, 1920, 2560, or 3840 px, plus more options…; **JustWebP**, **JustAVIF**, **JustJPG**, **JustPNG**; **JustCrop**, **JustRMBG** | +| PDF | **JustPDF** merge (two or more), split into pages, save embedded images, save links, show details, more options…; **JustLinks** | +| SVG / JSON | **JustSVG**; **JustJSON** format, minify, validate | +| Office, Markdown, HTML, text, CSV | **JustLinks** to `links.txt` or `links.csv` | +| Folder, or a folder's empty background | **JustPaste** downloads the copied link into that folder; JustOptimize, JustResize, JustWebP, JustVideo, JustMP3, and JustLinks launchers opened in that folder; **JustZip** when the folder is a Git working tree | + +Every choice is its own row in the JustTools flyout, such as +**JustVideo · 1080p**. The Windows 11 menu does not draw a flyout nested inside +another, so the menu is one level deep by design. + +Every menu ends with **Open JustTools here**, which opens the `just` tool +browser in that folder. `.ts` and `.mts` are left alone because they are far +more often TypeScript than video. + +A mixed selection shows every tool that can read at least one selected file, +and each tool is given only the files it reads. + +## What a click does + +A console window opens, prints the exact **Headless:** command and the folder +it runs in, and runs it. That command is what a terminal user would type, so +the menu never does anything the command line cannot. + +- Every window closes by itself: after about three seconds when the run + succeeded, and after thirty when it failed or the result is something to read + (PDF details, JSON validation). +- Enter closes it at once; any other key stops the countdown and keeps the + window open until Enter. +- Entries ending in **…** open the tool's [console launcher](console-ui.md) + with the selection filled in, so saved defaults and every option apply. + +## Defaults + +Menu entries use each tool's built-in defaults plus the one setting the entry +names; saved launcher defaults apply only to the **…** entries. + +| Entry | Command | Result | +| --- | --- | --- | +| JustVideo · 1080p | `justvideo --resolution 1080p` | `-web.mp4`, never upscaled, source kept | +| JustMP3 / JustAudio / JustWAV | `justmp3`, `justaudio`, `justwav` | `.mp3` / `.m4a` / `.wav`, source kept | +| JustOptimize | `justoptimize` | `-optimized.`, source kept | +| JustResize · fit 2560 px | `justresize --max 2560` | `-resized.`, source kept | +| JustWebP / JustAVIF | `justwebp --output .`, `justavif --output .` | `.webp` / `.avif` beside the source, **source kept** | +| JustJPG | `justjpg` | `-optimized.jpg`, source kept | +| JustPNG · compress in place | `justpng` | Replaces the PNG only when the result is smaller | +| JustCrop / JustRMBG | `justcrop`, `justrmbg` | `-cropped.` / `-nobg.png`, source kept | +| JustPaste | `justpaste --clipboard` | The copied link's file in that folder; a taken name is numbered, never overwritten | +| JustSVG, JustJSON Format/Minify | `justsvg`, `justjson`, `justjson --minify` | Rewrites the file in place | + +Entries that change a file in place say so. JustWebP and JustAVIF normally +remove a source once a smaller result exists; the menu passes `--output .` so +a right-click never deletes anything. The menu never passes `--yes`, so a tool +that would overwrite an existing result still asks in the console window. + +## Windows 11 top-level menu + +Windows 11 only lets an app with a package identity into its top-level menu. +`just context install` uses the best registration available: + +| Registration | Appears | Needs | +| --- | --- | --- | +| Package | Top-level Windows 11 menu | A signed `justtools-shell.msix` beside `just.exe`, **or** Developer Mode (Settings > System > For developers) | +| Classic | "Show more options" on Windows 11 (also Shift+right-click); the normal menu on Windows 10 | Nothing | + +`just context install --classic` forces the classic registration. Only one +registration is active at a time. `just install` refreshes a menu that the +installed copy registered, and leaves a menu registered by another copy alone. + +The menu is served by `justtools_shell.dll`, which ships in the Windows release +archives and is copied beside `just.exe` by `just install`. When building from +source, build it too: + +```sh +cargo build --locked --release -p justtools -p justtools-shell +./target/release/just install +just context install +``` + +Release signing is optional. When the release workflow is given the +`JUSTTOOLS_SIGNING_PFX` (base64 PFX) and `JUSTTOOLS_SIGNING_PASSWORD` secrets, +it packs and signs `justtools-shell.msix` into the Windows archives; the +certificate must chain to a root the user's machine trusts. + +## How it is built + +- `explorer/menu` is the menu itself: which tools read which extensions, the + entries for a selection, and the command behind each entry. It is plain Rust, + tested on every platform, and shared by the extension and the CLI. +- `explorer/shell` is the Explorer extension (`IExplorerCommand`). It asks the + model what to show and starts `just.exe context run `. +- `explorer/icons/build.py` draws the icons; the generated files are committed. diff --git a/docs/downloads.md b/docs/downloads.md new file mode 100644 index 0000000..7ae2bc8 --- /dev/null +++ b/docs/downloads.md @@ -0,0 +1,103 @@ +# Media downloads + +`justvideo`, `justaudio`, `justmp3`, and `justwav` accept an `http(s)` URL +anywhere they accept a file. yt-dlp downloads it into a temporary folder, the +tool converts it with its own settings, and only the converted result is kept. +A URL therefore produces exactly what the same tool produces from a local file. + +```sh +justvideo https://example.com/watch?v=ID # ./ [<id>].mp4, 720p CRF 28 +justvideo --resolution 4k https://example.com/watch?v=ID # up to 2160p +justaudio https://example.com/watch?v=ID # ./<title> [<id>].m4a, AAC-LC 160k +justmp3 https://example.com/watch?v=ID -o mixes # mixes/<title> [<id>].mp3 +justvideo --playlist https://example.com/list=ID # every entry of a playlist +justaudio --dry-run https://example.com/watch # plan only; nothing downloads +justvideo clips https://example.com/watch?v=ID # local folder and URL in one run +``` + +## What is downloaded + +| Tool | Selected source | +| --- | --- | +| `justvideo` | Best video plus audio at 1080p or lower (or at a larger `--resolution`), merged to a temporary MKV | +| `justaudio`, `justmp3`, `justwav` | Best audio-only stream | + +The cap exists because the result is re-encoded to `--resolution` (720p by +default), so a taller source would only cost bandwidth. `--resolution 1440p` and +`4k` raise the cap to match, and `--resolution source` removes it. Playlists are skipped unless `--playlist` is passed: +a `watch?v=...&list=...` link downloads the one video. yt-dlp runs with +`--ignore-config`, so a personal yt-dlp configuration cannot redirect the +output or re-encode behind the tool's back. + +## Output + +Downloads have no source folder to sit beside, so they are written to the +current folder, or to `--output`, as `<title> [<id>].<target-extension>`. +`justvideo` uses that plain name rather than the `-web` suffix reserved for +optimizing a local file. An existing destination is replaced atomically, +exactly as it is for a local conversion. + +The downloaded file itself is temporary and is removed with the run, so +`--replace` has nothing to remove for a URL. Local files and URLs can be mixed +in one command; two inputs that would write the same output are rejected before +anything is encoded. Each line of the summary names the URL it came from: + +``` +justaudio: downloading https://example.com/watch?v=ID +justaudio: 1 file(s), 2 job(s) + done https://example.com/watch?v=ID -> Title [ID].m4a 12.4 MiB -> 3.1 MiB +``` + +## Keeping yt-dlp current + +Every run of `justvideo`, `justaudio`, `justmp3`, `justwav`, or `justpaste` +checks when yt-dlp was last updated and runs `yt-dlp --update` if twelve hours +have passed, whether or not that run downloads anything. Sites change faster +than releases reach package managers, so this keeps downloads working. A dry +run and a machine without yt-dlp are left alone. The timestamp of the last +attempt is stored next to the defaults file, which `just --defaults-path` +prints, as `yt-dlp-update`. + +The update is best-effort. A package-managed yt-dlp refuses to replace itself +and an offline machine cannot reach the release feed; either way the message is +a warning and the run proceeds with the installed version. The next attempt +still waits twelve hours, so a broken feed cannot slow every run. + +## Requirements + +yt-dlp and FFmpeg are used from `PATH` when they are there. When one is +missing, JustTools downloads it automatically, on every platform, the first +time a tool needs it: + +| Program | Source | Verified against | +| --- | --- | --- | +| yt-dlp | The official `yt-dlp/yt-dlp` GitHub release for this OS and CPU | The release's `SHA2-256SUMS` | +| FFmpeg and ffprobe, Windows | Gyan Doshi's release essentials build (the build WinGet's `Gyan.FFmpeg` installs) | The `.sha256` published beside it | +| FFmpeg and ffprobe, macOS and Linux | Martin Riedl's static release builds for x64 and ARM64 | The `.sha256` published beside each | + +The programs go into a per-user folder (`%LOCALAPPDATA%\JustTools\deps\bin` +on Windows, the matching data folder elsewhere). Nothing is installed +system-wide, `PATH` is not changed, no elevation is requested, and a download +that fails verification is discarded. A copy on `PATH` always wins. A managed +yt-dlp updates itself in place through the twelve-hour check; a managed FFmpeg +is refreshed with `just deps fetch ffmpeg`. + +```sh +just deps fetch yt-dlp ffmpeg # download or refresh the managed copies now +``` + +Set `JUSTTOOLS_NO_DOWNLOAD=1` to turn automatic downloads off, and +`JUSTTOOLS_DEPS_DIR` to choose the folder. If a download is off or fails, the +previous flow applies: JustTools shows the exact install command for WinGet, +Homebrew, APT, DNF, or pacman and runs it only after interactive confirmation. + +Set `YTDLP_BIN` to pin a specific executable, the same way `FFMPEG_BIN` pins +FFmpeg. An explicit path is resolved as-is and never triggers a download or an +installation. + +## Console + +Run `justvideo` or `justaudio` with no arguments and type or paste the URL into +**Input files / folders / URLs**; separate several with semicolons. The footer +shows where the download will land and the exact `Headless:` command. **Download +playlists** is a saved setting; the URL itself is never saved. diff --git a/docs/images/context-menu-icons.png b/docs/images/context-menu-icons.png new file mode 100644 index 0000000..1edfd4c Binary files /dev/null and b/docs/images/context-menu-icons.png differ diff --git a/docs/ip.md b/docs/ip.md new file mode 100644 index 0000000..98c1982 --- /dev/null +++ b/docs/ip.md @@ -0,0 +1,84 @@ +# JustIP + +`justip` (or `just ip`) reports the public IP address this machine presents to +the internet. Both IPv4 and IPv6 are reported together, so the common question +needs no switch. Run it bare for the console UI; the footer shows the exact +headless command. + +```sh +justip # both families, labeled +justip -4 # IPv4 only +justip -6 # IPv6 only +justip --plain # bare addresses, one per line +justip --json # {"ipv4":"…","ipv6":"…"} +justip --timeout 15 # wait longer on a slow link +``` + +## What the address is + +The address comes from a public echo service, which reports the address it saw +the request arrive from. That is the address the internet sees, which behind +NAT, a VPN, or a mobile carrier is not any address configured on a local +interface. Nothing is sent but the request itself; the reply is a single line. + +Each family is pinned by hostname rather than by a socket option. The IPv4 +services publish only A records and the IPv6 services only AAAA records, so a +request can reach them over one family only. An answer that arrives in the +wrong family is discarded rather than reported under the wrong label. + +| Family | Services, in the order they are tried | +| --- | --- | +| IPv4 | `api.ipify.org`, `ipv4.icanhazip.com`, `v4.ident.me` | +| IPv6 | `api6.ipify.org`, `ipv6.icanhazip.com`, `v6.ident.me` | + +The next service is tried whenever one fails, times out, or answers with +something that is not an address for the family being asked about, so a single +operator's outage does not take the command down with it. + +## Output + +The default labeled form is one line per requested family: + +```text +IPv4: 203.0.113.7 +IPv6: 2001:db8::7 +``` + +`--plain` prints only the addresses, one per line, for piping into another +command. `--json` prints one object whose `ipv4` and `ipv6` keys hold the +address or `null`. Both keep stdout free of anything but the answer and send +every explanation to stderr. + +## Families that cannot be reached + +Most networks still have no public IPv6 route. Rather than wait out a +connection that cannot complete, `justip` reads the source address the routing +table would choose and skips the lookup when there is no globally routable one. +No packet is sent for this check. A machine holding only link-local or +unique-local IPv6 addresses is reported immediately: + +```text +IPv4: 203.0.113.7 +IPv6: unavailable (this machine has no public IPv6 route) +``` + +One family answering is still an answer, so that run succeeds. The command +fails, with exit status 1 and nothing on stdout, only when no requested family +answers at all — including `justip -6` on a machine with no IPv6. + +`--timeout` bounds each family's lookup separately, from 1 to 60 seconds, and +defaults to 5. Both families are looked up at once, so reporting both costs one +timeout rather than two. + +## Saved defaults + +The launcher remembers the address family, the output format, and the timeout. +Nothing else is stored, and no address is ever written to disk. + +## Testing without the network + +`JUSTIP_IPV4_URL` and `JUSTIP_IPV6_URL` replace the service list for that +family with their own semicolon-separated URLs. An overridden family skips the +public-route check, since the override is not expected to be on the public +internet. This exists for the test suite and for pointing the command at a +private echo service. diff --git a/docs/paste.md b/docs/paste.md new file mode 100644 index 0000000..b25ef70 --- /dev/null +++ b/docs/paste.md @@ -0,0 +1,74 @@ +# JustPaste + +`justpaste` turns a link into a file in the current folder. It works out what +the link is and takes the shortest route to the real file. + +```sh +justpaste https://example.com/photo.jpg # saves photo.jpg +justpaste https://www.youtube.com/watch?v=ID # saves "<title> [<id>].mp4" +justpaste https://www.tiktok.com/@user/video/ID # same, through yt-dlp +justpaste --clipboard # every link in the copied text +justpaste -o downloads https://a.test/x.pdf https://b.test/clip +justpaste --dry-run --clipboard # list the links; no network +``` + +On Windows, right-click a folder or its empty background and choose +**JustTools ▸ JustPaste · download the copied link here**; see the +[context menu guide](context-menu.md). + +## How a link is handled + +1. **The link is requested.** If the answer is a file of any kind (image, + video, audio, PDF, archive, anything that is not a web page), it is saved + as-is. Nothing is converted. +2. **A web page goes to yt-dlp**, which supports YouTube, TikTok, Instagram, + X, Reddit, Vimeo, and most other video and social sites, plus a generic + extractor for pages that simply embed a video. A link the plain request + could not fetch is tried through yt-dlp as well. +3. **If yt-dlp finds nothing**, the video or image the page declares for link + previews (`og:video`, `og:image`, and the Twitter equivalents) is saved. +4. Otherwise the link is reported as failed and the run exits with status 1. + Other links in the same run are still processed. + +yt-dlp downloads the highest resolution available, prefers H.264 and MP4 at +that resolution so the result plays everywhere, and merges into an `.mp4`. A +playlist link downloads one entry unless `--playlist` is passed. To shrink or +convert the result, run `justvideo`, `justmp3`, or another tool on it, or give +those tools the link directly. + +## Names and safety + +| Source | Saved name | +| --- | --- | +| Direct file | The server's `Content-Disposition` name, else the last part of the address; an extension is added from the content type when the name has none | +| yt-dlp | `<title> [<id>].<ext>` | + +- Nothing is overwritten. A taken name is saved as `name (2).ext`, `name (3).ext`. +- A download is written under a hidden `.justpaste-*` name inside the + destination and renamed only when complete, so an interrupted run leaves + nothing behind under a real name. +- Names are stripped of characters and reserved words that a file system + refuses. +- `--dry-run` only lists the links and the destination; it makes no request. + +## Inputs + +Links can be given as arguments, piped on standard input, or read from the +clipboard with `--clipboard`. Text around a link is ignored, so a copied +sentence or a list works; a bare `www.` address is treated as HTTPS. Anything +that is not an `http(s)` link is a usage error. + +Clipboard access is built in on Windows, uses `pbpaste` on macOS, and uses +`wl-paste`, `xclip`, or `xsel` on Linux. + +## Dependencies and privacy + +Direct file links need nothing. Page links need yt-dlp and FFmpeg, which are +downloaded automatically when they are not on `PATH`, and yt-dlp is updated +whenever twelve hours have passed; both are described in the +[downloads guide](downloads.md). A link is fetched from the address you give +it, so the site sees the request. Only download what you have the right to. + +Bare `justpaste` opens the launcher. A blank **Links** row pastes the +clipboard; **Output folder** and **Download playlists** are saved, and the +links themselves never are. diff --git a/explorer/icons/audio.ico b/explorer/icons/audio.ico new file mode 100644 index 0000000..18df1ec Binary files /dev/null and b/explorer/icons/audio.ico differ diff --git a/explorer/icons/brand.ico b/explorer/icons/brand.ico new file mode 100644 index 0000000..9712df1 Binary files /dev/null and b/explorer/icons/brand.ico differ diff --git a/explorer/icons/build.py b/explorer/icons/build.py new file mode 100644 index 0000000..ac2c9c6 --- /dev/null +++ b/explorer/icons/build.py @@ -0,0 +1,244 @@ +"""Draw the JustTools context-menu icons. + + python explorer/icons/build.py + +Each icon is a rounded colour tile with a white glyph, drawn on a 32-unit grid +and rendered separately at every size so the 16 px menu version stays crisp. +The tile carries its own contrast, so one set serves light and dark menus. +Requires Pillow. The generated .ico and .png files are committed. +""" + +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +HERE = Path(__file__).parent +SIZES = [16, 20, 24, 32, 40, 48, 64, 256] +SUPERSAMPLE = 8 +WHITE = (255, 255, 255, 255) + + +class Canvas: + """A drawing surface addressed in 32-unit grid coordinates.""" + + def __init__(self, size): + self.pixels = size * SUPERSAMPLE + self.scale = self.pixels / 32 + self.image = Image.new("RGBA", (self.pixels, self.pixels), (0, 0, 0, 0)) + self.draw = ImageDraw.Draw(self.image) + + def p(self, value): + return value * self.scale + + def box(self, x0, y0, x1, y1): + return [self.p(x0), self.p(y0), self.p(x1), self.p(y1)] + + def rect(self, x0, y0, x1, y1, radius=0, fill=WHITE): + self.draw.rounded_rectangle(self.box(x0, y0, x1, y1), self.p(radius), fill=fill) + + def ellipse(self, x0, y0, x1, y1, fill=WHITE): + self.draw.ellipse(self.box(x0, y0, x1, y1), fill=fill) + + def polygon(self, points, fill=WHITE): + self.draw.polygon([(self.p(x), self.p(y)) for x, y in points], fill=fill) + + def line(self, points, width, fill=WHITE): + scaled = [(self.p(x), self.p(y)) for x, y in points] + self.draw.line(scaled, fill=fill, width=round(self.p(width)), joint="curve") + radius = self.p(width) / 2 + for x, y in (scaled[0], scaled[-1]): + self.draw.ellipse([x - radius, y - radius, x + radius, y + radius], fill=fill) + + def arc(self, x0, y0, x1, y1, start, end, width, fill=WHITE): + self.draw.arc(self.box(x0, y0, x1, y1), start, end, fill=fill, width=round(self.p(width))) + + def text(self, value, size, x, y): + font = ImageFont.truetype("consolab.ttf", round(self.p(size))) + self.draw.text((self.p(x), self.p(y)), value, font=font, fill=WHITE, anchor="mm") + + def tile(self, top, bottom): + gradient = Image.new("RGBA", (1, self.pixels)) + for y in range(self.pixels): + mix = y / (self.pixels - 1) + gradient.putpixel( + (0, y), + tuple(round(a + (b - a) * mix) for a, b in zip(top, bottom)) + (255,), + ) + gradient = gradient.resize((self.pixels, self.pixels)) + mask = Image.new("L", (self.pixels, self.pixels), 0) + ImageDraw.Draw(mask).rounded_rectangle(self.box(1, 1, 31, 31), self.p(7.5), fill=255) + self.image.paste(gradient, (0, 0), mask) + + def finish(self, size): + return self.image.resize((size, size), Image.LANCZOS) + + +def rgb(value): + return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5)) + + +def brand(c): + c.rect(12, 8, 22.5, 11.6, 1.8) + c.rect(18.4, 8, 22.5, 19, 1.8) + c.arc(10.2, 11.6, 22.5, 24.4, 0, 180, 4.1) + c.ellipse(10.2, 16.6, 14.3, 20.7) + + +def video(c): + c.polygon([(12.2, 8.6), (24.2, 16), (12.2, 23.4)]) + c.line([(12.2, 8.6), (24.2, 16), (12.2, 23.4), (12.2, 8.6)], 1.6) + + +def audio(c): + for x, top in ((8.4, 13), (13.2, 8.5), (18, 11), (22.8, 14)): + c.rect(x - 1.5, top, x + 1.5, 32 - top, 1.5) + + +def optimize(c): + c.ellipse(19.2, 8.6, 23.6, 13) + c.polygon([(7, 24), (13.4, 13.2), (19.8, 24)]) + c.polygon([(15.4, 24), (20.4, 16.8), (25.4, 24)]) + c.rect(7, 22.6, 25.4, 24.4, 0.9) + + +def resize(c): + c.line([(10, 22), (22, 10)], 2.4) + c.line([(15.6, 9.6), (22.4, 9.6), (22.4, 16.4)], 2.4) + c.line([(9.6, 15.6), (9.6, 22.4), (16.4, 22.4)], 2.4) + + +def convert(c): + c.line([(8.5, 12), (23, 12)], 2.4) + c.line([(19, 8), (23.4, 12), (19, 16)], 2.4) + c.line([(23.5, 20), (9, 20)], 2.4) + c.line([(13, 16), (8.6, 20), (13, 24)], 2.4) + + +def crop(c): + c.line([(11.5, 7.5), (11.5, 20.5), (24.5, 20.5)], 2.4) + c.line([(7.5, 11.5), (20.5, 11.5), (20.5, 24.5)], 2.4) + + +def cutout(c): + c.ellipse(12.4, 7.6, 19.6, 14.8) + c.draw.pieslice(c.box(8, 16.6, 24, 32.6), 180, 360, fill=WHITE) + + +def pdf(c): + c.polygon([(9.5, 7), (18.6, 7), (22.5, 10.9), (22.5, 25), (9.5, 25)]) + c.polygon([(18.6, 7), (22.5, 10.9), (18.6, 10.9)], fill=(255, 255, 255, 120)) + for y in (14.4, 17.6, 20.8): + c.rect(12, y - 0.8, 20, y + 0.8, 0.8, fill=TILES["pdf"][1] + (255,)) + + +def svg(c): + c.arc(4, 9.5, 23, 28.5, 270, 360, 2.2) + c.rect(10.7, 6.8, 16.3, 12.4, 1) + c.rect(19.6, 15.7, 25.2, 21.3, 1) + c.line([(8, 23.5), (13.4, 23.5)], 2.2) + + +def json(c): + c.text("{}", 17.5, 16, 15.2) + + +def links(c): + c.line([(13.4, 18.6), (18.6, 13.4)], 2.4) + c.arc(7, 13.2, 18.8, 25, 60, 300, 2.4) + c.arc(13.2, 7, 25, 18.8, 240, 480, 2.4) + + +def zip_(c): + c.rect(8, 9, 24, 14, 1.4) + c.rect(9.4, 15.2, 22.6, 24.5, 1.4) + c.rect(13.6, 17.6, 18.4, 19.8, 1.1, fill=TILES["zip"][1] + (255,)) + + +def console(c): + c.line([(9.5, 11), (15, 16), (9.5, 21)], 2.4) + c.line([(17.2, 21.4), (23, 21.4)], 2.4) + + +def options(c): + for y, knob in ((10.5, 19.5), (16, 12), (21.5, 17)): + c.line([(8.5, y), (23.5, y)], 1.8) + c.ellipse(knob - 2.7, y - 2.7, knob + 2.7, y + 2.7) + + +def paste(c): + c.line([(16, 8), (16, 18.5)], 2.4) + c.line([(11.4, 14.4), (16, 19), (20.6, 14.4)], 2.4) + c.line([(8.5, 19.5), (8.5, 23.5), (23.5, 23.5), (23.5, 19.5)], 2.4) + + +TILES = { + "brand": (rgb("#9d6bff"), rgb("#6d28d9")), + "video": (rgb("#fb7185"), rgb("#e11d48")), + "audio": (rgb("#34d399"), rgb("#059669")), + "optimize": (rgb("#60a5fa"), rgb("#2563eb")), + "resize": (rgb("#22d3ee"), rgb("#0891b2")), + "convert": (rgb("#818cf8"), rgb("#4f46e5")), + "crop": (rgb("#2dd4bf"), rgb("#0d9488")), + "cutout": (rgb("#f472b6"), rgb("#db2777")), + "pdf": (rgb("#f87171"), rgb("#dc2626")), + "svg": (rgb("#fb923c"), rgb("#ea580c")), + "json": (rgb("#fbbf24"), rgb("#d97706")), + "links": (rgb("#38bdf8"), rgb("#0284c7")), + "zip": (rgb("#94a3b8"), rgb("#475569")), + "console": (rgb("#64748b"), rgb("#1e293b")), + "options": (rgb("#9ca3af"), rgb("#4b5563")), + "paste": (rgb("#e879f9"), rgb("#c026d3")), +} + +GLYPHS = { + "brand": brand, + "video": video, + "audio": audio, + "optimize": optimize, + "resize": resize, + "convert": convert, + "crop": crop, + "cutout": cutout, + "pdf": pdf, + "svg": svg, + "json": json, + "links": links, + "zip": zip_, + "console": console, + "options": options, + "paste": paste, +} + + +def render(name, size): + canvas = Canvas(size) + canvas.tile(*TILES[name]) + GLYPHS[name](canvas) + return canvas.finish(size) + + +def main(): + for name in GLYPHS: + frames = [render(name, size) for size in SIZES] + frames[-1].save( + HERE / f"{name}.ico", + sizes=[(size, size) for size in SIZES], + append_images=frames[:-1], + ) + # The package identity needs loose logos beside its manifest. + for stem, size in (("logo-44", 44), ("logo-150", 150), ("logo-store", 50)): + render("brand", size).save(HERE / f"{stem}.png") + sheet = Image.new("RGBA", (len(GLYPHS) * 72 + 8, 2 * 72 + 44), (32, 32, 32, 255)) + sheet.paste((243, 243, 243, 255), (0, 72 + 22, sheet.width, sheet.height)) + for row in range(2): + for index, name in enumerate(GLYPHS): + top = row * (72 + 22) + large = render(name, 48) + small = render(name, 16) + sheet.alpha_composite(large, (index * 72 + 8, top + 8)) + sheet.alpha_composite(small, (index * 72 + 8, top + 64)) + sheet.save(HERE.parent.parent / "docs" / "images" / "context-menu-icons.png") + + +if __name__ == "__main__": + main() diff --git a/explorer/icons/console.ico b/explorer/icons/console.ico new file mode 100644 index 0000000..2edf624 Binary files /dev/null and b/explorer/icons/console.ico differ diff --git a/explorer/icons/convert.ico b/explorer/icons/convert.ico new file mode 100644 index 0000000..827ca15 Binary files /dev/null and b/explorer/icons/convert.ico differ diff --git a/explorer/icons/crop.ico b/explorer/icons/crop.ico new file mode 100644 index 0000000..6bda06d Binary files /dev/null and b/explorer/icons/crop.ico differ diff --git a/explorer/icons/cutout.ico b/explorer/icons/cutout.ico new file mode 100644 index 0000000..812b149 Binary files /dev/null and b/explorer/icons/cutout.ico differ diff --git a/explorer/icons/json.ico b/explorer/icons/json.ico new file mode 100644 index 0000000..698fd19 Binary files /dev/null and b/explorer/icons/json.ico differ diff --git a/explorer/icons/links.ico b/explorer/icons/links.ico new file mode 100644 index 0000000..44ff731 Binary files /dev/null and b/explorer/icons/links.ico differ diff --git a/explorer/icons/logo-150.png b/explorer/icons/logo-150.png new file mode 100644 index 0000000..efae42f Binary files /dev/null and b/explorer/icons/logo-150.png differ diff --git a/explorer/icons/logo-44.png b/explorer/icons/logo-44.png new file mode 100644 index 0000000..a46a022 Binary files /dev/null and b/explorer/icons/logo-44.png differ diff --git a/explorer/icons/logo-store.png b/explorer/icons/logo-store.png new file mode 100644 index 0000000..5f2d922 Binary files /dev/null and b/explorer/icons/logo-store.png differ diff --git a/explorer/icons/optimize.ico b/explorer/icons/optimize.ico new file mode 100644 index 0000000..b0d9208 Binary files /dev/null and b/explorer/icons/optimize.ico differ diff --git a/explorer/icons/options.ico b/explorer/icons/options.ico new file mode 100644 index 0000000..c428b17 Binary files /dev/null and b/explorer/icons/options.ico differ diff --git a/explorer/icons/paste.ico b/explorer/icons/paste.ico new file mode 100644 index 0000000..70a1519 Binary files /dev/null and b/explorer/icons/paste.ico differ diff --git a/explorer/icons/pdf.ico b/explorer/icons/pdf.ico new file mode 100644 index 0000000..b86e969 Binary files /dev/null and b/explorer/icons/pdf.ico differ diff --git a/explorer/icons/resize.ico b/explorer/icons/resize.ico new file mode 100644 index 0000000..5b1754d Binary files /dev/null and b/explorer/icons/resize.ico differ diff --git a/explorer/icons/svg.ico b/explorer/icons/svg.ico new file mode 100644 index 0000000..b00c97a Binary files /dev/null and b/explorer/icons/svg.ico differ diff --git a/explorer/icons/video.ico b/explorer/icons/video.ico new file mode 100644 index 0000000..b5febce Binary files /dev/null and b/explorer/icons/video.ico differ diff --git a/explorer/icons/zip.ico b/explorer/icons/zip.ico new file mode 100644 index 0000000..9e6e7eb Binary files /dev/null and b/explorer/icons/zip.ico differ diff --git a/explorer/menu/Cargo.toml b/explorer/menu/Cargo.toml new file mode 100644 index 0000000..8df692d --- /dev/null +++ b/explorer/menu/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "justtools-menu" +version = "2.6.0" +edition = "2024" +rust-version = "1.90" +license = "MIT" +description = "The JustTools File Explorer context-menu model shared by the shell extension and the CLI" + +[dependencies] diff --git a/explorer/menu/src/lib.rs b/explorer/menu/src/lib.rs new file mode 100644 index 0000000..ef988f5 --- /dev/null +++ b/explorer/menu/src/lib.rs @@ -0,0 +1,870 @@ +//! The File Explorer context menu, described once. +//! +//! The shell extension asks [`menu`] what to show for a selection and the CLI +//! asks [`action`] what a chosen entry runs, so the two can never disagree. +//! Nothing here touches Windows; the model is plain data and is tested on +//! every platform. + +/// The COM class of the context-menu command, without braces. +pub const CLASS_ID: &str = "7A1D3C52-6B0E-4E2F-9C41-5D8E2B7F4A10"; +/// [`CLASS_ID`] as the integer the Windows bindings build a GUID from. +pub const CLASS_ID_VALUE: u128 = 0x7A1D3C52_6B0E_4E2F_9C41_5D8E2B7F4A10; +/// The extension library `just install` places beside `just.exe`. +pub const SHELL_LIBRARY: &str = "justtools_shell.dll"; + +/// A JustTools command the menu can start. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Tool { + Video, + Mp3, + Audio, + Wav, + Optimize, + Resize, + Webp, + Avif, + Jpg, + Png, + Crop, + Rmbg, + Pdf, + Svg, + Json, + Links, + Zip, + Paste, +} + +const VIDEO: &[&str] = &[ + "mp4", "m4v", "mov", "mkv", "avi", "webm", "wmv", "flv", "mpg", "mpeg", "m2ts", "3gp", "ogv", +]; +const AUDIO: &[&str] = &[ + "aac", "ac3", "aiff", "alac", "ape", "flac", "m4a", "mka", "mp2", "mp3", "ogg", "opus", "wav", + "wma", +]; +const STILL: &[&str] = &["jpg", "jpeg", "png", "webp", "bmp", "tif", "tiff", "qoi"]; +const ALPHA: &[&str] = &["png", "webp", "tif", "tiff", "qoi"]; +const WEBP_SOURCES: &[&str] = &["jpg", "jpeg", "png", "bmp", "tif", "tiff"]; +const AVIF_SOURCES: &[&str] = &["jpg", "jpeg", "png", "webp", "bmp", "tif", "tiff"]; +const LINK_SOURCES: &[&str] = &[ + "pdf", "xlsx", "xlsm", "docx", "docm", "pptx", "pptm", "md", "markdown", "html", "htm", "txt", + "csv", +]; + +impl Tool { + pub const ALL: [Tool; 18] = [ + Tool::Video, + Tool::Mp3, + Tool::Audio, + Tool::Wav, + Tool::Optimize, + Tool::Resize, + Tool::Webp, + Tool::Avif, + Tool::Jpg, + Tool::Png, + Tool::Crop, + Tool::Rmbg, + Tool::Pdf, + Tool::Svg, + Tool::Json, + Tool::Links, + Tool::Zip, + Tool::Paste, + ]; + + /// The native alias, which is also the launcher and dispatch name. + pub fn command(self) -> &'static str { + match self { + Tool::Video => "justvideo", + Tool::Mp3 => "justmp3", + Tool::Audio => "justaudio", + Tool::Wav => "justwav", + Tool::Optimize => "justoptimize", + Tool::Resize => "justresize", + Tool::Webp => "justwebp", + Tool::Avif => "justavif", + Tool::Jpg => "justjpg", + Tool::Png => "justpng", + Tool::Crop => "justcrop", + Tool::Rmbg => "justrmbg", + Tool::Pdf => "justpdf", + Tool::Svg => "justsvg", + Tool::Json => "justjson", + Tool::Links => "justlinks", + Tool::Zip => "justzip", + Tool::Paste => "justpaste", + } + } + + pub fn name(self) -> &'static str { + match self { + Tool::Video => "JustVideo", + Tool::Mp3 => "JustMP3", + Tool::Audio => "JustAudio", + Tool::Wav => "JustWAV", + Tool::Optimize => "JustOptimize", + Tool::Resize => "JustResize", + Tool::Webp => "JustWebP", + Tool::Avif => "JustAVIF", + Tool::Jpg => "JustJPG", + Tool::Png => "JustPNG", + Tool::Crop => "JustCrop", + Tool::Rmbg => "JustRMBG", + Tool::Pdf => "JustPDF", + Tool::Svg => "JustSVG", + Tool::Json => "JustJSON", + Tool::Links => "JustLinks", + Tool::Zip => "JustZip", + Tool::Paste => "JustPaste", + } + } + + /// Whether the tool can read a file with this lowercase extension. + /// + /// A converter never lists its own target format: turning an MP3 into an + /// MP3 needs the tool's explicit re-encode switch, not a one-click entry. + pub fn accepts(self, extension: &str) -> bool { + let within = |list: &[&str]| list.contains(&extension); + match self { + Tool::Video => within(VIDEO), + Tool::Mp3 => (within(VIDEO) || within(AUDIO)) && extension != "mp3", + Tool::Audio => (within(VIDEO) || within(AUDIO)) && extension != "m4a", + Tool::Wav => (within(VIDEO) || within(AUDIO)) && extension != "wav", + Tool::Optimize | Tool::Resize | Tool::Jpg | Tool::Rmbg => within(STILL), + Tool::Webp => within(WEBP_SOURCES), + Tool::Avif => within(AVIF_SOURCES), + Tool::Png => extension == "png", + Tool::Crop => within(ALPHA), + Tool::Pdf => extension == "pdf", + Tool::Svg => extension == "svg", + Tool::Json => extension == "json", + Tool::Links => within(LINK_SOURCES), + // Folder tools take the folder itself, never a file in it. + Tool::Zip | Tool::Paste => false, + } + } + + fn index(self) -> usize { + Tool::ALL + .iter() + .position(|tool| *tool == self) + .unwrap_or_default() + } +} + +/// The lowercase extension of a file name or path, without the dot. +pub fn extension(path: &str) -> Option<String> { + let name = path.rsplit(['\\', '/']).next().unwrap_or(path); + let (stem, extension) = name.rsplit_once('.')?; + (!stem.is_empty() && !extension.is_empty()).then(|| extension.to_ascii_lowercase()) +} + +/// Every extension the menu is registered for, sorted and without dots. +/// +/// `.ts` and `.mts` are deliberately absent: they are far more often +/// TypeScript than transport streams. +pub fn extensions() -> Vec<&'static str> { + let mut all: Vec<&str> = [VIDEO, AUDIO, STILL, LINK_SOURCES, &["svg", "json"]] + .concat() + .to_vec(); + all.sort_unstable(); + all.dedup(); + all +} + +/// What the user right-clicked, reduced to what the menu depends on. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Selection { + accepted: [u32; Tool::ALL.len()], + pub files: u32, + pub folders: u32, + /// The only selected folder is a Git working tree. + pub git: bool, + video: u32, +} + +impl Selection { + pub fn add_file(&mut self, path: &str) { + self.files += 1; + let Some(extension) = extension(path) else { + return; + }; + if VIDEO.contains(&extension.as_str()) { + self.video += 1; + } + for tool in Tool::ALL { + if tool.accepts(&extension) { + self.accepted[tool.index()] += 1; + } + } + } + + pub fn add_folder(&mut self, git: bool) { + self.folders += 1; + self.git = git && self.folders == 1; + } + + /// How many selected files the tool can read. + pub fn count(&self, tool: Tool) -> u32 { + self.accepted[tool.index()] + } + + fn has(&self, tool: Tool) -> bool { + self.count(tool) > 0 + } +} + +/// Artwork for an entry. The discriminant is the icon's resource ID in the +/// shell extension, so values are stable and start at 101. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u16)] +pub enum Icon { + None = 0, + Brand = 101, + Video = 102, + Audio = 103, + Optimize = 104, + Resize = 105, + Convert = 106, + Crop = 107, + Cutout = 108, + Pdf = 109, + Svg = 110, + Json = 111, + Links = 112, + Zip = 113, + Console = 114, + Options = 115, + Paste = 116, +} + +impl Icon { + pub const ALL: [Icon; 16] = [ + Icon::Brand, + Icon::Video, + Icon::Audio, + Icon::Optimize, + Icon::Resize, + Icon::Convert, + Icon::Crop, + Icon::Cutout, + Icon::Pdf, + Icon::Svg, + Icon::Json, + Icon::Links, + Icon::Zip, + Icon::Console, + Icon::Options, + Icon::Paste, + ]; + + pub fn resource_id(self) -> u16 { + self as u16 + } + + /// The artwork's file stem under `explorer/icons`. + pub fn file_stem(self) -> &'static str { + match self { + Icon::None => "", + Icon::Brand => "brand", + Icon::Video => "video", + Icon::Audio => "audio", + Icon::Optimize => "optimize", + Icon::Resize => "resize", + Icon::Convert => "convert", + Icon::Crop => "crop", + Icon::Cutout => "cutout", + Icon::Pdf => "pdf", + Icon::Svg => "svg", + Icon::Json => "json", + Icon::Links => "links", + Icon::Zip => "zip", + Icon::Console => "console", + Icon::Options => "options", + Icon::Paste => "paste", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Kind { + /// Runs the [`Action`] with this ID. + Action(&'static str), + Separator, +} + +/// One row of the JustTools flyout. +/// +/// The menu is deliberately a single level deep: the Windows 11 context menu +/// draws the flyout of a top-level entry but leaves a flyout nested inside it +/// empty, so every choice has to be a row of its own. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Item { + pub title: String, + pub icon: Icon, + pub kind: Kind, +} + +fn entry(title: impl Into<String>, icon: Icon, id: &'static str) -> Item { + debug_assert!(action(id).is_some(), "unknown action {id}"); + Item { + title: title.into(), + icon, + kind: Kind::Action(id), + } +} + +fn separator() -> Item { + Item { + title: String::new(), + icon: Icon::None, + kind: Kind::Separator, + } +} + +/// Drop separators that would open, close, or double up the menu. +fn tidy(items: Vec<Item>) -> Vec<Item> { + let mut result: Vec<Item> = Vec::with_capacity(items.len()); + for item in items { + if item.kind == Kind::Separator + && result + .last() + .is_none_or(|last| last.kind == Kind::Separator) + { + continue; + } + result.push(item); + } + if result + .last() + .is_some_and(|last| last.kind == Kind::Separator) + { + result.pop(); + } + result +} + +/// The label Explorer shows for the whole JustTools entry. +pub const ROOT_TITLE: &str = "JustTools"; + +/// The entries to show for a selection, in display order. +/// +/// An empty result means JustTools has nothing to offer and stays hidden. +pub fn menu(selection: &Selection) -> Vec<Item> { + let mut items = Vec::new(); + if selection.files > 0 { + file_items(selection, &mut items); + } else if selection.folders > 0 { + folder_items(selection, &mut items); + } + let mut items = tidy(items); + if !items.is_empty() { + items.push(separator()); + items.push(entry("Open JustTools here", Icon::Console, "browse")); + } + items +} + +fn file_items(selection: &Selection, items: &mut Vec<Item>) { + if selection.has(Tool::Video) { + for (label, id) in [ + ("480p", "video.480"), + ("720p (default)", "video.720"), + ("1080p", "video.1080"), + ("1440p", "video.1440"), + ("4K", "video.2160"), + ("source resolution", "video.source"), + ] { + items.push(entry(format!("JustVideo · {label}"), Icon::Video, id)); + } + items.push(entry( + "JustVideo · more options…", + Icon::Options, + "video.options", + )); + items.push(separator()); + } + // A selection made only of videos is having its soundtrack pulled out; + // anything else is a plain format conversion. + let verb = if selection.video == selection.files { + "extract" + } else { + "convert to" + }; + for (tool, format, id) in [ + (Tool::Mp3, "MP3", "mp3"), + (Tool::Audio, "M4A", "audio"), + (Tool::Wav, "WAV", "wav"), + ] { + if selection.has(tool) { + items.push(entry( + format!("{} · {verb} {format}", tool.name()), + Icon::Audio, + id, + )); + } + } + items.push(separator()); + + if selection.has(Tool::Optimize) { + items.push(entry( + "JustOptimize · smallest web format", + Icon::Optimize, + "optimize", + )); + } + if selection.has(Tool::Resize) { + for (label, id) in [ + ("fit 1024 px", "resize.1024"), + ("fit 1920 px (default)", "resize.1920"), + ("fit 2560 px", "resize.2560"), + ("fit 3840 px", "resize.3840"), + ] { + items.push(entry(format!("JustResize · {label}"), Icon::Resize, id)); + } + items.push(entry( + "JustResize · more options…", + Icon::Options, + "resize.options", + )); + } + items.push(separator()); + if selection.has(Tool::Webp) { + items.push(entry("JustWebP · convert to WebP", Icon::Convert, "webp")); + } + if selection.has(Tool::Avif) { + items.push(entry("JustAVIF · convert to AVIF", Icon::Convert, "avif")); + } + if selection.has(Tool::Jpg) { + items.push(entry("JustJPG · convert to JPEG", Icon::Convert, "jpg")); + } + if selection.has(Tool::Png) { + items.push(entry("JustPNG · compress in place", Icon::Convert, "png")); + } + items.push(separator()); + if selection.has(Tool::Crop) { + items.push(entry( + "JustCrop · trim transparent edges", + Icon::Crop, + "crop", + )); + } + if selection.has(Tool::Rmbg) { + items.push(entry("JustRMBG · remove background", Icon::Cutout, "rmbg")); + } + items.push(separator()); + + let pdfs = selection.count(Tool::Pdf); + if pdfs > 0 { + if pdfs > 1 { + items.push(entry( + format!("JustPDF · merge {pdfs} PDFs"), + Icon::Pdf, + "pdf.merge", + )); + } + for (label, id) in [ + ("split into pages", "pdf.split"), + ("save embedded images", "pdf.images"), + ("save links", "pdf.links"), + ("show page count and details", "pdf.info"), + ] { + items.push(entry(format!("JustPDF · {label}"), Icon::Pdf, id)); + } + items.push(entry( + "JustPDF · more options…", + Icon::Options, + "pdf.options", + )); + items.push(separator()); + } + if selection.has(Tool::Svg) { + items.push(entry("JustSVG · optimize in place", Icon::Svg, "svg")); + } + if selection.has(Tool::Json) { + for (label, id) in [ + ("format in place", "json.format"), + ("minify in place", "json.minify"), + ("validate", "json.check"), + ] { + items.push(entry(format!("JustJSON · {label}"), Icon::Json, id)); + } + } + if selection.has(Tool::Links) { + items.push(entry( + "JustLinks · links to links.txt", + Icon::Links, + "links.txt", + )); + items.push(entry( + "JustLinks · links.csv with titles", + Icon::Links, + "links.csv", + )); + } +} + +fn folder_items(selection: &Selection, items: &mut Vec<Item>) { + if selection.folders == 1 { + items.push(entry( + "JustPaste · download the copied link here", + Icon::Paste, + "paste", + )); + items.push(separator()); + // Each opens the tool's launcher in the folder, where a blank input + // already means "this folder". + for (tool, icon, id) in [ + (Tool::Optimize, Icon::Optimize, "folder.optimize"), + (Tool::Resize, Icon::Resize, "folder.resize"), + (Tool::Webp, Icon::Convert, "folder.webp"), + (Tool::Video, Icon::Video, "folder.video"), + (Tool::Mp3, Icon::Audio, "folder.mp3"), + (Tool::Links, Icon::Links, "folder.links"), + ] { + items.push(entry(format!("{} in this folder…", tool.name()), icon, id)); + } + items.push(separator()); + } + if selection.git { + items.push(entry("JustZip · archive Git repository", Icon::Zip, "zip")); + } +} + +/// How a chosen entry starts its tool. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Run { + /// Run headless straight away with the preset arguments. + Headless, + /// Open the tool's console launcher with the selection filled in. + Launcher, + /// Open the tool's console launcher inside the selected folder. + LauncherHere, + /// Open the `just` tool browser inside the folder. + Browse, +} + +/// Where a headless run writes its results. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Output { + /// The tool's own beside-source default. + Default, + /// Pass `--output <source folder>`, which is how the tools that would + /// otherwise consume their source are told to keep it. + SourceFolder, +} + +/// What one menu entry does. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Action { + pub id: &'static str, + /// `None` only for [`Run::Browse`]. + pub tool: Option<Tool>, + /// Arguments placed before the selected paths. + pub args: &'static [&'static str], + pub run: Run, + pub output: Output, + /// The tool takes exactly one input, so run it once per file. + pub per_file: bool, + /// The fewest accepted files the entry needs. + pub minimum: u32, + /// The result is something to read, so the window stays open. + pub hold: bool, +} + +const fn headless(id: &'static str, tool: Tool, args: &'static [&'static str]) -> Action { + Action { + id, + tool: Some(tool), + args, + run: Run::Headless, + output: Output::Default, + per_file: false, + minimum: 1, + hold: false, + } +} + +const fn launcher(id: &'static str, tool: Tool) -> Action { + Action { + run: Run::Launcher, + ..headless(id, tool, &[]) + } +} + +const fn launcher_here(id: &'static str, tool: Tool) -> Action { + Action { + run: Run::LauncherHere, + ..headless(id, tool, &[]) + } +} + +const ACTIONS: &[Action] = &[ + headless("video.480", Tool::Video, &["--resolution", "480p"]), + headless("video.720", Tool::Video, &["--resolution", "720p"]), + headless("video.1080", Tool::Video, &["--resolution", "1080p"]), + headless("video.1440", Tool::Video, &["--resolution", "1440p"]), + headless("video.2160", Tool::Video, &["--resolution", "4k"]), + headless("video.source", Tool::Video, &["--resolution", "source"]), + launcher("video.options", Tool::Video), + headless("mp3", Tool::Mp3, &[]), + headless("audio", Tool::Audio, &[]), + headless("wav", Tool::Wav, &[]), + headless("optimize", Tool::Optimize, &[]), + headless("resize.1024", Tool::Resize, &["--max", "1024"]), + headless("resize.1920", Tool::Resize, &["--max", "1920"]), + headless("resize.2560", Tool::Resize, &["--max", "2560"]), + headless("resize.3840", Tool::Resize, &["--max", "3840"]), + launcher("resize.options", Tool::Resize), + Action { + output: Output::SourceFolder, + ..headless("webp", Tool::Webp, &[]) + }, + Action { + output: Output::SourceFolder, + ..headless("avif", Tool::Avif, &[]) + }, + headless("jpg", Tool::Jpg, &[]), + headless("png", Tool::Png, &[]), + headless("crop", Tool::Crop, &[]), + headless("rmbg", Tool::Rmbg, &[]), + Action { + minimum: 2, + ..headless("pdf.merge", Tool::Pdf, &["merge"]) + }, + Action { + per_file: true, + ..headless("pdf.split", Tool::Pdf, &["split"]) + }, + Action { + per_file: true, + ..headless("pdf.images", Tool::Pdf, &["images"]) + }, + Action { + per_file: true, + ..headless("pdf.links", Tool::Pdf, &["links"]) + }, + Action { + hold: true, + ..headless("pdf.info", Tool::Pdf, &["info"]) + }, + launcher("pdf.options", Tool::Pdf), + headless("svg", Tool::Svg, &[]), + headless("json.format", Tool::Json, &[]), + headless("json.minify", Tool::Json, &["--minify"]), + Action { + hold: true, + ..headless("json.check", Tool::Json, &["--check"]) + }, + headless("links.txt", Tool::Links, &[]), + headless("links.csv", Tool::Links, &["--csv"]), + headless("zip", Tool::Zip, &[]), + headless("paste", Tool::Paste, &["--clipboard"]), + launcher_here("folder.optimize", Tool::Optimize), + launcher_here("folder.resize", Tool::Resize), + launcher_here("folder.webp", Tool::Webp), + launcher_here("folder.video", Tool::Video), + launcher_here("folder.mp3", Tool::Mp3), + launcher_here("folder.links", Tool::Links), + Action { + id: "browse", + tool: None, + args: &[], + run: Run::Browse, + output: Output::Default, + per_file: false, + minimum: 0, + hold: false, + }, +]; + +/// The action behind a menu entry's ID. +pub fn action(id: &str) -> Option<Action> { + ACTIONS.iter().copied().find(|action| action.id == id) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn selection(files: &[&str]) -> Selection { + let mut selection = Selection::default(); + for file in files { + selection.add_file(file); + } + selection + } + + fn titles(items: &[Item]) -> Vec<&str> { + items.iter().map(|item| item.title.as_str()).collect() + } + + fn ids(items: &[Item]) -> Vec<&'static str> { + items + .iter() + .filter_map(|item| match item.kind { + Kind::Action(id) => Some(id), + Kind::Separator => None, + }) + .collect() + } + + #[test] + fn videos_offer_every_resolution_and_audio_extraction_in_one_level() { + let items = menu(&selection(&[r"C:\clips\holiday.MOV"])); + assert_eq!( + titles(&items), + [ + "JustVideo · 480p", + "JustVideo · 720p (default)", + "JustVideo · 1080p", + "JustVideo · 1440p", + "JustVideo · 4K", + "JustVideo · source resolution", + "JustVideo · more options…", + "", + "JustMP3 · extract MP3", + "JustAudio · extract M4A", + "JustWAV · extract WAV", + "", + "Open JustTools here", + ] + ); + assert_eq!( + action("video.2160").unwrap().args, + ["--resolution", "4k"].as_slice() + ); + assert_eq!( + action("video.1080").unwrap().args, + ["--resolution", "1080p"].as_slice() + ); + } + + #[test] + fn converters_never_offer_their_own_format() { + let items = menu(&selection(&["song.mp3"])); + assert_eq!( + titles(&items), + [ + "JustAudio · convert to M4A", + "JustWAV · convert to WAV", + "", + "Open JustTools here", + ] + ); + assert!(!titles(&menu(&selection(&["a.webp"]))).contains(&"JustWebP · convert to WebP")); + } + + #[test] + fn images_group_without_stray_separators() { + let items = menu(&selection(&["photo.jpg"])); + assert_eq!( + titles(&items), + [ + "JustOptimize · smallest web format", + "JustResize · fit 1024 px", + "JustResize · fit 1920 px (default)", + "JustResize · fit 2560 px", + "JustResize · fit 3840 px", + "JustResize · more options…", + "", + "JustWebP · convert to WebP", + "JustAVIF · convert to AVIF", + "JustJPG · convert to JPEG", + "", + "JustRMBG · remove background", + "", + "Open JustTools here", + ] + ); + let png = menu(&selection(&["logo.png"])); + assert!(titles(&png).contains(&"JustPNG · compress in place")); + assert!(titles(&png).contains(&"JustCrop · trim transparent edges")); + } + + #[test] + fn merge_needs_two_pdfs_and_single_input_operations_run_per_file() { + let one = menu(&selection(&["a.pdf"])); + assert_eq!(one[0].title, "JustPDF · split into pages"); + let two = menu(&selection(&["a.pdf", "b.pdf"])); + assert_eq!(two[0].title, "JustPDF · merge 2 PDFs"); + assert_eq!(action("pdf.merge").unwrap().minimum, 2); + assert!(action("pdf.split").unwrap().per_file); + assert!(action("pdf.info").unwrap().hold); + } + + #[test] + fn source_consuming_converters_are_told_to_keep_their_source() { + for id in ["webp", "avif"] { + assert_eq!(action(id).unwrap().output, Output::SourceFolder); + } + assert_eq!(action("jpg").unwrap().output, Output::Default); + } + + #[test] + fn folders_open_launchers_and_only_git_trees_offer_zip() { + let mut plain = Selection::default(); + plain.add_folder(false); + assert!(!titles(&menu(&plain)).contains(&"JustZip · archive Git repository")); + assert_eq!( + menu(&plain)[0].title, + "JustPaste · download the copied link here" + ); + assert_eq!(action("paste").unwrap().args, ["--clipboard"].as_slice()); + assert_eq!(menu(&plain)[2].title, "JustOptimize in this folder…"); + assert_eq!(action("folder.video").unwrap().run, Run::LauncherHere); + + let mut repository = Selection::default(); + repository.add_folder(true); + assert!(titles(&menu(&repository)).contains(&"JustZip · archive Git repository")); + + let mut several = Selection::default(); + several.add_folder(true); + several.add_folder(true); + assert!(menu(&several).is_empty()); + } + + #[test] + fn unsupported_selections_hide_the_menu() { + assert!(menu(&selection(&["notes.xyz", "Makefile"])).is_empty()); + assert!(menu(&Selection::default()).is_empty()); + assert_eq!(extension(r"C:\a.b\file"), None); + assert_eq!(extension(".gitignore"), None); + assert_eq!(extension("Clip.MP4").as_deref(), Some("mp4")); + } + + #[test] + fn every_entry_resolves_and_every_action_is_reachable() { + let mut reachable = std::collections::HashSet::new(); + for extension in extensions() { + let one = format!("file.{extension}"); + let two = format!("other.{extension}"); + let items = menu(&selection(&[&one, &two])); + assert!(!items.is_empty(), ".{extension} is registered for nothing"); + for id in ids(&items) { + let action = action(id).unwrap_or_else(|| panic!("no action {id}")); + assert_eq!(action.id, id); + reachable.insert(id); + } + } + let mut repository = Selection::default(); + repository.add_folder(true); + reachable.extend(ids(&menu(&repository))); + + assert!(!extensions().contains(&"ts")); + let mut seen = std::collections::HashSet::new(); + for action in ACTIONS { + assert!(seen.insert(action.id), "duplicate action {}", action.id); + assert!( + reachable.contains(action.id), + "no menu entry runs {}", + action.id + ); + } + for icon in Icon::ALL { + assert!(!icon.file_stem().is_empty()); + } + assert_eq!(format!("{CLASS_ID_VALUE:032X}"), CLASS_ID.replace('-', "")); + } +} diff --git a/explorer/shell/Cargo.toml b/explorer/shell/Cargo.toml new file mode 100644 index 0000000..30314b1 --- /dev/null +++ b/explorer/shell/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "justtools-shell" +version = "2.6.0" +edition = "2024" +rust-version = "1.90" +license = "MIT" +description = "Windows File Explorer context-menu extension for JustTools" +build = "build.rs" + +[lib] +name = "justtools_shell" +crate-type = ["cdylib"] +# The library is empty off Windows and has no tests of its own; the menu it +# shows is tested in justtools-menu. +test = false +doctest = false + +[target.'cfg(windows)'.dependencies] +justtools-menu = { path = "../menu" } +windows = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_Com", + "Win32_System_Com_StructuredStorage", + "Win32_System_LibraryLoader", + "Win32_System_Memory", + "Win32_System_Ole", + "Win32_System_SystemServices", + "Win32_System_Threading", + "Win32_UI_Shell", + "Win32_UI_Shell_Common", + "Win32_UI_WindowsAndMessaging", +] } +# The `implement` macro names this crate directly. +windows-core = "0.61" + +[target.'cfg(windows)'.build-dependencies] +embed-resource = "3" +justtools-menu = { path = "../menu" } diff --git a/explorer/shell/build.rs b/explorer/shell/build.rs new file mode 100644 index 0000000..65e96ef --- /dev/null +++ b/explorer/shell/build.rs @@ -0,0 +1,75 @@ +//! Compile the menu icons and version details into the extension. +//! +//! The resource script is generated so icon IDs come from `justtools_menu::Icon` +//! and the version from this crate, leaving nothing to keep in step by hand. + +fn main() { + println!("cargo:rerun-if-changed=../icons"); + #[cfg(windows)] + if std::env::var_os("CARGO_CFG_WINDOWS").is_some() { + resources(); + // rustc exports the COM entry points by name; MSVC's reminder that + // they are conventionally PRIVATE does not apply to a library that + // nothing links against. + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + println!("cargo:rustc-link-arg-cdylib=/IGNORE:4104"); + } + } +} + +#[cfg(windows)] +fn resources() { + use std::fmt::Write; + use std::path::PathBuf; + + let icons = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../icons"); + let mut script = String::new(); + for icon in justtools_menu::Icon::ALL { + let path = icons.join(format!("{}.ico", icon.file_stem())); + let path = path.to_string_lossy().replace('\\', "/"); + writeln!(script, "{} ICON \"{path}\"", icon.resource_id()).unwrap(); + } + let version = env!("CARGO_PKG_VERSION"); + let numbers = format!( + "{},{},{},0", + env!("CARGO_PKG_VERSION_MAJOR"), + env!("CARGO_PKG_VERSION_MINOR"), + env!("CARGO_PKG_VERSION_PATCH") + ); + write!( + script, + r#" +1 VERSIONINFO +FILEVERSION {numbers} +PRODUCTVERSION {numbers} +FILEOS 0x40004L +FILETYPE 0x2L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904B0" + BEGIN + VALUE "CompanyName", "JustGains" + VALUE "FileDescription", "JustTools File Explorer context menu" + VALUE "FileVersion", "{version}" + VALUE "InternalName", "justtools_shell" + VALUE "LegalCopyright", "MIT License" + VALUE "OriginalFilename", "justtools_shell.dll" + VALUE "ProductName", "JustTools" + VALUE "ProductVersion", "{version}" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END +"# + ) + .unwrap(); + let output = PathBuf::from(std::env::var_os("OUT_DIR").unwrap()).join("resources.rc"); + std::fs::write(&output, script).unwrap(); + embed_resource::compile(&output, embed_resource::NONE) + .manifest_required() + .expect("the context-menu icons must compile into the extension"); +} diff --git a/explorer/shell/src/lib.rs b/explorer/shell/src/lib.rs new file mode 100644 index 0000000..17ae6a1 --- /dev/null +++ b/explorer/shell/src/lib.rs @@ -0,0 +1,549 @@ +//! The JustTools entry in the Windows File Explorer context menu. +//! +//! Explorer loads this library, asks it what to show for the selection, and on +//! a click it starts `just.exe context run <action>` in a new console window. +//! Everything about *what* the menu contains lives in `justtools-menu`; this +//! file only translates that model to `IExplorerCommand`. +//! +//! The library runs inside Explorer (or its COM surrogate), and the workspace +//! builds with `panic = "abort"`, so nothing here may panic: no unwrapping, no +//! indexing, and no `RefCell` borrow held across a call back into COM. + +#![cfg(windows)] +#![allow(non_snake_case)] + +use std::cell::{Cell, RefCell}; +use std::ffi::c_void; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use justtools_menu::{self as menu, Icon, Item, Kind, Selection, Tool}; +use windows::Win32::Foundation::{ + CLASS_E_CLASSNOTAVAILABLE, CLASS_E_NOAGGREGATION, CloseHandle, E_FAIL, E_NOTIMPL, E_POINTER, + HINSTANCE, HMODULE, MAX_PATH, S_FALSE, S_OK, +}; +use windows::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_DIRECTORY, GetFileAttributesW, INVALID_FILE_ATTRIBUTES, +}; +use windows::Win32::System::Com::{ + CoTaskMemFree, DVASPECT_CONTENT, FORMATETC, IBindCtx, IClassFactory, IClassFactory_Impl, + IDataObject, IServiceProvider, TYMED_HGLOBAL, +}; +use windows::Win32::System::LibraryLoader::GetModuleFileNameW; +use windows::Win32::System::Ole::{ + CF_HDROP, IObjectWithSite, IObjectWithSite_Impl, ReleaseStgMedium, +}; +use windows::Win32::System::SystemServices::DLL_PROCESS_ATTACH; +use windows::Win32::System::Threading::{ + CREATE_NEW_CONSOLE, CREATE_UNICODE_ENVIRONMENT, CreateProcessW, GetCurrentProcessId, + PROCESS_INFORMATION, STARTUPINFOW, +}; +use windows::Win32::UI::Shell::{ + BHID_DataObject, DragQueryFileW, ECF_DEFAULT, ECF_HASSUBCOMMANDS, ECF_ISSEPARATOR, ECS_ENABLED, + ECS_HIDDEN, HDROP, IEnumExplorerCommand, IEnumExplorerCommand_Impl, IExplorerCommand, + IExplorerCommand_Impl, IFolderView, IShellItem, IShellItemArray, SHStrDupW, SIGDN_FILESYSPATH, +}; +use windows::Win32::UI::WindowsAndMessaging::{MB_ICONWARNING, MB_OK, MessageBoxW}; +use windows::core::{ + BOOL, GUID, HRESULT, HSTRING, IUnknown, Interface, PCWSTR, PWSTR, Ref, Result, implement, w, +}; + +const CLASS_ID: GUID = GUID::from_u128(menu::CLASS_ID_VALUE); +/// `SID_SFolderView`, the service that yields the folder a view is showing. +const FOLDER_VIEW_SERVICE: GUID = GUID::from_u128(0xcde725b0_ccc9_4519_917e_325d72fab4ce); +/// A stat call per item is fine for a handful of unknown names and far too +/// slow for a selection of thousands, so only the first few are probed. +const FOLDER_PROBES: usize = 16; + +static MODULE: AtomicUsize = AtomicUsize::new(0); +static OBJECTS: AtomicUsize = AtomicUsize::new(0); +static LAUNCHES: AtomicUsize = AtomicUsize::new(0); + +fn wide(text: &str) -> Vec<u16> { + text.encode_utf16().chain([0]).collect() +} + +fn module_path() -> Option<PathBuf> { + let module = HMODULE(MODULE.load(Ordering::Relaxed) as *mut c_void); + let mut buffer = vec![0_u16; MAX_PATH as usize]; + loop { + let length = unsafe { GetModuleFileNameW(Some(module), &mut buffer) } as usize; + if length == 0 { + return None; + } + if length < buffer.len() { + return Some(PathBuf::from(String::from_utf16_lossy( + buffer.get(..length)?, + ))); + } + if buffer.len() > 1 << 16 { + return None; + } + buffer.resize(buffer.len() * 2, 0); + } +} + +fn is_directory(path: &str) -> bool { + let attributes = unsafe { GetFileAttributesW(&HSTRING::from(path)) }; + attributes != INVALID_FILE_ATTRIBUTES && attributes & FILE_ATTRIBUTE_DIRECTORY.0 != 0 +} + +fn exists(path: &str) -> bool { + unsafe { GetFileAttributesW(&HSTRING::from(path)) != INVALID_FILE_ATTRIBUTES } +} + +/// Reduce the selected paths to what the menu depends on. +fn summarize(paths: &[String]) -> Selection { + let mut selection = Selection::default(); + let mut probes = 0; + for path in paths { + let recognized = menu::extension(path) + .is_some_and(|extension| Tool::ALL.iter().any(|tool| tool.accepts(&extension))); + if !recognized && probes < FOLDER_PROBES { + probes += 1; + if is_directory(path) { + selection.add_folder(exists(&format!("{path}\\.git"))); + continue; + } + } + selection.add_file(path); + } + selection +} + +/// Every selected path in one cross-process call, when Explorer can provide a +/// drop list for the selection. +unsafe fn drop_list_paths(items: &IShellItemArray) -> Vec<String> { + let mut paths = Vec::new(); + let Ok(data) = (unsafe { items.BindToHandler::<_, IDataObject>(None, &BHID_DataObject) }) + else { + return paths; + }; + let format = FORMATETC { + cfFormat: CF_HDROP.0, + ptd: std::ptr::null_mut(), + dwAspect: DVASPECT_CONTENT.0, + lindex: -1, + tymed: TYMED_HGLOBAL.0 as u32, + }; + let Ok(mut medium) = (unsafe { data.GetData(&format) }) else { + return paths; + }; + let drop = HDROP(unsafe { medium.u.hGlobal }.0); + let count = unsafe { DragQueryFileW(drop, u32::MAX, None) }; + for index in 0..count { + let length = unsafe { DragQueryFileW(drop, index, None) } as usize; + let mut buffer = vec![0_u16; length + 1]; + let written = unsafe { DragQueryFileW(drop, index, Some(&mut buffer)) } as usize; + if let Some(text) = buffer.get(..written) + && written > 0 + { + paths.push(String::from_utf16_lossy(text)); + } + } + unsafe { ReleaseStgMedium(&mut medium) }; + paths +} + +unsafe fn item_path(item: &IShellItem) -> Option<String> { + let name = unsafe { item.GetDisplayName(SIGDN_FILESYSPATH) }.ok()?; + let path = unsafe { name.to_string() }.ok(); + unsafe { CoTaskMemFree(Some(name.0 as *const c_void)) }; + path.filter(|path| !path.is_empty()) +} + +unsafe fn selected_paths(items: &IShellItemArray) -> Vec<String> { + let paths = unsafe { drop_list_paths(items) }; + if !paths.is_empty() { + return paths; + } + let count = unsafe { items.GetCount() }.unwrap_or(0); + (0..count) + .filter_map(|index| unsafe { items.GetItemAt(index) }.ok()) + .filter_map(|item| unsafe { item_path(&item) }) + .collect() +} + +/// The folder whose empty background was right-clicked. +unsafe fn site_folder(site: &IUnknown) -> Option<String> { + let provider: IServiceProvider = site.cast().ok()?; + let view: IFolderView = unsafe { provider.QueryService(&FOLDER_VIEW_SERVICE) }.ok()?; + let folder: IShellItem = unsafe { view.GetFolder() }.ok()?; + unsafe { item_path(&folder) } +} + +/// State shared by the root command and the entries it hands out. +#[derive(Default)] +struct Shared { + /// The menu resolved for the current selection. + items: RefCell<Vec<Item>>, + paths: RefCell<Vec<String>>, + site: RefCell<Option<IUnknown>>, +} + +impl Shared { + fn selection(&self, items: Option<&IShellItemArray>) -> Vec<String> { + let mut paths = items + .map(|items| unsafe { selected_paths(items) }) + .unwrap_or_default(); + if paths.is_empty() { + let site = self.site.try_borrow().ok().and_then(|site| site.clone()); + if let Some(folder) = site.and_then(|site| unsafe { site_folder(&site) }) { + paths.push(folder); + } + } + paths + } + + /// Resolve and remember the menu for a fresh selection. + fn refresh(&self, items: Option<&IShellItemArray>) -> bool { + let paths = self.selection(items); + let resolved = menu::menu(&summarize(&paths)); + let visible = !resolved.is_empty(); + if let Ok(mut slot) = self.items.try_borrow_mut() { + *slot = resolved; + } + if let Ok(mut slot) = self.paths.try_borrow_mut() { + *slot = paths; + } + visible + } +} + +/// One menu entry. `item` is `None` for the top-level JustTools entry. +#[implement(IExplorerCommand, IObjectWithSite)] +struct Command { + item: Option<Item>, + shared: Rc<Shared>, +} + +impl Command { + fn create(item: Option<Item>, shared: Rc<Shared>) -> IExplorerCommand { + OBJECTS.fetch_add(1, Ordering::Relaxed); + Command { item, shared }.into() + } + + /// Only the top-level entry has a flyout; its rows are the resolved menu. + fn children(&self) -> Vec<Item> { + if self.item.is_some() { + return Vec::new(); + } + self.shared + .items + .try_borrow() + .map(|items| items.clone()) + .unwrap_or_default() + } +} + +impl Drop for Command { + fn drop(&mut self) { + OBJECTS.fetch_sub(1, Ordering::Relaxed); + } +} + +impl IExplorerCommand_Impl for Command_Impl { + fn GetTitle(&self, _items: Ref<'_, IShellItemArray>) -> Result<PWSTR> { + let title = self + .item + .as_ref() + .map_or(menu::ROOT_TITLE, |item| item.title.as_str()); + unsafe { SHStrDupW(&HSTRING::from(title)) } + } + + fn GetIcon(&self, _items: Ref<'_, IShellItemArray>) -> Result<PWSTR> { + let icon = self.item.as_ref().map_or(Icon::Brand, |item| item.icon); + if icon == Icon::None { + return Err(E_NOTIMPL.into()); + } + let library = module_path().ok_or(E_FAIL)?; + let location = format!("{},-{}", library.display(), icon.resource_id()); + unsafe { SHStrDupW(&HSTRING::from(location)) } + } + + fn GetToolTip(&self, _items: Ref<'_, IShellItemArray>) -> Result<PWSTR> { + Err(E_NOTIMPL.into()) + } + + fn GetCanonicalName(&self) -> Result<GUID> { + Ok(if self.item.is_none() { + CLASS_ID + } else { + GUID::zeroed() + }) + } + + fn GetState(&self, items: Ref<'_, IShellItemArray>, _slow: BOOL) -> Result<u32> { + if self.item.is_some() || self.shared.refresh(items.as_ref()) { + Ok(ECS_ENABLED.0 as u32) + } else { + Ok(ECS_HIDDEN.0 as u32) + } + } + + fn Invoke(&self, items: Ref<'_, IShellItemArray>, _context: Ref<'_, IBindCtx>) -> Result<()> { + let Some(Item { + kind: Kind::Action(id), + .. + }) = &self.item + else { + return Ok(()); + }; + let mut paths = self.shared.selection(items.as_ref()); + if paths.is_empty() { + paths = self + .shared + .paths + .try_borrow() + .map(|paths| paths.clone()) + .unwrap_or_default(); + } + if let Err(message) = launch(id, &paths) { + unsafe { + MessageBoxW( + None, + &HSTRING::from(message), + w!("JustTools"), + MB_OK | MB_ICONWARNING, + ); + } + } + Ok(()) + } + + fn GetFlags(&self) -> Result<u32> { + let flags = match self.item.as_ref().map(|item| &item.kind) { + None => ECF_HASSUBCOMMANDS, + Some(Kind::Separator) => ECF_ISSEPARATOR, + Some(Kind::Action(_)) => ECF_DEFAULT, + }; + Ok(flags.0 as u32) + } + + fn EnumSubCommands(&self) -> Result<IEnumExplorerCommand> { + Ok(Commands::create(self.children(), 0, self.shared.clone())) + } +} + +impl IObjectWithSite_Impl for Command_Impl { + fn SetSite(&self, site: Ref<'_, IUnknown>) -> Result<()> { + if let Ok(mut slot) = self.shared.site.try_borrow_mut() { + *slot = site.as_ref().cloned(); + } + Ok(()) + } + + fn GetSite(&self, interface: *const GUID, result: *mut *mut c_void) -> Result<()> { + if result.is_null() { + return Err(E_POINTER.into()); + } + unsafe { *result = std::ptr::null_mut() }; + let site = self + .shared + .site + .try_borrow() + .ok() + .and_then(|site| site.clone()) + .ok_or(E_FAIL)?; + unsafe { site.query(interface, result).ok() } + } +} + +#[implement(IEnumExplorerCommand)] +struct Commands { + items: Vec<Item>, + next: Cell<usize>, + shared: Rc<Shared>, +} + +impl Commands { + fn create(items: Vec<Item>, next: usize, shared: Rc<Shared>) -> IEnumExplorerCommand { + OBJECTS.fetch_add(1, Ordering::Relaxed); + Commands { + items, + next: Cell::new(next), + shared, + } + .into() + } +} + +impl Drop for Commands { + fn drop(&mut self) { + OBJECTS.fetch_sub(1, Ordering::Relaxed); + } +} + +impl IEnumExplorerCommand_Impl for Commands_Impl { + fn Next( + &self, + count: u32, + commands: *mut Option<IExplorerCommand>, + fetched: *mut u32, + ) -> HRESULT { + if commands.is_null() { + return E_POINTER; + } + let mut written = 0_u32; + while written < count { + let Some(item) = self.items.get(self.next.get()) else { + break; + }; + let command = Command::create(Some(item.clone()), self.shared.clone()); + // The caller's buffer is uninitialized, so it must not be dropped. + unsafe { commands.add(written as usize).write(Some(command)) }; + self.next.set(self.next.get() + 1); + written += 1; + } + if !fetched.is_null() { + unsafe { *fetched = written }; + } + if written == count { S_OK } else { S_FALSE } + } + + fn Skip(&self, count: u32) -> Result<()> { + let next = self.next.get().saturating_add(count as usize); + self.next.set(next.min(self.items.len())); + Ok(()) + } + + fn Reset(&self) -> Result<()> { + self.next.set(0); + Ok(()) + } + + fn Clone(&self) -> Result<IEnumExplorerCommand> { + Ok(Commands::create( + self.items.clone(), + self.next.get(), + self.shared.clone(), + )) + } +} + +/// Start `just.exe context run <id>` in its own console window. +/// +/// The selection travels in a temporary list file: a command line holds +/// about 32,000 characters and a selection can hold far more than that. +fn launch(id: &str, paths: &[String]) -> std::result::Result<(), String> { + let library = module_path().ok_or("JustTools could not locate its own files.")?; + let executable = library.parent().unwrap_or(Path::new(".")).join("just.exe"); + if !executable.is_file() { + return Err(format!( + "JustTools is not installed beside its context menu.\n\nMissing: {}\n\nRun “just install” and then “just context install”.", + executable.display() + )); + } + let list = std::env::temp_dir().join(format!( + "justtools-menu-{}-{}.txt", + unsafe { GetCurrentProcessId() }, + LAUNCHES.fetch_add(1, Ordering::Relaxed) + )); + std::fs::write(&list, paths.join("\n")) + .map_err(|error| format!("JustTools could not pass on the selection: {error}"))?; + let mut command = wide(&format!( + "\"{}\" context run {id} --list \"{}\"", + executable.display(), + list.display() + )); + let startup = STARTUPINFOW { + cb: size_of::<STARTUPINFOW>() as u32, + ..Default::default() + }; + let mut process = PROCESS_INFORMATION::default(); + let started = unsafe { + CreateProcessW( + &HSTRING::from(executable.as_os_str()), + Some(PWSTR(command.as_mut_ptr())), + None, + None, + false, + CREATE_NEW_CONSOLE | CREATE_UNICODE_ENVIRONMENT, + None, + PCWSTR::null(), + &startup, + &mut process, + ) + }; + match started { + Ok(()) => { + unsafe { + CloseHandle(process.hThread).ok(); + CloseHandle(process.hProcess).ok(); + } + Ok(()) + } + Err(error) => { + std::fs::remove_file(&list).ok(); + Err(format!("JustTools could not start: {}", error.message())) + } + } +} + +#[implement(IClassFactory)] +struct Factory; + +impl IClassFactory_Impl for Factory_Impl { + fn CreateInstance( + &self, + outer: Ref<'_, IUnknown>, + interface: *const GUID, + result: *mut *mut c_void, + ) -> Result<()> { + if result.is_null() { + return Err(E_POINTER.into()); + } + unsafe { *result = std::ptr::null_mut() }; + if outer.is_some() { + return Err(CLASS_E_NOAGGREGATION.into()); + } + let command = Command::create(None, Rc::new(Shared::default())); + unsafe { command.query(interface, result).ok() } + } + + fn LockServer(&self, lock: BOOL) -> Result<()> { + if lock.as_bool() { + OBJECTS.fetch_add(1, Ordering::Relaxed); + } else { + OBJECTS.fetch_sub(1, Ordering::Relaxed); + } + Ok(()) + } +} + +#[unsafe(no_mangle)] +extern "system" fn DllMain(instance: HINSTANCE, reason: u32, _reserved: *mut c_void) -> BOOL { + if reason == DLL_PROCESS_ATTACH { + MODULE.store(instance.0 as usize, Ordering::Relaxed); + } + true.into() +} + +/// # Safety +/// Called by COM with valid pointers for the class, interface, and result. +#[unsafe(no_mangle)] +pub unsafe extern "system" fn DllGetClassObject( + class: *const GUID, + interface: *const GUID, + result: *mut *mut c_void, +) -> HRESULT { + if result.is_null() { + return E_POINTER; + } + unsafe { *result = std::ptr::null_mut() }; + if class.is_null() || interface.is_null() || unsafe { *class } != CLASS_ID { + return CLASS_E_CLASSNOTAVAILABLE; + } + let factory: IClassFactory = Factory.into(); + unsafe { factory.query(interface, result) } +} + +#[unsafe(no_mangle)] +pub extern "system" fn DllCanUnloadNow() -> HRESULT { + if OBJECTS.load(Ordering::Relaxed) == 0 { + S_OK + } else { + S_FALSE + } +} diff --git a/ports/core-tools/src/links.rs b/ports/core-tools/src/links.rs index ca54d98..a1a05d2 100644 --- a/ports/core-tools/src/links.rs +++ b/ports/core-tools/src/links.rs @@ -568,8 +568,10 @@ fn text_links(text: &str) -> Vec<Found> { fn decode_text(bytes: &[u8]) -> Option<String> { let utf16 = |bytes: &[u8], from: fn([u8; 2]) -> u16| { let units: Vec<u16> = bytes - .chunks_exact(2) - .map(|pair| from([pair[0], pair[1]])) + .as_chunks::<2>() + .0 + .iter() + .map(|pair| from(*pair)) .collect(); String::from_utf16_lossy(&units) }; diff --git a/ports/core-tools/src/pdf/images.rs b/ports/core-tools/src/pdf/images.rs index 881ac33..edc800a 100644 --- a/ports/core-tools/src/pdf/images.rs +++ b/ports/core-tools/src/pdf/images.rs @@ -509,9 +509,11 @@ fn unpack(data: &[u8], row: usize, per_row: usize, height: usize, bits: usize) - match bits { 8 => samples.extend(row[..per_row].iter().map(|byte| u16::from(*byte))), 16 => samples.extend( - row.chunks_exact(2) + row.as_chunks::<2>() + .0 + .iter() .take(per_row) - .map(|pair| u16::from_be_bytes([pair[0], pair[1]])), + .map(|pair| u16::from_be_bytes(*pair)), ), _ => { let per_byte = 8 / bits; @@ -667,7 +669,15 @@ fn decode_raw( fn to_gray_or_rgb(color: &Color, values: Vec<u8>) -> (usize, Vec<u8>) { match color { Color::Gray => (1, values), - Color::Cmyk => (3, values.chunks_exact(4).flat_map(cmyk_to_rgb).collect()), + Color::Cmyk => ( + 3, + values + .as_chunks::<4>() + .0 + .iter() + .flat_map(|cmyk| cmyk_to_rgb(cmyk)) + .collect(), + ), _ => (3, values), } } @@ -704,7 +714,7 @@ fn color_key( .map(|pixel| { let keyed = pixel .iter() - .zip(ranges.chunks_exact(2)) + .zip(ranges.as_chunks::<2>().0) .all(|(sample, range)| (range[0]..=range[1]).contains(&i64::from(*sample))); if keyed { 0 } else { 255 } }) @@ -738,7 +748,9 @@ fn mask_alpha( } else { picture .pixels - .chunks_exact(3) + .as_chunks::<3>() + .0 + .iter() .map(|pixel| pixel[0]) .collect() }; diff --git a/skills/justtools/SKILL.md b/skills/justtools/SKILL.md index f16a077..5af67d5 100644 --- a/skills/justtools/SKILL.md +++ b/skills/justtools/SKILL.md @@ -1,7 +1,7 @@ --- name: justtools description: Use JustTools whenever a request names `just`, a `just*` command, JustReady, JustCommit, or bunt, or needs local software setup, AI-assisted Git commits, process cleanup, safe image or media conversion, JSON/PDF/SVG/QR work, link extraction from documents, port inspection, background removal, or Git-aware ZIP archives. Prefer the matching installed JustTools command over an ad-hoc script. -compatibility: Requires a local shell and JustTools on PATH. Individual commands may also require Git, OpenRouter, FFmpeg, image codecs, ONNX Runtime, or an RMBG model. +compatibility: Requires a local shell and JustTools on PATH. Individual commands may also require Git, OpenRouter, FFmpeg, yt-dlp, image codecs, ONNX Runtime, or an RMBG model. --- # JustTools @@ -47,9 +47,21 @@ Claude-only or Codex-only tool name when a normal shell operation is sufficient. ## Dependency consent -Some media commands need FFmpeg, pngquant, cwebp, or Git. RMBG may need ONNX -Runtime and its model. JustTools discovers these only when needed and asks for -interactive confirmation before installing or downloading them. JustRMBG's +Some media commands need FFmpeg, pngquant, cwebp, or Git, and `justpaste` and +an `http(s)` input to `justvideo`, `justaudio`, `justmp3`, or `justwav` also +need yt-dlp. RMBG may need ONNX Runtime and its model. JustTools discovers +these only when needed. + +**yt-dlp and FFmpeg download themselves.** When either is missing from `PATH`, +the first tool that needs it fetches the vendor's build into a per-user +JustTools folder, verifies its published SHA-256, and uses it from there. This +happens without a prompt, in interactive and non-interactive runs alike; it +changes nothing system-wide and never edits `PATH`. Set +`JUSTTOOLS_NO_DOWNLOAD=1` when the user does not want that, and tell the user +when a first run will download (FFmpeg is roughly 35 to 115 MB). + +Every other dependency still asks for interactive confirmation before it is +installed or downloaded. JustRMBG's launcher is explicit about the exception: its one-run **Install dependencies** row emits `--download`, so choosing Run grants that verified managed download without a second prompt. A headless run must pass `--download` explicitly. @@ -65,9 +77,9 @@ installer and may surface UAC or `sudo` prompts. `--download` only when the user has authorized its pinned runtime/model. - `--yes` approves the requested file operation; it does not approve third-party dependency installation. -- An agent's non-interactive process will intentionally refuse acquisition - unless the user explicitly authorized JustRMBG's `--download`. If another - dependency is missing, show the exact command/source JustTools reports and +- Apart from yt-dlp and FFmpeg, an agent's non-interactive process will + intentionally refuse acquisition unless the user explicitly authorized + JustRMBG's `--download`. If another dependency is missing, show the exact command/source JustTools reports and ask the user to run or approve it in an interactive terminal. - Explicit `*_BIN`, `RMBG_MODEL`, and `ORT_DYLIB_PATH` overrides are resolve-only; do not replace them without checking the user's environment. `ORT_DYLIB_PATH` @@ -88,6 +100,12 @@ installer and may surface UAC or `sudo` prompts. only when the user requested them; never substitute them for ordinary CLI invocations just to avoid an approval prompt. All additional arguments pass through, and neither shortcut stores a permission bypass in JustTools defaults. +- An `http(s)` input to `justvideo`, `justaudio`, `justmp3`, or `justwav` + reaches the network: it downloads the media with yt-dlp. Every run of those + tools and of `justpaste` also runs `yt-dlp --update` once twelve hours have + passed since the last update. Pass only a URL the user supplied, + and use `--dry-run` to show the plan without downloading. `--playlist` + downloads every entry of a playlist link, so add it only when asked. - Prefer `--output DIR` when the user wants copies. - Use `--replace` only with clear authorization; outputs are installed atomically. - Keep `justresize`'s no-upscale default unless enlargement is explicitly wanted. @@ -100,6 +118,11 @@ installer and may surface UAC or `sudo` prompts. - Do not kill a port owner without identifying it first. `justport --kill` intentionally limits termination to same-user processes and revalidates the process identity. +- `justip` contacts a third-party echo service and therefore discloses the + machine's public address to it. It is read-only and writes nothing, but do + not run it on a user's behalf when they have not asked where they appear + from. Both families are reported without a switch, so add `-4` or `-6` only + to narrow the result. - `justports` discovery is read-only until the user explicitly opens, starts, or stops a selection. Review remembered start recipes before launching them. `K` must remain confirmation-gated and revalidate PID, start time, same-user @@ -145,7 +168,8 @@ the shared file. tables, keep the source, and write `image-optimized.jpg`. - `justjpg assets --recursive --output assets-jpg --dry-run`: preview recursive JPEG copies in a separate directory while keeping every source file. -- `just video clip.mov`: streaming-ready 720p H.264 MP4. +- `just video clip.mov`: streaming-ready 720p H.264 MP4; `--resolution` takes + `480p`, `720p`, `1080p`, `1440p`, `4k`, or `source` and never upscales. - `just audio clip.mov`: AAC-LC M4A at 160 kb/s. - `just qr TEXT`: 1024 px error-Q PNG. - `bunt`: interactively inspect, protect, filter, and stop Node/Bun/Python @@ -166,6 +190,11 @@ the shared file. pinned managed runtime/model to install without a second prompt. - `justrmbg --check`: test runtime/provider session creation and tiny inference without resolving or downloading the BRIA model. +- `justpaste <url>` / `justpaste --clipboard`: download whatever a link points + at, unconverted; pages go through yt-dlp. Never overwrites. +- `justip`: report the public IPv4 and IPv6 address together; `--plain` and + `--json` keep stdout free of anything but the answer, and a family with no + public route is reported as unavailable rather than failing the run. - `just zip`: archive Git's exact tracked and unignored working-tree files. Read [references/commands.md](references/commands.md) for the full command map diff --git a/skills/justtools/references/commands.md b/skills/justtools/references/commands.md index 053597f..236d95c 100644 --- a/skills/justtools/references/commands.md +++ b/skills/justtools/references/commands.md @@ -14,23 +14,26 @@ | Optimize PNG | `justpng` | pngquant quality 65-90; same path only when smaller | | Convert to WebP | `justwebp` | Blank output removes source only after a smaller WebP is safe | | Convert to AVIF | `justavif` | Blank output removes source only after a smaller AVIF is safe | -| Optimize video | `justvideo` | 720p H.264 MP4, CRF 28, AAC 128 kb/s | -| Extract/convert audio | `justaudio` | AAC-LC M4A, 160 kb/s, 48 kHz | -| Create MP3 | `justmp3` | LAME VBR quality 2, 48 kHz | +| Optimize video | `justvideo` | 720p H.264 MP4, CRF 28, AAC 128 kb/s; `--resolution` 480p to 4k or source; `http(s)` URLs download with yt-dlp | +| Extract/convert audio | `justaudio` | AAC-LC M4A, 160 kb/s, 48 kHz; `http(s)` URLs download with yt-dlp | +| Create MP3 | `justmp3` | LAME VBR quality 2, 48 kHz; `http(s)` URLs download with yt-dlp | +| Download any link | `justpaste` | A file link is saved as-is; a page goes through yt-dlp at the highest resolution as MP4; never overwrites | | Choose the best web image | `justoptimize` | Measure PNG/WebP/JPEG; preserve alpha; keep source | -| Create WAV | `justwav` | Stereo 16-bit PCM, 48 kHz | +| Create WAV | `justwav` | Stereo 16-bit PCM, 48 kHz; `http(s)` URLs download with yt-dlp | | Work with JSON | `justjson` | Format, validate, query, or minify | | Work with PDF | `justpdf` | Inspect, merge, split, extract, rotate, save images, or list unique links | | Extract links | `justlinks` | Unique links from PDF, Office, and text files to `links.txt`; `--csv` adds title, file, location, and count | | Optimize SVG | `justsvg` | Conservative SVGOMG-style OXVG optimization | | Generate QR | `justqr` | 1024 px PNG, error correction Q, four-module margin | | Inspect ports | `justport` | Show listener/process ownership; guarded kill is optional | +| Show the public IP address | `justip` | Report the public IPv4 and IPv6 address together; `-4`/`-6` narrow it | | Browse development servers | `justports` | Live smart TUI, automatic saving, and one-key Launch Again recipes | | Remove backgrounds | `justrmbg` / `rmbg` | Local BRIA RMBG-2.0 inference to `<name>-nobg.png`; Auto visibly falls back to CPU | | Archive a repository | `justzip` | ZIP Git's tracked and unignored file set | | Create and enter a directory | `justmkcd` / `mkcd` | Create one directory; enter it through `just init` shell integration | | Claude permission-bypass shortcut | `claude_` | Shell function for `claude --dangerously-skip-permissions`, forwarding arguments | | Codex permission-bypass shortcut | `codex_` | Shell function for `codex --yolo`, forwarding arguments | +| Windows right-click menu | `just context install` | Per-user File Explorer menu; `status` and `uninstall` manage it | Every direct alias also works through short dispatch: `just resize`, `just pdf`, `just rmbg`, and so on. @@ -128,6 +131,28 @@ opaque tokens are not cached. Use `justport` instead when exact-port ownership from scripts is the goal; `K` provides confirmation-gated termination for the selected Running Now service. +## Public IP examples + +```sh +justip +justip -4 +justip -6 +justip --plain +justip --json +justip --timeout 15 +just ip +``` + +Both families are reported without a switch, so `-4` and `-6` are only needed +to narrow the result. The address is echoed back by a public lookup service, so +it is what the internet sees rather than a local interface address; each family +is pinned by hostname and an answer in the wrong family is discarded. A family +with no globally routable source address is reported as unavailable immediately +instead of waiting out the timeout, and one family answering still succeeds. +The run fails, with exit status 1 and nothing on stdout, only when no requested +family answers. `--plain` and `--json` keep stdout free of anything but the +answer. + ## Commit examples ```sh @@ -240,6 +265,76 @@ Use the command-specific help because not every option applies to every tool. The launchers keep the resolved output pattern and overwrite/source policy visible above the Headless command. +## Video resolution + +```sh +justvideo clip.mov # 720p +justvideo clip.mov --resolution 1080p +justvideo clip.mov --resolution 4k # 2160p +justvideo clip.mov --resolution source # keep the source size +``` + +`--resolution` accepts `480p`, `720p`, `1080p`, `1440p`, `4k`, or `source` and +bounds the frame without ever upscaling. The output name stays `<name>-web.mp4`. + +## Windows context menu + +```powershell +just context install +just context status +just context uninstall +``` + +Adds a per-user **JustTools** entry to File Explorer for supported files and +folders. Entries run built-in defaults in a console window that prints the +Headless command; `…` entries open the launcher with the selection filled in. +The top-level Windows 11 menu needs a signed `justtools-shell.msix` or Developer +Mode; `--classic` registers under "Show more options". `just context run` is +the entry point Explorer uses and is not meant to be typed. See +`docs/context-menu.md`. + +## Media download examples + +```sh +just video https://example.com/watch?v=ID +just audio https://example.com/watch?v=ID --output podcasts +just mp3 --playlist https://example.com/list=ID +just video --dry-run https://example.com/watch?v=ID +``` + +`justvideo`, `justaudio`, `justmp3`, and `justwav` accept an `http(s)` URL +wherever they accept a file. yt-dlp downloads it to a temporary folder (best +video plus audio at 1080p or lower, or at a larger `--resolution`, for video; best audio-only stream otherwise) +and the tool then encodes it with its usual settings into the current folder, or +`--output`, as `<title> [<id>].<ext>`. The download is never kept, so `--replace` +has nothing to remove. A playlist link downloads one entry unless `--playlist` +is passed, and `--dry-run` reports the plan without any network access. yt-dlp +runs with `--ignore-config`. Every run of these tools and of `justpaste` runs +`yt-dlp --update` once twelve hours have passed; an update failure is a +warning, never a failed run. A missing yt-dlp or FFmpeg is downloaded +automatically into a per-user folder and verified against the vendor's SHA-256 +(`JUSTTOOLS_NO_DOWNLOAD=1` disables this; `just deps fetch yt-dlp ffmpeg` +does it up front). See `docs/downloads.md`. + +## Paste a link + +```sh +justpaste https://example.com/photo.jpg +justpaste https://www.youtube.com/watch?v=ID +justpaste --clipboard +justpaste -o downloads --dry-run https://example.com/a.pdf +``` + +`justpaste` saves what a link points at into the current folder or `--output`. +A file answer is saved unconverted under the server's name; a page goes to +yt-dlp (YouTube, TikTok, and most other sites), and if that finds nothing the +page's declared `og:video`/`og:image` is saved. A taken name becomes +`name (2).ext`; nothing is overwritten and partial downloads leave nothing +behind. `--clipboard` takes every link in the copied text, `--playlist` opts +into multi-entry links, and `--dry-run` makes no request. Use `justvideo` or +`justmp3` with a URL instead when the result should be converted. See +`docs/paste.md`. + ## Structured and document examples ```sh diff --git a/src/commands/download.rs b/src/commands/download.rs new file mode 100644 index 0000000..a917673 --- /dev/null +++ b/src/commands/download.rs @@ -0,0 +1,352 @@ +//! Fetching media URLs with yt-dlp before the normal encode pipeline runs. +//! +//! A downloaded file is only ever an intermediate: it lands in a temporary +//! workspace, is converted with the calling tool's own settings, and is removed +//! with that workspace. yt-dlp refreshes itself at most once every twelve hours. + +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use atomic_write_file::AtomicWriteFile; + +use crate::deps; +use crate::error::{ToolError, ToolResult}; + +const UPDATE_INTERVAL: Duration = Duration::from_secs(12 * 60 * 60); +const STAMP_NAME: &str = "yt-dlp-update"; +const OUTPUT_TEMPLATE: &str = "%(title)s [%(id)s].%(ext)s"; + +/// What the calling tool needs out of a URL. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Kind { + /// `max_height` is the tallest stream worth fetching; `None` takes the best. + Video { + max_height: Option<u32>, + }, + Audio, + /// The best version of whatever the page offers, kept as downloaded. + Original, +} + +impl Kind { + fn format(self) -> String { + match self { + // The encode scales down to its own target, so a taller source + // than that only costs time. + Self::Video { + max_height: Some(height), + } => format!("bv*[height<={height}]+ba/b[height<={height}]/bv*+ba/b"), + Self::Video { max_height: None } => "bv*+ba/b".into(), + Self::Audio => "ba/b".into(), + Self::Original => "bv*+ba/b".into(), + } + } +} + +/// Whether an input names a downloadable address rather than a path. +pub fn is_url(value: &OsStr) -> bool { + value.to_str().is_some_and(|text| { + let lower = text.trim().to_ascii_lowercase(); + lower.starts_with("http://") || lower.starts_with("https://") + }) +} + +/// Resolve yt-dlp, honoring `YTDLP_BIN`. +pub fn executable(tool: &str) -> ToolResult<PathBuf> { + deps::require_override(tool, "YTDLP_BIN", "yt-dlp") +} + +fn stamp_path() -> Option<PathBuf> { + let defaults = crate::preferences::path().ok()?; + Some(defaults.parent()?.join(STAMP_NAME)) +} + +fn due(now: SystemTime, stamp: &Path) -> bool { + let Ok(text) = fs::read_to_string(stamp) else { + return true; + }; + let Ok(seconds) = text.trim().parse::<u64>() else { + return true; + }; + match now.duration_since(UNIX_EPOCH + Duration::from_secs(seconds)) { + Ok(elapsed) => elapsed >= UPDATE_INTERVAL, + // A stamp from the future only costs one extra check; recording the + // current time immediately afterwards repairs the clock skew. + Err(_) => true, + } +} + +fn record(stamp: &Path, now: SystemTime) { + let Ok(since) = now.duration_since(UNIX_EPOCH) else { + return; + }; + if let Some(parent) = stamp.parent() { + fs::create_dir_all(parent).ok(); + } + if let Ok(mut file) = AtomicWriteFile::open(stamp) + && file + .write_all(since.as_secs().to_string().as_bytes()) + .is_ok() + { + file.commit().ok(); + } +} + +fn last_line(text: &str) -> Option<&str> { + text.lines().map(str::trim).rfind(|line| !line.is_empty()) +} + +/// Run `yt-dlp --update` unless it already ran within the last twelve hours. +/// +/// Updating is best-effort: a package-managed yt-dlp refuses to replace itself, +/// and an offline machine cannot reach the release feed. Either way the +/// download still proceeds with the installed version. +pub fn update(tool: &str, executable: &Path) { + let now = SystemTime::now(); + let stamp = stamp_path(); + if let Some(path) = &stamp + && !due(now, path) + { + return; + } + println!("{tool}: checking for a newer yt-dlp"); + match Command::new(executable).arg("--update").output() { + Ok(output) => { + let text = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let summary = last_line(&text) + .unwrap_or("yt-dlp reported nothing") + .to_owned(); + if output.status.success() { + println!("{tool}: {summary}"); + } else { + eprintln!("{tool}: yt-dlp could not update itself: {summary}"); + } + } + Err(error) => eprintln!("{tool}: cannot run yt-dlp --update: {error}"), + } + if let Some(path) = &stamp { + record(path, now); + } +} + +/// Keep an installed yt-dlp current even when this run downloads nothing. +/// +/// Sites change faster than yt-dlp releases reach package managers, so every +/// run of a tool that can download checks whether twelve hours have passed. +/// A machine without yt-dlp is left alone; it is fetched when first needed. +pub fn refresh(tool: &str) { + if let Some(executable) = deps::installed_yt_dlp() { + update(tool, &executable); + } +} + +fn arguments( + kind: Kind, + ffmpeg: &Path, + directory: &Path, + url: &str, + playlist: bool, +) -> Vec<OsString> { + let mut args: Vec<OsString> = Vec::new(); + // A personal yt-dlp configuration could redirect the output or re-encode + // behind our back, which headless runs must stay free of. + args.push(OsString::from("--ignore-config")); + args.push(OsString::from(if playlist { + "--yes-playlist" + } else { + "--no-playlist" + })); + args.extend([OsString::from("-f"), OsString::from(kind.format())]); + if matches!(kind, Kind::Video { .. }) { + args.extend([ + OsString::from("--merge-output-format"), + OsString::from("mkv"), + ]); + } + if kind == Kind::Original { + // Nothing re-encodes this file afterwards: take the highest + // resolution, and at that resolution the streams that play + // everywhere, in an MP4. + args.extend( + [ + "-S", + "res,vcodec:h264,ext:mp4:m4a", + "--merge-output-format", + "mp4", + ] + .map(OsString::from), + ); + } + args.extend([OsString::from("--trim-filenames"), OsString::from("120")]); + args.extend([ + OsString::from("--ffmpeg-location"), + ffmpeg.as_os_str().to_owned(), + ]); + args.extend([OsString::from("-P"), directory.as_os_str().to_owned()]); + args.extend([OsString::from("-o"), OsString::from(OUTPUT_TEMPLATE)]); + args.extend([OsString::from("--"), OsString::from(url)]); + args +} + +fn downloaded_files(directory: &Path) -> Vec<PathBuf> { + let mut files: Vec<_> = walkdir::WalkDir::new(directory) + .sort_by_file_name() + .into_iter() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_file()) + .map(|entry| entry.into_path()) + .collect(); + files.sort(); + files +} + +/// A downloaded file and the URL it came from. +#[derive(Clone, Debug)] +pub struct Fetched { + pub url: String, + pub file: PathBuf, +} + +/// Download every URL into its own folder below `workspace`. +/// +/// yt-dlp keeps the terminal so its progress stays visible; a failure is +/// reported after it has already explained itself. +pub fn fetch( + tool: &str, + kind: Kind, + executable: &Path, + ffmpeg: &Path, + urls: &[String], + playlist: bool, + workspace: &Path, +) -> ToolResult<Vec<Fetched>> { + let mut downloaded = Vec::new(); + for (index, url) in urls.iter().enumerate() { + let directory = workspace.join(index.to_string()); + fs::create_dir_all(&directory).map_err(|error| { + ToolError::new(tool, format!("cannot prepare a download folder: {error}")) + })?; + println!("{tool}: downloading {url}"); + let status = Command::new(executable) + .args(arguments(kind, ffmpeg, &directory, url, playlist)) + .status() + .map_err(|error| ToolError::new(tool, format!("cannot run yt-dlp: {error}")))?; + if !status.success() { + return Err(ToolError::new( + tool, + format!("yt-dlp could not download {url}"), + )); + } + let files = downloaded_files(&directory); + if files.is_empty() { + return Err(ToolError::new( + tool, + format!("yt-dlp reported success but saved nothing for {url}"), + )); + } + downloaded.extend(files.into_iter().map(|file| Fetched { + url: url.clone(), + file, + })); + } + Ok(downloaded) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_http_addresses_are_treated_as_urls() { + assert!(is_url(OsStr::new("https://example.test/clip"))); + assert!(is_url(OsStr::new("HTTP://EXAMPLE.TEST/clip"))); + assert!(is_url(OsStr::new(" https://example.test/clip "))); + assert!(!is_url(OsStr::new("clip.mp4"))); + assert!(!is_url(OsStr::new("ftp://example.test/clip"))); + assert!(!is_url(OsStr::new("C:/videos/https_clip.mp4"))); + } + + #[test] + fn updates_run_once_per_twelve_hours() { + let temp = tempfile::tempdir().unwrap(); + let stamp = temp.path().join(STAMP_NAME); + let now = UNIX_EPOCH + Duration::from_secs(1_800_000_000); + assert!(due(now, &stamp), "a missing stamp must update"); + record(&stamp, now); + assert!(!due(now + Duration::from_secs(11 * 60 * 60), &stamp)); + assert!(due(now + UPDATE_INTERVAL, &stamp)); + // A damaged or future stamp updates once and is then rewritten. + fs::write(&stamp, "not a timestamp").unwrap(); + assert!(due(now, &stamp)); + record(&stamp, now + Duration::from_secs(60 * 60 * 24)); + assert!(due(now, &stamp)); + } + + #[test] + fn video_downloads_are_capped_and_merged_while_audio_skips_video() { + let video = arguments( + Kind::Video { + max_height: Some(1080), + }, + Path::new("/opt/ffmpeg"), + Path::new("/work/0"), + "https://example.test/watch?v=1", + false, + ); + let rendered: Vec<_> = video + .iter() + .map(|value| value.to_string_lossy().into_owned()) + .collect(); + assert!(rendered.contains(&"--ignore-config".to_owned())); + assert!(rendered.contains(&"--no-playlist".to_owned())); + assert!(rendered.contains(&"bv*[height<=1080]+ba/b[height<=1080]/bv*+ba/b".to_owned())); + assert!(rendered.contains(&"mkv".to_owned())); + assert_eq!(rendered.last().unwrap(), "https://example.test/watch?v=1"); + assert_eq!(rendered[rendered.len() - 2], "--"); + + let audio = arguments( + Kind::Audio, + Path::new("/opt/ffmpeg"), + Path::new("/work/0"), + "https://example.test/watch?v=1", + true, + ); + let rendered: Vec<_> = audio + .iter() + .map(|value| value.to_string_lossy().into_owned()) + .collect(); + assert!(rendered.contains(&"ba/b".to_owned())); + assert_eq!( + Kind::Video { + max_height: Some(2160) + } + .format(), + "bv*[height<=2160]+ba/b[height<=2160]/bv*+ba/b" + ); + assert_eq!(Kind::Video { max_height: None }.format(), "bv*+ba/b"); + + let original: Vec<_> = arguments( + Kind::Original, + Path::new("/opt/ffmpeg"), + Path::new("/work/0"), + "https://example.test/watch?v=1", + false, + ) + .iter() + .map(|value| value.to_string_lossy().into_owned()) + .collect(); + assert!(original.contains(&"res,vcodec:h264,ext:mp4:m4a".to_owned())); + assert!(original.contains(&"mp4".to_owned())); + assert!(!original.contains(&"mkv".to_owned())); + assert!(rendered.contains(&"--yes-playlist".to_owned())); + assert!(!rendered.contains(&"--merge-output-format".to_owned())); + } +} diff --git a/src/commands/ip.rs b/src/commands/ip.rs new file mode 100644 index 0000000..b5f9aa6 --- /dev/null +++ b/src/commands/ip.rs @@ -0,0 +1,457 @@ +//! Reporting the public IP address this machine presents to the internet. +//! +//! Both families are looked up at once so a bare run answers the question +//! without a switch. Each family is pinned by hostname rather than by socket +//! option: a name that publishes records for one family can only be reached +//! over that family, so the address the service echoes back is the one it +//! actually saw. A machine with no public route for a family is reported +//! straight away instead of waiting out a connection that cannot complete. + +use std::ffi::OsString; +use std::net::{IpAddr, SocketAddr, UdpSocket}; +use std::time::Duration; + +use serde_json::{Map, Value}; + +use crate::common; +use crate::error::{ToolError, ToolResult}; + +const TOOL: &str = "justip"; + +const HELP: &str = r#"justip — Show the public IP address this machine presents. + +Usage: + justip [options] + +Both IPv4 and IPv6 are reported by default. The address is echoed back by a +public lookup service, so it is what the internet sees rather than a local +interface address. Only the request itself is sent. + +Options: + -4, --ipv4 Report only the public IPv4 address + -6, --ipv6 Report only the public IPv6 address + --plain Print bare addresses only, one per line + --json Print machine-readable JSON + -t, --timeout SECONDS Seconds to wait for each lookup (1-60, default: 5) + -h, --help Show this help + +Run bare to open the interactive launcher; explicit arguments bypass the UI. +A family this machine cannot reach is reported as unavailable, and the run +fails only when no requested family answers."#; + +/// Public echo services, tried in order until one answers usefully. +/// +/// Each hostname publishes records for a single family, and that is what pins +/// the request to that family. Later entries are independent operators so one +/// outage does not take the tool down with it. +const IPV4_SERVICES: &[&str] = &[ + "https://api.ipify.org", + "https://ipv4.icanhazip.com", + "https://v4.ident.me", +]; +const IPV6_SERVICES: &[&str] = &[ + "https://api6.ipify.org", + "https://ipv6.icanhazip.com", + "https://v6.ident.me", +]; + +/// An echoed address is one short line, so anything longer is not an answer. +const MAX_RESPONSE_BYTES: u64 = 128; +const DEFAULT_TIMEOUT: u32 = 5; +const MAX_REASON_CHARS: usize = 160; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Family { + V4, + V6, +} + +impl Family { + fn label(self) -> &'static str { + match self { + Self::V4 => "IPv4", + Self::V6 => "IPv6", + } + } + + fn key(self) -> &'static str { + match self { + Self::V4 => "ipv4", + Self::V6 => "ipv6", + } + } + + fn matches(self, address: IpAddr) -> bool { + matches!( + (self, address), + (Self::V4, IpAddr::V4(_)) | (Self::V6, IpAddr::V6(_)) + ) + } + + /// The services to ask, in the order they are tried. + /// + /// An environment override replaces the whole list, semicolons between + /// entries, so a test can point at local servers instead of reaching the + /// network and still exercise the fallback chain. + fn services(self) -> Services { + let variable = match self { + Self::V4 => "JUSTIP_IPV4_URL", + Self::V6 => "JUSTIP_IPV6_URL", + }; + if let Ok(overridden) = std::env::var(variable) { + let urls: Vec<String> = overridden + .split(';') + .map(str::trim) + .filter(|url| !url.is_empty()) + .map(str::to_owned) + .collect(); + if !urls.is_empty() { + return Services { + urls, + overridden: true, + }; + } + } + Services { + urls: match self { + Self::V4 => IPV4_SERVICES, + Self::V6 => IPV6_SERVICES, + } + .iter() + .map(|url| (*url).to_owned()) + .collect(), + overridden: false, + } + } + + /// Where to bind and which address to route towards when asking the + /// routing table for a source address. + /// + /// Both destinations are documentation prefixes. Connecting a UDP socket + /// only selects a route and a source address; no packet is ever sent, so + /// the address chosen here is never contacted. + fn probe(self) -> (&'static str, &'static str) { + match self { + Self::V4 => ("0.0.0.0:0", "192.0.2.1:53"), + Self::V6 => ("[::]:0", "[2001:db8::1]:53"), + } + } +} + +struct Services { + urls: Vec<String>, + /// An override points somewhere other than the public internet, so the + /// public-route check does not describe it. + overridden: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Format { + Labeled, + Plain, + Json, +} + +#[derive(Debug)] +struct Options { + ipv4: bool, + ipv6: bool, + format: Format, + timeout: u32, + help: bool, +} + +impl Options { + /// Neither flag and both flags mean the same thing: report everything. + fn families(&self) -> Vec<Family> { + match (self.ipv4, self.ipv6) { + (true, false) => vec![Family::V4], + (false, true) => vec![Family::V6], + _ => vec![Family::V4, Family::V6], + } + } +} + +fn parse(args: Vec<OsString>) -> ToolResult<Options> { + let mut options = Options { + ipv4: false, + ipv6: false, + format: Format::Labeled, + timeout: DEFAULT_TIMEOUT, + help: false, + }; + let mut index = 0; + while index < args.len() { + let original = common::os_to_string(TOOL, &args[index], "option")?; + let (option, inline) = original + .split_once('=') + .filter(|_| original.starts_with("--")) + .map_or((original.as_str(), None), |(key, value)| { + (key, Some(value.to_owned())) + }); + match option { + "-h" | "--help" => options.help = true, + "-4" | "--ipv4" => options.ipv4 = true, + "-6" | "--ipv6" => options.ipv6 = true, + "--plain" => options.format = Format::Plain, + "--json" => options.format = Format::Json, + "-t" | "--timeout" => { + let value = match inline { + Some(value) => value, + None => common::option_value(TOOL, &args, &mut index, option)?, + }; + options.timeout = common::integer(TOOL, &value, "timeout", 1, 60)?; + } + _ if option.starts_with('-') => { + return Err(ToolError::usage(TOOL, format!("unknown option: {option}"))); + } + _ => { + return Err(ToolError::usage( + TOOL, + format!("justip takes no arguments: {option}"), + )); + } + } + index += 1; + } + Ok(options) +} + +/// Whether the routing table offers a source address that can reach a public +/// service for `family`. +/// +/// Waiting out a connection to an unreachable family is the slowest part of a +/// bare run, and an IPv6 stack limited to link-local or unique-local addresses +/// never had a path to begin with. Reading the source address a UDP connect +/// selects costs nothing and sends nothing. +fn reachable(family: Family) -> bool { + let (bind, probe) = family.probe(); + let (Ok(socket), Ok(probe)) = (UdpSocket::bind(bind), probe.parse::<SocketAddr>()) else { + return false; + }; + if socket.connect(probe).is_err() { + return false; + } + match socket.local_addr() { + // Behind NAT the IPv4 source is private, which says nothing about + // reachability; only loopback and an unassigned socket rule it out. + Ok(SocketAddr::V4(local)) => !local.ip().is_loopback() && !local.ip().is_unspecified(), + // Global unicast is 2000::/3. A unique-local or link-local source + // cannot reach a public service however the route is configured. + Ok(SocketAddr::V6(local)) => local.ip().segments()[0] & 0xE000 == 0x2000, + Err(_) => false, + } +} + +fn shorten(reason: &str) -> String { + let mut text = reason.replace(['\r', '\n', '\0'], " "); + if text.chars().count() > MAX_REASON_CHARS { + let boundary = text + .char_indices() + .nth(MAX_REASON_CHARS) + .map_or(text.len(), |(index, _)| index); + text.truncate(boundary); + text.push_str("..."); + } + text +} + +fn fetch(url: &str, timeout: Duration) -> Result<IpAddr, String> { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(timeout)) + .build() + .into(); + let mut response = agent.get(url).call().map_err(|error| error.to_string())?; + let body = response + .body_mut() + .with_config() + .limit(MAX_RESPONSE_BYTES) + .read_to_string() + .map_err(|error| error.to_string())?; + body.trim() + .parse::<IpAddr>() + .map_err(|_| "answer was not an IP address".to_owned()) +} + +fn lookup(family: Family, timeout: Duration) -> Result<IpAddr, String> { + let services = family.services(); + if !services.overridden && !reachable(family) { + return Err(format!( + "this machine has no public {} route", + family.label() + )); + } + let mut failure = format!("no {} service was reachable", family.label()); + for url in services.urls { + match fetch(&url, timeout) { + Ok(address) if family.matches(address) => return Ok(address), + // A service answering with the other family cannot be trusted for + // this one, so move on rather than report the wrong address. + Ok(_) => failure = format!("{url} answered with the wrong address family"), + Err(error) => failure = format!("{url}: {error}"), + } + } + Err(shorten(&failure)) +} + +struct Report { + family: Family, + result: Result<IpAddr, String>, +} + +fn emit(reports: &[Report], format: Format) { + match format { + Format::Labeled => { + for report in reports { + match &report.result { + Ok(address) => println!("{}: {address}", report.family.label()), + Err(reason) => { + println!("{}: unavailable ({reason})", report.family.label()); + } + } + } + } + // Plain and JSON are consumed by other programs, so stdout carries only + // the answer and every explanation goes to stderr. + Format::Plain => { + for report in reports { + match &report.result { + Ok(address) => println!("{address}"), + Err(reason) => { + eprintln!("{TOOL}: {} unavailable: {reason}", report.family.label()); + } + } + } + } + Format::Json => { + let mut object = Map::new(); + for report in reports { + let value = match &report.result { + Ok(address) => Value::String(address.to_string()), + Err(reason) => { + eprintln!("{TOOL}: {} unavailable: {reason}", report.family.label()); + Value::Null + } + }; + object.insert(report.family.key().to_owned(), value); + } + println!("{}", Value::Object(object)); + } + } +} + +pub fn run(args: Vec<OsString>) -> ToolResult { + let options = parse(args)?; + if options.help { + println!("{HELP}"); + return Ok(()); + } + let timeout = Duration::from_secs(u64::from(options.timeout)); + // Both families are looked up together so reporting both costs one + // timeout rather than two. + let reports: Vec<Report> = std::thread::scope(|scope| { + let handles: Vec<_> = options + .families() + .into_iter() + .map(|family| (family, scope.spawn(move || lookup(family, timeout)))) + .collect(); + handles + .into_iter() + .map(|(family, handle)| Report { + family, + result: handle + .join() + .unwrap_or_else(|_| Err("the lookup did not finish".to_owned())), + }) + .collect() + }); + let failures: Vec<String> = reports + .iter() + .filter_map(|report| { + report + .result + .as_ref() + .err() + .map(|reason| format!("{}: {reason}", report.family.label())) + }) + .collect(); + if failures.len() == reports.len() { + return Err(ToolError::new( + TOOL, + format!("no public address available ({})", failures.join("; ")), + )); + } + emit(&reports, options.format); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_args(args: &[&str]) -> ToolResult<Options> { + parse(args.iter().map(OsString::from).collect()) + } + + #[test] + fn both_families_are_reported_without_a_switch() { + let options = parse_args(&[]).unwrap(); + assert_eq!(options.families(), vec![Family::V4, Family::V6]); + assert_eq!(options.format, Format::Labeled); + assert_eq!(options.timeout, DEFAULT_TIMEOUT); + // Asking for both explicitly is the same request as asking for neither. + assert_eq!( + parse_args(&["-4", "-6"]).unwrap().families(), + vec![Family::V4, Family::V6] + ); + assert_eq!(parse_args(&["-4"]).unwrap().families(), vec![Family::V4]); + assert_eq!( + parse_args(&["--ipv6"]).unwrap().families(), + vec![Family::V6] + ); + } + + #[test] + fn output_and_timeout_options_parse_in_both_forms() { + assert_eq!(parse_args(&["--plain"]).unwrap().format, Format::Plain); + assert_eq!(parse_args(&["--json"]).unwrap().format, Format::Json); + assert_eq!(parse_args(&["--timeout=12"]).unwrap().timeout, 12); + assert_eq!(parse_args(&["-t", "30"]).unwrap().timeout, 30); + for rejected in [ + vec!["--timeout", "0"], + vec!["--timeout", "61"], + vec!["--timeout"], + vec!["--timeout="], + vec!["--nope"], + vec!["8.8.8.8"], + ] { + let error = parse_args(&rejected).unwrap_err(); + assert_eq!(error.exit_code(), 2, "{rejected:?} should be a usage error"); + } + } + + #[test] + fn an_answer_is_only_accepted_for_the_family_it_was_asked_for() { + assert!(Family::V4.matches("203.0.113.7".parse().unwrap())); + assert!(!Family::V4.matches("2001:db8::7".parse().unwrap())); + assert!(Family::V6.matches("2001:db8::7".parse().unwrap())); + assert!(!Family::V6.matches("203.0.113.7".parse().unwrap())); + } + + #[test] + fn a_long_failure_is_trimmed_to_one_line() { + assert_eq!(shorten("dns failed\nfor host"), "dns failed for host"); + let long = shorten(&"x".repeat(MAX_REASON_CHARS + 50)); + assert_eq!(long.chars().count(), MAX_REASON_CHARS + 3); + assert!(long.ends_with("...")); + // A message that already fits is returned unchanged. + assert_eq!(shorten("short"), "short"); + } + + #[test] + fn an_ipv4_route_exists_wherever_tests_run() { + // Every test host can reach its own loopback-free IPv4 stack; IPv6 is + // deliberately not asserted because runners differ. + assert!(reachable(Family::V4)); + } +} diff --git a/src/commands/media.rs b/src/commands/media.rs index 710b72e..b592c43 100644 --- a/src/commands/media.rs +++ b/src/commands/media.rs @@ -7,6 +7,7 @@ use std::time::{Duration, Instant}; use rayon::prelude::*; +use crate::commands::download; use crate::common::{self, CollectedPaths, InputOptions, Plan}; use crate::deps; use crate::error::{ToolError, ToolResult}; @@ -91,6 +92,15 @@ impl MediaMode { fn is_audio(self) -> bool { matches!(self, Self::Audio | Self::Mp3 | Self::Wav) } + fn download_kind(self) -> Option<download::Kind> { + match self { + Self::Video => Some(download::Kind::Video { + max_height: Some(1080), + }), + Self::Audio | Self::Mp3 | Self::Wav => Some(download::Kind::Audio), + Self::Png | Self::Webp | Self::Avif => None, + } + } } #[derive(Clone, Debug)] @@ -109,8 +119,11 @@ struct Options { crf: u32, preset: String, audio_bitrate: String, + /// Tallest frame the video encode may produce; `None` keeps the source size. + resolution: Option<u32>, sample_rate: u32, bits: u32, + playlist: bool, inputs: Vec<OsString>, help: bool, } @@ -164,20 +177,29 @@ Options: mode.default_jobs() ), MediaMode::Video => format!( - r#"justvideo — Optimize videos as streaming-ready 720p H.264 MP4. + r#"justvideo — Optimize videos as streaming-ready H.264 MP4. Usage: - justvideo [options] [file-or-folder ...] + justvideo [options] [file-or-folder-or-url ...] + +Default output is <name>-web.mp4 at 720p using CRF 28, x264 medium, AAC 128k. +--resolution sets the largest frame; smaller sources are never upscaled. -Default output is <name>-web.mp4 using CRF 28, x264 medium, AAC 128k. +An http(s) URL is downloaded with yt-dlp (source height capped at 1080p, or at +a larger --resolution) and then encoded with these same settings to +`<title> [<id>].mp4` in the current folder, or in --output. The download itself +is temporary and is never kept. A missing yt-dlp or FFmpeg is downloaded +automatically, and yt-dlp is updated whenever twelve hours have passed. Options: -f, --replace Replace sources; non-MP4 sources become <name>.mp4 + --playlist Download every entry when a URL is a playlist -o, --output DIR Write outputs to DIR and keep sources -j, --jobs N Parallel encodes (default: {}) -r, --recursive Include nested folders -y, --yes Skip folder-scan confirmation -n, --dry-run Preview without changing files + --resolution SIZE 480p, 720p, 1080p, 1440p, 4k, or source (default: 720p) --crf N H.264 quality, 0-51 (default: 28) --preset NAME x264 preset (default: medium) --audio-bitrate N AAC bitrate (default: 128k) @@ -228,12 +250,18 @@ Options: r#"{} — Convert audio or extract it from video. Usage: - {} [options] [file-or-folder ...] + {} [options] [file-or-folder-or-url ...] Default: {description}. Sources are kept. +An http(s) URL is downloaded with yt-dlp and converted with these same settings +to `<title> [<id>]{}` in the current folder, or in --output. The download itself +is temporary and is never kept. A missing yt-dlp or FFmpeg is downloaded +automatically, and yt-dlp is updated whenever twelve hours have passed. + Options: -f, --replace Remove source after output is safely installed + --playlist Download every entry when a URL is a playlist -o, --output DIR Write outputs to DIR and keep sources -j, --jobs N Parallel conversions (default: {}) -r, --recursive Include nested folders @@ -245,6 +273,7 @@ Options: -h, --help Show this help"#, mode.tool(), mode.tool(), + mode.target_extension(), mode.default_jobs() ) } @@ -294,8 +323,10 @@ fn parse(mode: MediaMode, args: Vec<OsString>) -> ToolResult<Options> { "128k".into() } }), + resolution: Some(720), sample_rate: 48_000, bits: 16, + playlist: false, inputs: Vec::new(), help: false, }; @@ -412,6 +443,10 @@ fn parse(mode: MediaMode, args: Vec<OsString>) -> ToolResult<Options> { flag(&inline)?; options.include_target = true; } + "--playlist" if mode.download_kind().is_some() => { + flag(&inline)?; + options.playlist = true; + } "--reencode" if mode.is_audio() => { flag(&inline)?; options.include_target = true; @@ -430,6 +465,9 @@ fn parse(mode: MediaMode, args: Vec<OsString>) -> ToolResult<Options> { options.audio_bitrate = value(&mut index)?; validate_bitrate(tool, &options.audio_bitrate)?; } + "--resolution" if mode == MediaMode::Video => { + options.resolution = parse_resolution(tool, &value(&mut index)?)? + } "--crf" if mode == MediaMode::Video => { options.crf = common::integer(tool, &value(&mut index)?, "CRF", 0, 51)? } @@ -524,6 +562,48 @@ fn parse(mode: MediaMode, args: Vec<OsString>) -> ToolResult<Options> { Ok(options) } +/// Frame heights `justvideo --resolution` accepts, smallest first. +const RESOLUTIONS: [u32; 5] = [480, 720, 1080, 1440, 2160]; + +fn parse_resolution(tool: &str, value: &str) -> ToolResult<Option<u32>> { + let lower = value.trim().to_ascii_lowercase(); + let height = match lower.as_str() { + "source" | "original" => return Ok(None), + "4k" | "uhd" => Some(2160), + "qhd" => Some(1440), + other => other.strip_suffix('p').unwrap_or(other).parse::<u32>().ok(), + }; + height + .filter(|height| RESOLUTIONS.contains(height)) + .map(Some) + .ok_or_else(|| { + ToolError::usage( + tool, + "resolution must be 480p, 720p, 1080p, 1440p, 4k, or source", + ) + }) +} + +/// Scale into a 16:9 box of the requested height without ever upscaling; +/// H.264 4:2:0 needs even dimensions either way. +fn video_filter(resolution: Option<u32>) -> String { + match resolution { + Some(height) => { + let width = (height * 16 / 9 + 1) & !1; + format!( + "scale='min({width},iw)':'min({height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2,format=yuv420p" + ) + } + None => "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p".into(), + } +} + +/// Downloading more than the encode keeps only costs time, but 1080p is the +/// floor so smaller targets still downscale from a clean source. +fn download_height(resolution: Option<u32>) -> Option<u32> { + resolution.map(|height| height.max(1080)) +} + fn validate_quality_range(tool: &str, value: &str) -> ToolResult<String> { if let Ok(maximum) = value.parse::<u32>() && maximum <= 100 @@ -763,23 +843,7 @@ fn dependency_executable(mode: MediaMode) -> ToolResult<PathBuf> { MediaMode::Webp => "CWEBP_BIN", _ => "FFMPEG_BIN", }; - let Some(requested) = std::env::var_os(variable).filter(|value| !value.is_empty()) else { - return deps::require(mode.tool(), mode.dependency()); - }; - let path = PathBuf::from(&requested); - if path.is_file() { - return Ok(path); - } - let text = requested.to_str().ok_or_else(|| { - ToolError::new( - mode.tool(), - format!( - "{variable} points to a missing non-UTF-8 path: {}", - path.to_string_lossy() - ), - ) - })?; - deps::require(mode.tool(), text) + deps::require_override(mode.tool(), variable, mode.dependency()) } pub(crate) fn animated_png(path: &Path) -> bool { @@ -1061,27 +1125,154 @@ fn encode(mode: MediaMode, options: &Options, executable: &Path, plan: &Plan) -> let source = plan.source.as_os_str().to_owned(); let target = temporary.as_os_str().to_owned(); let args: Vec<OsString> = match mode { - MediaMode::Png => ["--quality", &options.quality_range, "--speed", &options.speed.to_string(), "--strip", "--skip-if-larger", "--force", "--output"] - .into_iter().map(OsString::from).chain([target.clone(), OsString::from("--"), source.clone()]).collect(), - MediaMode::Webp => ["-quiet", "-q", &options.quality.to_string(), "-m", &options.method.to_string(), "-mt", "-sharp_yuv", "-metadata", "none"] - .into_iter().map(OsString::from).chain([source.clone(), OsString::from("-o"), target.clone()]).collect(), - MediaMode::Video => vec!["-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i"].into_iter().map(OsString::from).chain([source.clone()]).chain([ - "-map", "0:v:0", "-map", "0:a:0?", "-map_metadata", "-1", "-sn", "-dn", "-vf", "scale='min(1280,iw)':'min(720,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2,format=yuv420p", "-c:v", "libx264", "-preset" - ].into_iter().map(OsString::from)).chain([OsString::from(&options.preset), OsString::from("-crf"), OsString::from(options.crf.to_string()), OsString::from("-c:a"), OsString::from("aac"), OsString::from("-b:a"), OsString::from(&options.audio_bitrate), OsString::from("-movflags"), OsString::from("+faststart"), target.clone()]).collect(), + MediaMode::Png => [ + "--quality", + &options.quality_range, + "--speed", + &options.speed.to_string(), + "--strip", + "--skip-if-larger", + "--force", + "--output", + ] + .into_iter() + .map(OsString::from) + .chain([target.clone(), OsString::from("--"), source.clone()]) + .collect(), + MediaMode::Webp => [ + "-quiet", + "-q", + &options.quality.to_string(), + "-m", + &options.method.to_string(), + "-mt", + "-sharp_yuv", + "-metadata", + "none", + ] + .into_iter() + .map(OsString::from) + .chain([source.clone(), OsString::from("-o"), target.clone()]) + .collect(), + MediaMode::Video => vec!["-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i"] + .into_iter() + .map(OsString::from) + .chain([source.clone()]) + .chain( + [ + "-map", + "0:v:0", + "-map", + "0:a:0?", + "-map_metadata", + "-1", + "-sn", + "-dn", + "-vf", + ] + .into_iter() + .map(OsString::from), + ) + .chain([ + OsString::from(video_filter(options.resolution)), + OsString::from("-c:v"), + OsString::from("libx264"), + OsString::from("-preset"), + OsString::from(&options.preset), + OsString::from("-crf"), + OsString::from(options.crf.to_string()), + OsString::from("-c:a"), + OsString::from("aac"), + OsString::from("-b:a"), + OsString::from(&options.audio_bitrate), + OsString::from("-movflags"), + OsString::from("+faststart"), + target.clone(), + ]) + .collect(), MediaMode::Avif => { let crf = (63.0 - options.quality as f64 * 0.63).round() as u32; - vec!["-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i"].into_iter().map(OsString::from).chain([source.clone()]).chain([ - "-frames:v", "1", "-map_metadata", "-1", "-an", "-sn", "-dn", "-c:v", "libaom-av1", "-still-picture", "1", "-crf" - ].into_iter().map(OsString::from)).chain([OsString::from(crf.to_string()), OsString::from("-cpu-used"), OsString::from(options.speed.to_string()), OsString::from("-row-mt"), OsString::from("1"), OsString::from("-b:v"), OsString::from("0"), OsString::from("-pix_fmt"), OsString::from("yuv420p"), target.clone()]).collect() + vec!["-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i"] + .into_iter() + .map(OsString::from) + .chain([source.clone()]) + .chain( + [ + "-frames:v", + "1", + "-map_metadata", + "-1", + "-an", + "-sn", + "-dn", + "-c:v", + "libaom-av1", + "-still-picture", + "1", + "-crf", + ] + .into_iter() + .map(OsString::from), + ) + .chain([ + OsString::from(crf.to_string()), + OsString::from("-cpu-used"), + OsString::from(options.speed.to_string()), + OsString::from("-row-mt"), + OsString::from("1"), + OsString::from("-b:v"), + OsString::from("0"), + OsString::from("-pix_fmt"), + OsString::from("yuv420p"), + target.clone(), + ]) + .collect() } MediaMode::Audio | MediaMode::Mp3 | MediaMode::Wav => { - let mut values: Vec<OsString> = ["-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i"].into_iter().map(OsString::from).collect(); + let mut values: Vec<OsString> = + ["-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i"] + .into_iter() + .map(OsString::from) + .collect(); values.push(source.clone()); - values.extend(["-map", "0:a:0", "-vn", "-sn", "-dn", "-map_metadata", "-1"].into_iter().map(OsString::from)); + values.extend( + ["-map", "0:a:0", "-vn", "-sn", "-dn", "-map_metadata", "-1"] + .into_iter() + .map(OsString::from), + ); match mode { - MediaMode::Audio => values.extend([OsString::from("-c:a"), OsString::from("aac"), OsString::from("-profile:a"), OsString::from("aac_low"), OsString::from("-b:a"), OsString::from(&options.audio_bitrate), OsString::from("-ar"), OsString::from(options.sample_rate.to_string()), OsString::from("-movflags"), OsString::from("+faststart")]), - MediaMode::Mp3 => values.extend([OsString::from("-c:a"), OsString::from("libmp3lame"), OsString::from("-q:a"), OsString::from(options.quality.to_string()), OsString::from("-ar"), OsString::from(options.sample_rate.to_string())]), - MediaMode::Wav => values.extend([OsString::from("-c:a"), OsString::from(if options.bits == 24 { "pcm_s24le" } else { "pcm_s16le" }), OsString::from("-ar"), OsString::from(options.sample_rate.to_string()), OsString::from("-ac"), OsString::from("2")]), + MediaMode::Audio => values.extend([ + OsString::from("-c:a"), + OsString::from("aac"), + OsString::from("-profile:a"), + OsString::from("aac_low"), + OsString::from("-b:a"), + OsString::from(&options.audio_bitrate), + OsString::from("-ar"), + OsString::from(options.sample_rate.to_string()), + OsString::from("-movflags"), + OsString::from("+faststart"), + ]), + MediaMode::Mp3 => values.extend([ + OsString::from("-c:a"), + OsString::from("libmp3lame"), + OsString::from("-q:a"), + OsString::from(options.quality.to_string()), + OsString::from("-ar"), + OsString::from(options.sample_rate.to_string()), + ]), + MediaMode::Wav => values.extend([ + OsString::from("-c:a"), + OsString::from(if options.bits == 24 { + "pcm_s24le" + } else { + "pcm_s16le" + }), + OsString::from("-ar"), + OsString::from(options.sample_rate.to_string()), + OsString::from("-ac"), + OsString::from("2"), + ]), _ => unreachable!(), } values.push(target.clone()); @@ -1140,6 +1331,84 @@ fn encode(mode: MediaMode, options: &Options, executable: &Path, plan: &Plan) -> Status::Done { before, after } } +fn split_inputs(mode: MediaMode, inputs: &[OsString]) -> ToolResult<(Vec<String>, Vec<OsString>)> { + let mut urls = Vec::new(); + let mut paths = Vec::new(); + for input in inputs { + if download::is_url(input) { + if mode.download_kind().is_none() { + return Err(ToolError::usage( + mode.tool(), + format!( + "{} reads local files; download a URL with justvideo, justaudio, justmp3, or justwav", + mode.tool() + ), + )); + } + urls.push(input.to_string_lossy().trim().to_owned()); + } else { + paths.push(input.clone()); + } + } + Ok((urls, paths)) +} + +fn download_directory(options: &Options) -> PathBuf { + options + .output + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) +} + +/// Downloads carry no source folder to write beside, so they land in the +/// current folder (or --output) under the title yt-dlp saved them as. Each +/// plan is reported against its URL rather than its temporary file. +fn download_plans( + mode: MediaMode, + options: &Options, + files: Vec<download::Fetched>, +) -> Vec<(Plan, String)> { + let parent = download_directory(options); + files + .into_iter() + .map(|fetched| { + let stem = fetched + .file + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + ( + Plan { + output: parent.join(format!("{stem}{}", mode.target_extension())), + source: fetched.file, + output_exists: false, + overwrites_source: false, + removes_source: false, + }, + fetched.url, + ) + }) + .collect() +} + +fn print_download_dry_run(mode: MediaMode, options: &Options, urls: &[String]) { + let parent = download_directory(options); + println!( + "{}: dry run — {} download(s) with yt-dlp", + mode.tool(), + urls.len() + ); + for url in urls { + println!( + " {url} -> {}", + common::display_path( + &parent.join(format!("<title> [<id>]{}", mode.target_extension())) + ) + ); + } +} + fn print_dry_run(mode: MediaMode, plans: &[Plan]) { println!("{}: dry run — {} file(s)", mode.tool(), plans.len()); for plan in plans.iter().take(100) { @@ -1182,26 +1451,40 @@ pub fn run(mode: MediaMode, args: Vec<OsString>) -> ToolResult { inputs = common::parse_input_lines(&common::read_stdin()?); piped = !inputs.is_empty(); } - if inputs.is_empty() { + let (urls, mut inputs) = split_inputs(mode, &inputs)?; + if inputs.is_empty() && urls.is_empty() { inputs.push( std::env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) .into_os_string(), ); } - let collected = collect(mode, &options, &inputs)?; + let collected = if inputs.is_empty() { + CollectedPaths { + files: Vec::new(), + used_directory: false, + warnings: Vec::new(), + } + } else { + collect(mode, &options, &inputs)? + }; for warning in &collected.warnings { eprintln!("{}: {warning}", mode.tool()); } - if collected.files.is_empty() { + if collected.files.is_empty() && urls.is_empty() { return Err(ToolError::new(mode.tool(), "no supported files found")); } for file in &collected.files { assert_safe(mode, file)?; } - let plans = plans(mode, &options, collected.files)?; + let mut plans = plans(mode, &options, collected.files)?; if options.dry_run { - print_dry_run(mode, &plans); + if !plans.is_empty() { + print_dry_run(mode, &plans); + } + if !urls.is_empty() { + print_download_dry_run(mode, &options, &urls); + } return Ok(()); } let destructive = plans @@ -1222,6 +1505,9 @@ pub fn run(mode: MediaMode, args: Vec<OsString>) -> ToolResult { { return Err(ToolError::cancelled(mode.tool())); } + if mode.download_kind().is_some() { + download::refresh(mode.tool()); + } let executable = dependency_executable(mode)?; verify_ffmpeg_encoder(mode, &options, &executable)?; if mode == MediaMode::Avif { @@ -1231,6 +1517,47 @@ pub fn run(mode: MediaMode, args: Vec<OsString>) -> ToolResult { } } } + let mut labels: Vec<String> = plans + .iter() + .map(|plan| common::display_path(&plan.source)) + .collect(); + // The workspace holds every download and is removed with this run. + let _workspace = if urls.is_empty() { + None + } else { + let kind = match mode + .download_kind() + .expect("URLs are rejected for modes that cannot download") + { + download::Kind::Video { .. } => download::Kind::Video { + max_height: download_height(options.resolution), + }, + kind => kind, + }; + let downloader = download::executable(mode.tool())?; + download::update(mode.tool(), &downloader); + let workspace = tempfile::tempdir().map_err(|error| { + ToolError::new( + mode.tool(), + format!("cannot create a download folder: {error}"), + ) + })?; + let files = download::fetch( + mode.tool(), + kind, + &downloader, + &executable, + &urls, + options.playlist, + workspace.path(), + )?; + for (plan, url) in download_plans(mode, &options, files) { + plans.push(plan); + labels.push(url); + } + common::validate_plans(mode.tool(), &mut plans)?; + Some(workspace) + }; let started = Instant::now(); println!( "{}: {} file(s), {} job(s)", @@ -1258,15 +1585,11 @@ pub fn run(mode: MediaMode, args: Vec<OsString>) -> ToolResult { let mut kept = 0; let mut failed = 0; let mut warnings = 0; - for (plan, status) in plans.iter().zip(&results) { + for ((plan, status), source) in plans.iter().zip(&results).zip(&labels) { let mapping = if common::same_path(&plan.source, &plan.output) { - common::display_path(&plan.source) + source.clone() } else { - format!( - "{} -> {}", - common::display_path(&plan.source), - common::display_path(&plan.output) - ) + format!("{source} -> {}", common::display_path(&plan.output)) }; match status { Status::Done { before, after } => { @@ -1279,10 +1602,7 @@ pub fn run(mode: MediaMode, args: Vec<OsString>) -> ToolResult { } Status::Kept(reason) => { kept += 1; - println!( - " kept {} ({reason})", - common::display_path(&plan.source) - ); + println!(" kept {source} ({reason})"); } Status::Warning(error) => { warnings += 1; @@ -1290,7 +1610,7 @@ pub fn run(mode: MediaMode, args: Vec<OsString>) -> ToolResult { } Status::Failed(error) => { failed += 1; - eprintln!(" failed {}: {error}", common::display_path(&plan.source)); + eprintln!(" failed {source}: {error}"); } } } @@ -1342,6 +1662,35 @@ mod tests { } } + #[test] + fn video_resolution_bounds_the_frame_and_the_download() { + let parsed = |value: &str| { + parse(MediaMode::Video, vec!["--resolution".into(), value.into()]) + .map(|options| options.resolution) + }; + assert_eq!( + parse(MediaMode::Video, vec![]).unwrap().resolution, + Some(720) + ); + assert_eq!(parsed("1080p").unwrap(), Some(1080)); + assert_eq!(parsed("480").unwrap(), Some(480)); + assert_eq!(parsed("4K").unwrap(), Some(2160)); + assert_eq!(parsed("source").unwrap(), None); + assert!(parsed("900").unwrap_err().message().contains("resolution")); + assert!(parse(MediaMode::Mp3, vec!["--resolution".into(), "720".into()]).is_err()); + + assert!(video_filter(Some(480)).starts_with("scale='min(854,iw)':'min(480,ih)'")); + assert!(video_filter(Some(720)).starts_with("scale='min(1280,iw)':'min(720,ih)'")); + assert!(video_filter(Some(2160)).starts_with("scale='min(3840,iw)':'min(2160,ih)'")); + assert_eq!( + video_filter(None), + "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p" + ); + assert_eq!(download_height(Some(480)), Some(1080)); + assert_eq!(download_height(Some(2160)), Some(2160)); + assert_eq!(download_height(None), None); + } + #[test] fn rejects_invalid_quality() { let error = parse(MediaMode::Png, vec!["--quality".into(), "90-20".into()]).unwrap_err(); @@ -1356,6 +1705,40 @@ mod tests { assert!(!encoder_available(listing, "x264")); } + #[test] + fn urls_are_separated_from_paths_and_refused_by_image_modes() { + let inputs = vec![ + OsString::from("https://example.test/watch?v=1"), + OsString::from("clip.mkv"), + ]; + let (urls, paths) = split_inputs(MediaMode::Video, &inputs).unwrap(); + assert_eq!(urls, vec!["https://example.test/watch?v=1".to_owned()]); + assert_eq!(paths, vec![OsString::from("clip.mkv")]); + let error = split_inputs(MediaMode::Png, &inputs).unwrap_err(); + assert_eq!(error.exit_code(), 2); + } + + #[test] + fn downloads_keep_their_saved_title_and_never_remove_a_source() { + let options = parse(MediaMode::Video, Vec::new()).unwrap(); + let plans = download_plans( + MediaMode::Video, + &options, + vec![download::Fetched { + url: "https://example.test/watch?v=1".into(), + file: PathBuf::from("Great Clip [abc123].mkv"), + }], + ); + let (plan, url) = &plans[0]; + assert_eq!( + plan.output.file_name().unwrap(), + OsStr::new("Great Clip [abc123].mp4") + ); + assert_eq!(url, "https://example.test/watch?v=1"); + assert!(!plan.removes_source); + assert!(!plan.overwrites_source); + } + #[test] fn encoder_output_path_stays_reserved() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9f862c9..43cd468 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -8,13 +8,16 @@ pub mod bunt; pub mod commit; mod core_port; pub mod crop; +mod download; mod image_ops; +pub mod ip; pub mod jpg; pub mod json; pub mod links; mod media; pub mod mkcd; pub mod optimize; +pub mod paste; pub mod pdf; pub mod png; pub mod port; @@ -55,6 +58,10 @@ pub const COMMANDS: &[CommandInfo] = &[ name: "justcrop", description: "trim transparent image borders to their alpha bounds", }, + CommandInfo { + name: "justip", + description: "show the public IPv4 and IPv6 address this machine presents", + }, CommandInfo { name: "justjpg", description: "create optimized progressive JPEG files", @@ -79,6 +86,10 @@ pub const COMMANDS: &[CommandInfo] = &[ name: "justoptimize", description: "choose the smallest web-ready PNG, WebP, or JPEG safely", }, + CommandInfo { + name: "justpaste", + description: "download whatever a link points at into the current folder", + }, CommandInfo { name: "justpdf", description: "inspect, merge, split, extract, or rotate PDFs, or save their images and links", @@ -117,7 +128,7 @@ pub const COMMANDS: &[CommandInfo] = &[ }, CommandInfo { name: "justvideo", - description: "optimize videos as streaming-ready 720p H.264 MP4", + description: "optimize videos as streaming-ready H.264 MP4 at 480p to 4K", }, CommandInfo { name: "justwav", @@ -151,7 +162,7 @@ pub fn dispatch(command: &str, args: Vec<OsString>) -> ToolResult { dispatch_headless(command, args) } -fn dispatch_headless(command: &str, args: Vec<OsString>) -> ToolResult { +pub(crate) fn dispatch_headless(command: &str, args: Vec<OsString>) -> ToolResult { match command { "justaudio" => audio::run(audio::Mode::Aac, args), "justmp3" => audio::run(audio::Mode::Mp3, args), @@ -161,6 +172,7 @@ fn dispatch_headless(command: &str, args: Vec<OsString>) -> ToolResult { "justbunt" => bunt::run(args), "justcommit" => commit::run(args), "justcrop" => crop::run(args), + "justip" => ip::run(args), "justjpg" => jpg::run(args), "justmkcd" => mkcd::run(args), "justpng" => png::run(args), @@ -169,6 +181,7 @@ fn dispatch_headless(command: &str, args: Vec<OsString>) -> ToolResult { "justzip" => zip::run(args), "justjson" => json::run(args), "justlinks" => links::run(args), + "justpaste" => paste::run(args), "justpdf" => pdf::run(args), "justport" => port::run(args), "justports" => ports::run(args), diff --git a/src/commands/paste.rs b/src/commands/paste.rs new file mode 100644 index 0000000..a7963da --- /dev/null +++ b/src/commands/paste.rs @@ -0,0 +1,845 @@ +//! `justpaste` — turn a link into a file in the current folder. +//! +//! A link that answers with a file (an image, a video, a PDF, an archive) is +//! saved as that file. A link that answers with a web page is handed to +//! yt-dlp, which knows YouTube, TikTok, and thousands of other sites; when +//! yt-dlp finds nothing, the media the page itself declares is saved instead. +//! Nothing is ever overwritten: a taken name gets " (2)", " (3)", and so on. + +use std::ffi::OsString; +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use ureq::ResponseExt; + +use crate::commands::download; +use crate::common; +use crate::deps; +use crate::error::{ToolError, ToolResult}; + +const TOOL: &str = "justpaste"; +/// Many hosts refuse clients that do not look like a browser. +const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"; +/// Enough of a page to find the media it declares in its head. +const MAX_PAGE_BYTES: u64 = 4 * 1024 * 1024; +const MAX_NAME_CHARS: usize = 150; + +struct Options { + output: Option<PathBuf>, + clipboard: bool, + playlist: bool, + dry_run: bool, + inputs: Vec<String>, + help: bool, +} + +fn help() { + println!( + r#"justpaste — Download whatever a link points at into this folder. + +Usage: + justpaste [options] [url ...] + justpaste --clipboard + +A link to a file (image, video, audio, PDF, archive, anything) is saved as that +file, named as the server names it. A link to a page is downloaded with yt-dlp, +which handles YouTube, TikTok, and most other video and social sites, at the +highest resolution, as an MP4. When yt-dlp finds nothing, the video or image +the page itself declares is saved instead. + +Files land in the current folder, or in --output. Nothing is overwritten: a +taken name becomes "name (2).ext". Partial downloads never appear under their +final name. Several links can be given, and --clipboard takes every link in +the copied text. + +Options: + -c, --clipboard Use the links in the clipboard + -o, --output DIR Save into DIR instead of the current folder + --playlist Download every entry when a link is a playlist + -n, --dry-run Show what would be fetched without any network access + -h, --help Show this help + +Page links need yt-dlp and FFmpeg, which are downloaded automatically when +missing; direct file links need nothing. yt-dlp is updated whenever twelve +hours have passed since its last update. + +Run justpaste with no arguments to open the interactive launcher. Its Headless +footer shows the equivalent direct command; explicit arguments and pipes bypass +the UI."# + ); +} + +fn parse(args: Vec<OsString>) -> ToolResult<Options> { + let mut options = Options { + output: None, + clipboard: false, + playlist: false, + dry_run: false, + inputs: Vec::new(), + help: false, + }; + let mut index = 0; + while index < args.len() { + let argument = common::os_to_string(TOOL, &args[index], "argument")?; + match argument.as_str() { + "-h" | "--help" => options.help = true, + "-c" | "--clipboard" => options.clipboard = true, + "--playlist" => options.playlist = true, + "-n" | "--dry-run" => options.dry_run = true, + "-o" | "--output" => { + index += 1; + let value = args + .get(index) + .filter(|value| !value.is_empty()) + .ok_or_else(|| ToolError::usage(TOOL, "--output needs a value"))?; + options.output = Some(PathBuf::from(value)); + } + _ if argument.starts_with("--output=") => { + options.output = Some(PathBuf::from(argument.trim_start_matches("--output="))); + } + _ if argument.starts_with('-') && argument != "-" => { + return Err(ToolError::usage( + TOOL, + format!("unknown option: {argument}"), + )); + } + _ => options.inputs.push(argument), + } + index += 1; + } + Ok(options) +} + +/// Every link in a piece of text, in order, without repeats. +/// +/// Copied text often wraps a link in quotes, brackets, or a trailing full +/// stop, so those are trimmed; a bare `www.` address is taken as HTTPS. +fn links(text: &str) -> Vec<String> { + let mut found: Vec<String> = Vec::new(); + for token in text.split(|character: char| { + character.is_whitespace() || matches!(character, '<' | '>' | '"' | '\'' | '`') + }) { + let token = token + .trim_start_matches(['(', '[', '{']) + .trim_end_matches(['.', ',', ';', '!', ')', ']', '}']); + let lower = token.to_ascii_lowercase(); + let link = if lower.starts_with("http://") || lower.starts_with("https://") { + token.to_owned() + } else if lower.starts_with("www.") && token.len() > 4 { + format!("https://{token}") + } else { + continue; + }; + if !found.contains(&link) { + found.push(link); + } + } + found +} + +#[cfg(windows)] +fn clipboard_text() -> Result<String, String> { + use windows_sys::Win32::System::DataExchange::{ + CloseClipboard, GetClipboardData, OpenClipboard, + }; + use windows_sys::Win32::System::Memory::{GlobalLock, GlobalUnlock}; + const UNICODE_TEXT: u32 = 13; + + // Another program may hold the clipboard for a moment while it copies. + let mut opened = false; + for _ in 0..10 { + if unsafe { OpenClipboard(std::ptr::null_mut()) } != 0 { + opened = true; + break; + } + std::thread::sleep(Duration::from_millis(40)); + } + if !opened { + return Err("the clipboard is in use by another program".into()); + } + let mut text = String::new(); + unsafe { + let handle = GetClipboardData(UNICODE_TEXT); + if !handle.is_null() { + let data = GlobalLock(handle) as *const u16; + if !data.is_null() { + let mut length = 0; + while *data.add(length) != 0 { + length += 1; + } + text = String::from_utf16_lossy(std::slice::from_raw_parts(data, length)); + GlobalUnlock(handle); + } + } + CloseClipboard(); + } + Ok(text) +} + +#[cfg(not(windows))] +fn clipboard_text() -> Result<String, String> { + let readers: &[(&str, &[&str])] = if cfg!(target_os = "macos") { + &[("pbpaste", &[])] + } else { + &[ + ("wl-paste", &["--no-newline"]), + ("xclip", &["-selection", "clipboard", "-o"]), + ("xsel", &["--clipboard", "--output"]), + ] + }; + for (program, args) in readers { + if let Ok(output) = std::process::Command::new(program).args(*args).output() + && output.status.success() + { + return Ok(String::from_utf8_lossy(&output.stdout).into_owned()); + } + } + Err("no clipboard reader was found (install wl-clipboard, xclip, or xsel)".into()) +} + +fn percent_decode(value: &str) -> String { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + let hex = |offset: usize| { + bytes + .get(index + offset) + .and_then(|byte| (*byte as char).to_digit(16)) + }; + match (bytes[index], hex(1), hex(2)) { + (b'%', Some(high), Some(low)) => { + decoded.push((high * 16 + low) as u8); + index += 3; + } + (byte, _, _) => { + decoded.push(byte); + index += 1; + } + } + } + String::from_utf8_lossy(&decoded).into_owned() +} + +/// Make a server-supplied name safe to create on every platform. +fn sanitize(name: &str) -> String { + let cleaned: String = name + .chars() + .map(|character| match character { + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_', + character if character.is_control() => '_', + character => character, + }) + .collect(); + let cleaned = cleaned.trim().trim_end_matches(['.', ' ']).to_owned(); + let (stem, extension) = match cleaned.rsplit_once('.') { + Some((stem, extension)) if !stem.is_empty() && extension.len() <= 10 => { + (stem.to_owned(), format!(".{extension}")) + } + _ => (cleaned, String::new()), + }; + let stem: String = stem.chars().take(MAX_NAME_CHARS).collect(); + let reserved = [ + "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "lpt1", "lpt2", "lpt3", + ]; + if reserved.contains(&stem.to_ascii_lowercase().as_str()) { + format!("_{stem}{extension}") + } else { + format!("{stem}{extension}") + } +} + +/// The file name a `Content-Disposition` header asks for. +fn disposition_name(header: &str) -> Option<String> { + let parameter = |key: &str| { + header.split(';').map(str::trim).find_map(|part| { + let (name, value) = part.split_once('=')?; + name.trim() + .eq_ignore_ascii_case(key) + .then(|| value.trim().trim_matches('"').to_owned()) + }) + }; + if let Some(encoded) = parameter("filename*") { + // RFC 5987: charset'language'percent-encoded-name + let value = encoded.rsplit('\'').next().unwrap_or(&encoded); + return Some(percent_decode(value)); + } + parameter("filename") +} + +fn extension_for(content_type: &str) -> Option<&'static str> { + Some(match content_type { + "image/jpeg" | "image/jpg" => "jpg", + "image/png" => "png", + "image/gif" => "gif", + "image/webp" => "webp", + "image/avif" => "avif", + "image/svg+xml" => "svg", + "image/bmp" => "bmp", + "image/tiff" => "tif", + "image/x-icon" | "image/vnd.microsoft.icon" => "ico", + "video/mp4" => "mp4", + "video/webm" => "webm", + "video/quicktime" => "mov", + "video/x-matroska" => "mkv", + "audio/mpeg" => "mp3", + "audio/mp4" | "audio/x-m4a" => "m4a", + "audio/wav" | "audio/x-wav" => "wav", + "audio/ogg" => "ogg", + "audio/flac" => "flac", + "application/pdf" => "pdf", + "application/zip" => "zip", + "application/json" => "json", + "application/gzip" => "gz", + "text/plain" => "txt", + "text/csv" => "csv", + "text/markdown" => "md", + _ => return None, + }) +} + +/// The media type without its parameters, in lowercase. +fn media_type(content_type: &str) -> String { + content_type + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase() +} + +fn is_page(content_type: &str) -> bool { + matches!( + media_type(content_type).as_str(), + "text/html" | "application/xhtml+xml" + ) +} + +/// Choose the saved name: what the server asks for, else the last segment of +/// the address, else the host, with an extension from the content type when +/// the name has none. +fn file_name(url: &str, disposition: Option<&str>, content_type: &str) -> String { + let without_query = url.split(['?', '#']).next().unwrap_or(url); + let after_scheme = without_query + .split_once("://") + .map_or(without_query, |(_, rest)| rest); + let (host, path) = after_scheme.split_once('/').unwrap_or((after_scheme, "")); + let segment = path.rsplit('/').find(|segment| !segment.is_empty()); + let mut name = disposition + .and_then(disposition_name) + .or_else(|| segment.map(percent_decode)) + .map(|name| sanitize(&name)) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| sanitize(host.split(':').next().unwrap_or("download"))); + if name.is_empty() { + name = "download".into(); + } + let has_extension = name + .rsplit_once('.') + .is_some_and(|(stem, extension)| !stem.is_empty() && !extension.is_empty()); + // A host name's dots are not an extension. + let named_by_host = disposition.is_none() && segment.is_none(); + if (!has_extension || named_by_host) + && let Some(extension) = extension_for(&media_type(content_type)) + { + name = format!("{name}.{extension}"); + } + name +} + +/// A name in `directory` that is not taken, numbering like a file manager. +fn free_path(directory: &Path, name: &str) -> PathBuf { + let candidate = directory.join(name); + if !candidate.exists() { + return candidate; + } + let (stem, extension) = match name.rsplit_once('.') { + Some((stem, extension)) if !stem.is_empty() => (stem, format!(".{extension}")), + _ => (name, String::new()), + }; + (2_u32..) + .map(|number| directory.join(format!("{stem} ({number}){extension}"))) + .find(|path| !path.exists()) + .expect("an unused number always exists") +} + +fn attribute(tag: &str, name: &str) -> Option<String> { + let lower = tag.to_ascii_lowercase(); + let mut search = 0; + while let Some(found) = lower[search..].find(name) { + let start = search + found; + let rest = tag[start + name.len()..].trim_start(); + let preceded = start == 0 + || lower[..start] + .chars() + .next_back() + .is_some_and(char::is_whitespace); + if preceded && let Some(value) = rest.strip_prefix('=') { + let value = value.trim_start(); + let quote = value.chars().next()?; + return if quote == '"' || quote == '\'' { + value[1..] + .split_once(quote) + .map(|(inner, _)| inner.to_owned()) + } else { + Some( + value + .split(|character: char| character.is_whitespace() || character == '>') + .next() + .unwrap_or_default() + .to_owned(), + ) + }; + } + search = start + name.len(); + } + None +} + +/// The media a page declares for link previews, video before image. +fn declared_media(html: &str, page: &str) -> Vec<String> { + const KEYS: [&str; 7] = [ + "og:video:secure_url", + "og:video:url", + "og:video", + "twitter:player:stream", + "og:image:secure_url", + "og:image", + "twitter:image", + ]; + let mut found: Vec<(usize, String)> = Vec::new(); + let lower = html.to_ascii_lowercase(); + let mut search = 0; + while let Some(offset) = lower[search..].find("<meta") { + let start = search + offset; + let end = lower[start..] + .find('>') + .map_or(html.len(), |end| start + end); + let tag = &html[start..end]; + search = end; + let key = attribute(tag, "property").or_else(|| attribute(tag, "name")); + let (Some(key), Some(content)) = (key, attribute(tag, "content")) else { + continue; + }; + let Some(rank) = KEYS + .iter() + .position(|known| key.eq_ignore_ascii_case(known)) + else { + continue; + }; + let content = content.replace("&", "&"); + let link = if content.starts_with("//") { + format!("https:{content}") + } else if content.starts_with('/') { + let origin: String = page.splitn(4, '/').take(3).collect::<Vec<_>>().join("/"); + format!("{origin}{content}") + } else { + content + }; + if (link.starts_with("http://") || link.starts_with("https://")) + && !found.iter().any(|(_, known)| *known == link) + { + found.push((rank, link)); + } + } + found.sort_by_key(|(rank, _)| *rank); + found.into_iter().map(|(_, link)| link).collect() +} + +/// What a link answered with. +enum Answer { + Saved(PathBuf, u64), + /// A web page, with as much of it as the head is likely to need. + Page { + html: String, + url: String, + }, +} + +fn agent() -> ureq::Agent { + ureq::Agent::config_builder() + .timeout_connect(Some(Duration::from_secs(20))) + .timeout_recv_response(Some(Duration::from_secs(45))) + .user_agent(USER_AGENT) + .build() + .into() +} + +/// Fetch a link, saving it when it is a file and returning it when it is a +/// page. The body streams into a hidden temporary file beside its +/// destination, so an interrupted download leaves nothing behind. +fn retrieve(agent: &ureq::Agent, url: &str, directory: &Path) -> Result<Answer, String> { + let mut response = agent.get(url).call().map_err(|error| error.to_string())?; + let header = |name: &str| { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + }; + let content_type = header("content-type").unwrap_or_default(); + let disposition = header("content-disposition"); + let final_url = response.get_uri().to_string(); + if is_page(&content_type) && disposition.is_none() { + let html = response + .body_mut() + .with_config() + .limit(MAX_PAGE_BYTES) + .read_to_string() + .unwrap_or_default(); + return Ok(Answer::Page { + html, + url: final_url, + }); + } + let name = file_name(&final_url, disposition.as_deref(), &content_type); + let mut temporary = tempfile::Builder::new() + .prefix(".justpaste-") + .suffix(".part") + .tempfile_in(directory) + .map_err(|error| format!("cannot write in {}: {error}", directory.display()))?; + let mut reader = response.body_mut().as_reader(); + let mut buffer = vec![0_u8; 256 * 1024]; + let mut written = 0_u64; + loop { + if common::interrupted() { + return Err("interrupted".into()); + } + let count = reader + .read(&mut buffer) + .map_err(|error| format!("the download stopped: {error}"))?; + if count == 0 { + break; + } + temporary + .write_all(&buffer[..count]) + .map_err(|error| format!("cannot write the download: {error}"))?; + written += count as u64; + } + if written == 0 { + return Err("the server sent an empty file".into()); + } + temporary + .flush() + .map_err(|error| format!("cannot write the download: {error}"))?; + let destination = free_path(directory, &name); + temporary + .persist_noclobber(&destination) + .map_err(|error| format!("cannot save {}: {}", destination.display(), error.error))?; + Ok(Answer::Saved(destination, written)) +} + +/// yt-dlp and FFmpeg, resolved once and only when a page link needs them. +struct Downloader { + resolved: Option<Result<(PathBuf, PathBuf), String>>, +} + +impl Downloader { + fn get(&mut self) -> Result<(PathBuf, PathBuf), String> { + self.resolved + .get_or_insert_with(|| { + let downloader = + download::executable(TOOL).map_err(|error| error.message().to_owned())?; + let ffmpeg = deps::require_override(TOOL, "FFMPEG_BIN", "ffmpeg") + .map_err(|error| error.message().to_owned())?; + download::update(TOOL, &downloader); + Ok((downloader, ffmpeg)) + }) + .clone() + } +} + +/// Download a page link with yt-dlp into a hidden folder inside the +/// destination, then move the results out under unused names. +fn with_downloader( + downloader: &mut Downloader, + url: &str, + playlist: bool, + directory: &Path, +) -> Result<Vec<(PathBuf, u64)>, String> { + let (executable, ffmpeg) = downloader.get()?; + let workspace = tempfile::Builder::new() + .prefix(".justpaste-") + .tempdir_in(directory) + .map_err(|error| format!("cannot write in {}: {error}", directory.display()))?; + let fetched = download::fetch( + TOOL, + download::Kind::Original, + &executable, + &ffmpeg, + &[url.to_owned()], + playlist, + workspace.path(), + ) + .map_err(|error| error.message().to_owned())?; + let mut saved = Vec::new(); + for file in fetched { + let name = file + .file + .file_name() + .map(|name| sanitize(&name.to_string_lossy())) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "download".into()); + let destination = free_path(directory, &name); + let size = fs::metadata(&file.file).map_or(0, |metadata| metadata.len()); + fs::rename(&file.file, &destination) + .map_err(|error| format!("cannot save {}: {error}", destination.display()))?; + saved.push((destination, size)); + } + Ok(saved) +} + +/// Bring one link into `directory` by whichever route works. +fn paste( + agent: &ureq::Agent, + downloader: &mut Downloader, + url: &str, + playlist: bool, + directory: &Path, +) -> Result<Vec<(PathBuf, u64)>, String> { + let page = match retrieve(agent, url, directory) { + Ok(Answer::Saved(path, size)) => return Ok(vec![(path, size)]), + Ok(Answer::Page { html, url }) => Some((html, url)), + // Sites that refuse a plain request often still work through yt-dlp. + Err(_) if !common::interrupted() => None, + Err(error) => return Err(error), + }; + let reason = match with_downloader(downloader, url, playlist, directory) { + Ok(saved) => return Ok(saved), + Err(reason) => reason, + }; + if let Some((html, page_url)) = &page { + let candidates = declared_media(html, page_url); + if !candidates.is_empty() { + println!("{TOOL}: yt-dlp found nothing; trying the media the page declares"); + } + for candidate in candidates { + if let Ok(Answer::Saved(path, size)) = retrieve(agent, &candidate, directory) { + return Ok(vec![(path, size)]); + } + } + } + Err(match page { + Some(_) => format!("nothing downloadable was found on that page ({reason})"), + None => format!("the link could not be fetched ({reason})"), + }) +} + +pub fn run(args: Vec<OsString>) -> ToolResult { + common::init_signals(); + let options = parse(args)?; + if options.help { + help(); + return Ok(()); + } + let mut urls = links(&options.inputs.join("\n")); + if urls.len() < options.inputs.len() { + return Err(ToolError::usage( + TOOL, + "every input must be an http(s) link", + )); + } + if options.clipboard { + let text = clipboard_text().map_err(|message| ToolError::new(TOOL, message))?; + let copied = links(&text); + if copied.is_empty() { + return Err(ToolError::new( + TOOL, + "the clipboard holds no http(s) link; copy a link and try again", + )); + } + for link in copied { + if !urls.contains(&link) { + urls.push(link); + } + } + } else if urls.is_empty() && !common::stdin_is_terminal() { + urls = links(&common::read_stdin()?); + } + if urls.is_empty() { + return Err(ToolError::usage( + TOOL, + "give a link, or use --clipboard to paste the copied one", + )); + } + let directory = match &options.output { + Some(output) => output.clone(), + None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + }; + if options.dry_run { + println!("{TOOL}: dry run — {} link(s)", urls.len()); + for url in &urls { + println!( + " {url} -> {} (the file itself, or yt-dlp for a page)", + common::display_path(&directory) + ); + } + return Ok(()); + } + fs::create_dir_all(&directory).map_err(|error| { + ToolError::new( + TOOL, + format!("cannot create {}: {error}", directory.display()), + ) + })?; + download::refresh(TOOL); + let agent = agent(); + let mut downloader = Downloader { resolved: None }; + let mut saved = 0; + let mut failed = 0; + for url in &urls { + if common::interrupted() { + break; + } + println!("{TOOL}: {url}"); + match paste(&agent, &mut downloader, url, options.playlist, &directory) { + Ok(files) => { + for (path, size) in files { + saved += 1; + println!( + " saved {} {}", + common::display_path(&path), + common::format_bytes(size) + ); + } + } + Err(reason) => { + failed += 1; + eprintln!(" failed {url}: {reason}"); + } + } + } + println!( + "{TOOL}: {saved} saved{}", + if failed > 0 { + format!(", {failed} failed") + } else { + String::new() + } + ); + if failed > 0 { + Err(ToolError::new(TOOL, "some links could not be downloaded")) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn links_are_found_in_copied_text_without_their_wrapping() { + assert_eq!( + links( + "Watch (https://youtu.be/abc123), then <https://example.test/a.jpg>.\nwww.example.test/page https://youtu.be/abc123" + ), + [ + "https://youtu.be/abc123", + "https://example.test/a.jpg", + "https://www.example.test/page", + ] + ); + assert!(links("no links here, ftp://example.test/file").is_empty()); + } + + #[test] + fn names_come_from_the_server_then_the_address_then_the_host() { + assert_eq!( + file_name( + "https://example.test/a/b/Photo%20One.JPG?size=large", + None, + "image/jpeg" + ), + "Photo One.JPG" + ); + assert_eq!( + file_name( + "https://example.test/download?id=7", + Some("attachment; filename=\"report: final?.pdf\""), + "application/pdf" + ), + "report_ final_.pdf" + ); + assert_eq!( + file_name( + "https://example.test/get", + Some("attachment; filename*=UTF-8''na%C3%AFve%20file.txt"), + "text/plain" + ), + "naïve file.txt" + ); + assert_eq!( + file_name("https://cdn.example.test/image/12345", None, "image/webp"), + "12345.webp" + ); + assert_eq!( + file_name("https://example.test/", None, "image/png"), + "example.test.png" + ); + assert_eq!( + file_name("https://example.test/blob", None, "application/x-unknown"), + "blob" + ); + assert_eq!(sanitize("con.txt"), "_con.txt"); + assert_eq!(sanitize("trailing. "), "trailing"); + } + + #[test] + fn taken_names_are_numbered_and_never_overwritten() { + let directory = tempfile::tempdir().unwrap(); + assert_eq!( + free_path(directory.path(), "clip.mp4"), + directory.path().join("clip.mp4") + ); + fs::write(directory.path().join("clip.mp4"), b"first").unwrap(); + fs::write(directory.path().join("clip (2).mp4"), b"second").unwrap(); + assert_eq!( + free_path(directory.path(), "clip.mp4"), + directory.path().join("clip (3).mp4") + ); + fs::write(directory.path().join("README"), b"x").unwrap(); + assert_eq!( + free_path(directory.path(), "README"), + directory.path().join("README (2)") + ); + } + + #[test] + fn pages_are_recognized_and_their_declared_media_ranks_video_first() { + assert!(is_page("text/html; charset=utf-8")); + assert!(!is_page("image/jpeg")); + assert!(!is_page("")); + let html = r#"<html><head> + <meta property="og:image" content="//cdn.example.test/cover.jpg?a=1&b=2"> + <META content='/media/clip.mp4' property='og:video'> + <meta name="twitter:image" content="https://cdn.example.test/cover.jpg?a=1&b=2"> + <meta name="description" content="https://example.test/not-media"> + </head></html>"#; + assert_eq!( + declared_media(html, "https://example.test/post/1"), + [ + "https://example.test/media/clip.mp4", + "https://cdn.example.test/cover.jpg?a=1&b=2", + ] + ); + } + + #[test] + fn options_parse_and_reject_unknown_switches() { + let options = parse(vec![ + "--clipboard".into(), + "-o".into(), + "downloads".into(), + "--playlist".into(), + "https://example.test/a".into(), + ]) + .unwrap(); + assert!(options.clipboard && options.playlist); + assert_eq!(options.output, Some(PathBuf::from("downloads"))); + assert_eq!(options.inputs, ["https://example.test/a"]); + assert!(parse(vec!["--replace".into()]).is_err()); + } +} diff --git a/src/context.rs b/src/context.rs new file mode 100644 index 0000000..36e83c0 --- /dev/null +++ b/src/context.rs @@ -0,0 +1,829 @@ +//! `just context` — the Windows File Explorer context menu. +//! +//! `install`, `uninstall`, and `status` manage the registration. `run` is what +//! Explorer starts when an entry is chosen: it turns the entry's ID and the +//! selected paths into the same headless command a terminal user would type, +//! shows that command, runs it, and keeps the window readable afterwards. + +use std::ffi::OsString; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::style::Stylize; +use justtools_menu::{self as menu, Action, Output, Run, Tool}; + +use crate::common; +use crate::error::{ToolError, ToolResult}; + +mod package; +#[cfg(windows)] +mod register; + +/// How long a successful run stays on screen before its window closes. +const CLOSE_DELAY: Duration = Duration::from_secs(3); +/// How long a failure, or a result meant to be read, stays on screen. +const READ_DELAY: Duration = Duration::from_secs(30); +/// File arguments shown in the Headless line before it is abbreviated. +const SHOWN_INPUTS: usize = 4; + +fn help() { + println!( + r#"just context — Put JustTools in the Windows File Explorer context menu. + +Usage: + just context install [--classic] + just context uninstall + just context status + +Right-click a video, audio file, image, PDF, SVG, JSON file, document, or +folder and choose JustTools. Every entry opens a console window that shows the +equivalent Headless command, runs it, and then closes by itself; any key keeps +it open. Entries ending in "…" open the tool's console launcher instead. + +install Register the menu for the current user; no elevation is needed. + On Windows 11 the top-level menu is used when a signed + justtools-shell.msix sits beside just.exe or Developer Mode is on. + Otherwise, and on Windows 10, the menu is registered under + "Show more options". + --classic Always register under "Show more options". +uninstall Remove every registration made by install. +status Show which registration is active. + +Sources are kept unless an entry says "in place". JustWebP and JustAVIF write +beside the source and keep it. Nothing is saved between runs."# + ); +} + +fn usage(message: impl Into<String>) -> ToolError { + ToolError::usage("just", message) +} + +pub fn run(args: Vec<OsString>) -> ToolResult { + let mut args = args.into_iter(); + let command = args + .next() + .map(|value| value.to_string_lossy().into_owned()); + let rest: Vec<OsString> = args.collect(); + match command.as_deref() { + None | Some("-h" | "--help" | "help") => { + help(); + Ok(()) + } + Some("run") => run_entry(rest), + Some("package") => write_package(rest), + Some("install") => { + let classic = match rest.as_slice() { + [] => false, + [flag] if flag == "--classic" => true, + _ => return Err(usage("context install accepts only --classic")), + }; + install(classic) + } + Some(name @ ("uninstall" | "status" | "refresh")) => { + if !rest.is_empty() { + return Err(usage(format!("context {name} does not take arguments"))); + } + match name { + "status" => status(), + "uninstall" => uninstall(), + _ => refresh(), + } + } + Some(other) => Err(usage(format!("unknown context command: {other}"))), + } +} + +/// Write the package layout a release signs: `just context package --output +/// DIR [--publisher SUBJECT]`. The publisher must equal the certificate's +/// subject, so the release workflow supplies it. +fn write_package(args: Vec<OsString>) -> ToolResult { + let mut output = None; + let mut publisher = package::DEFAULT_PUBLISHER.to_owned(); + let mut index = 0; + while index < args.len() { + match args[index].to_string_lossy().as_ref() { + "-o" | "--output" => { + index += 1; + output = args.get(index).map(PathBuf::from); + } + "--publisher" => { + publisher = common::option_value("just", &args, &mut index, "--publisher")?; + } + other => return Err(usage(format!("unknown context package option: {other}"))), + } + index += 1; + } + let output = output.ok_or_else(|| usage("context package needs --output DIR"))?; + package::write_layout(&output, &publisher)?; + println!( + "just: wrote the context-menu package layout to {}", + output.display() + ); + Ok(()) +} + +#[cfg(not(windows))] +fn windows_only() -> ToolError { + ToolError::new( + "just", + "the context menu is a Windows File Explorer feature", + ) +} + +#[cfg(not(windows))] +fn install(_classic: bool) -> ToolResult { + Err(windows_only()) +} + +#[cfg(not(windows))] +fn uninstall() -> ToolResult { + Err(windows_only()) +} + +#[cfg(not(windows))] +fn status() -> ToolResult { + Err(windows_only()) +} + +#[cfg(windows)] +const TOP_LEVEL_HINT: &str = "For the top-level Windows 11 menu, turn on Developer Mode (Settings > System > For developers) and run `just context install` again."; + +#[cfg(windows)] +fn install(classic: bool) -> ToolResult { + let (tier, reason) = register::install(classic)?; + match tier { + register::Tier::Package => { + println!("just: JustTools is in the Windows 11 context menu."); + } + register::Tier::Classic => { + let status = register::status()?; + if status.windows_11 { + println!( + "just: JustTools is in the context menu under \"Show more options\" (or Shift+right-click)." + ); + } else { + println!("just: JustTools is in the context menu."); + } + if let Some(reason) = reason { + println!("just: {reason}."); + if status.windows_11 && !status.developer_mode && !status.signed_package { + println!("just: {TOP_LEVEL_HINT}"); + } + } + } + } + println!("just: right-click a video, image, PDF, or folder to use it."); + Ok(()) +} + +#[cfg(windows)] +fn uninstall() -> ToolResult { + register::uninstall()?; + println!("just: JustTools was removed from the context menu."); + Ok(()) +} + +#[cfg(windows)] +fn status() -> ToolResult { + let status = register::status()?; + match (&status.package, status.classic) { + (Some(version), _) => { + println!("Context menu: installed in the Windows 11 menu (package {version})") + } + (None, true) if status.windows_11 => { + println!("Context menu: installed under \"Show more options\"") + } + (None, true) => println!("Context menu: installed"), + (None, false) => println!("Context menu: not installed (run `just context install`)"), + } + if let Some(owner) = status.owner.as_deref().filter(|_| !status.owned) { + println!( + "Registered by: {} (another JustTools copy)", + owner.display() + ); + } + println!( + "Extension: {}{}", + status.library.display(), + if status.library.is_file() { + "" + } else { + " (missing)" + } + ); + if status.windows_11 { + println!( + "Top-level: {}", + if status.signed_package { + "available (signed package present)" + } else if status.developer_mode { + "available (Developer Mode is on)" + } else { + "needs a signed justtools-shell.msix or Developer Mode" + } + ); + } + Ok(()) +} + +/// Re-register a menu that is already installed so it matches the build now +/// on disk; a menu that was never installed stays absent. `just install` runs +/// this after replacing the files. +#[cfg(windows)] +fn refresh() -> ToolResult { + match register::ownership()? { + None => { + if register::library_present() { + println!( + "just: add the File Explorer right-click menu with `just context install`." + ); + } + return Ok(()); + } + // Another copy of JustTools registered the menu; it is not ours to move. + Some(false) => return Ok(()), + Some(true) => {} + } + let status = register::status()?; + if status.package.is_none() && !status.classic { + return Ok(()); + } + if !status.library.is_file() { + // The new build ships no extension, so the old entry would point at + // nothing. + register::uninstall()?; + println!("just: removed the File Explorer context menu; this build has no extension."); + return Ok(()); + } + register::install(status.package.is_none())?; + println!("just: refreshed the File Explorer context menu."); + Ok(()) +} + +#[cfg(not(windows))] +fn refresh() -> ToolResult { + Ok(()) +} + +/// One headless run of a tool. +#[derive(Clone, Debug, Eq, PartialEq)] +struct Invocation { + directory: PathBuf, + args: Vec<OsString>, +} + +fn parent_of(path: &Path) -> PathBuf { + path.parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map_or_else(|| PathBuf::from("."), Path::to_path_buf) +} + +/// A path as the tool should receive it when running inside `directory`. +/// +/// Files in that folder go by name, which keeps the tool's own progress lines +/// short; a name that could be mistaken for an option is anchored with `.`. +fn argument(path: &Path, directory: &Path) -> OsString { + match path.file_name() { + Some(name) if parent_of(path) == directory => { + if name.to_string_lossy().starts_with('-') { + Path::new(".").join(name).into_os_string() + } else { + name.to_owned() + } + } + _ => path.as_os_str().to_owned(), + } +} + +/// Group paths by parent folder, keeping first-seen order. +fn by_folder(paths: &[PathBuf]) -> Vec<(PathBuf, Vec<PathBuf>)> { + let mut groups: Vec<(PathBuf, Vec<PathBuf>)> = Vec::new(); + for path in paths { + let parent = parent_of(path); + match groups.iter_mut().find(|(folder, _)| *folder == parent) { + Some((_, files)) => files.push(path.clone()), + None => groups.push((parent, vec![path.clone()])), + } + } + groups +} + +fn accepted(tool: Tool, paths: &[PathBuf]) -> Vec<PathBuf> { + paths + .iter() + .filter(|path| { + if matches!(tool, Tool::Zip | Tool::Paste) { + path.is_dir() + } else { + menu::extension(&path.to_string_lossy()) + .is_some_and(|extension| tool.accepts(&extension)) + } + }) + .cloned() + .collect() +} + +/// Turn a headless entry and the selection into the runs it stands for. +fn plan(action: &Action, tool: Tool, paths: &[PathBuf]) -> Result<Vec<Invocation>, String> { + let inputs = accepted(tool, paths); + if inputs.len() < action.minimum.max(1) as usize { + return Err(if inputs.is_empty() { + format!( + "nothing in the selection is something {} reads", + tool.name() + ) + } else { + format!( + "{} needs at least {} files here", + tool.name(), + action.minimum + ) + }); + } + let preset = || action.args.iter().map(OsString::from); + let invocation = |directory: &Path, files: &[PathBuf], output: bool| Invocation { + directory: directory.to_path_buf(), + args: preset() + .chain( + output + .then(|| ["--output", "."].map(OsString::from)) + .into_iter() + .flatten(), + ) + .chain(files.iter().map(|file| argument(file, directory))) + .collect(), + }; + if tool == Tool::Paste { + // The folder is where the download lands, not something to read. + return Ok(inputs + .iter() + .map(|folder| Invocation { + directory: folder.clone(), + args: preset().collect(), + }) + .collect()); + } + Ok(if action.per_file || tool == Tool::Zip { + inputs + .iter() + .map(|file| invocation(&parent_of(file), std::slice::from_ref(file), false)) + .collect() + } else if action.output == Output::SourceFolder { + by_folder(&inputs) + .iter() + .map(|(folder, files)| invocation(folder, files, true)) + .collect() + } else { + vec![invocation(&parent_of(&inputs[0]), &inputs, false)] + }) +} + +/// The command as a terminal user would type it, abbreviated once the file +/// list stops being readable. +fn headless_line(tool: Tool, action: &Action, invocation: &Invocation) -> String { + let fixed = invocation.args.len().min( + action.args.len() + + if action.output == Output::SourceFolder { + 2 + } else { + 0 + }, + ); + let (options, inputs) = invocation.args.split_at(fixed); + let mut parts = vec![tool.command().to_owned()]; + parts.extend( + options + .iter() + .chain(inputs.iter().take(SHOWN_INPUTS)) + .map(|value| crate::launcher::quote(&value.to_string_lossy())), + ); + if inputs.len() > SHOWN_INPUTS { + parts.push(format!("… (+{} more)", inputs.len() - SHOWN_INPUTS)); + } + parts.join(" ") +} + +fn interactive() -> bool { + common::stdin_is_terminal() && common::stdout_is_terminal() +} + +fn set_title(title: &str) { + if interactive() { + crossterm::execute!(io::stdout(), crossterm::terminal::SetTitle(title)).ok(); + } +} + +fn enter(directory: &Path) -> ToolResult { + std::env::set_current_dir(directory).map_err(|error| { + ToolError::new( + "just", + format!("cannot open {}: {error}", directory.display()), + ) + }) +} + +/// What a finished entry leaves for the window to do. +enum Outcome { + /// Nothing ran, or a full-screen console already had the last word. + Closed, + Ran { + failed: bool, + }, +} + +fn report(error: &ToolError) { + eprintln!("{}: {}", error.tool(), error.message()); +} + +fn run_headless(action: &Action, tool: Tool, paths: &[PathBuf]) -> ToolResult<Outcome> { + let invocations = + plan(action, tool, paths).map_err(|message| ToolError::new("just", message))?; + set_title(&format!("JustTools · {}", tool.name())); + let mut failed = false; + let mut cancelled = 0; + for (index, invocation) in invocations.iter().enumerate() { + if index > 0 { + println!(); + } + let command = headless_line(tool, action, invocation); + let folder = invocation.directory.display(); + if interactive() { + println!("{} {}", "Headless:".dark_grey(), command.magenta().bold()); + println!( + "{} {folder} +", + "Folder: ".dark_grey() + ); + } else { + println!( + "Headless: {command} +Folder: {folder} +" + ); + } + enter(&invocation.directory)?; + match crate::commands::dispatch_headless(tool.command(), invocation.args.clone()) { + Ok(()) => {} + Err(error) if error.exit_code() == 130 => cancelled += 1, + Err(error) => { + report(&error); + failed = true; + } + } + } + Ok(if !failed && cancelled == invocations.len() { + Outcome::Closed + } else { + Outcome::Ran { failed } + }) +} + +/// Run what a launcher or the tool browser configured. +fn run_configured(command: &str, args: Option<Vec<OsString>>) -> Outcome { + let Some(args) = args else { + return Outcome::Closed; + }; + match crate::commands::dispatch_headless(command, args) { + Ok(()) => Outcome::Ran { failed: false }, + Err(error) if error.exit_code() == 130 => Outcome::Closed, + Err(error) => { + report(&error); + Outcome::Ran { failed: true } + } + } +} + +fn needs_terminal() -> ToolError { + ToolError::new( + "just", + "this context-menu entry opens a console and needs a terminal", + ) +} + +fn folder_of(paths: &[PathBuf]) -> PathBuf { + match paths.first() { + Some(path) if path.is_dir() => path.clone(), + Some(path) => parent_of(path), + None => PathBuf::from("."), + } +} + +/// The launcher's input row for a selection: names inside the working folder, +/// quoted wherever the row's `;` separator or quote handling would split them. +fn launcher_inputs(paths: &[PathBuf], directory: &Path) -> String { + paths + .iter() + .map(|path| { + let text = argument(path, directory).to_string_lossy().into_owned(); + if text.contains([';', '\'', ' ']) { + format!("\"{text}\"") + } else { + text + } + }) + .collect::<Vec<_>>() + .join("; ") +} + +fn perform(action: &Action, paths: &[PathBuf]) -> ToolResult<Outcome> { + let Some(tool) = action.tool else { + if !interactive() { + return Err(needs_terminal()); + } + enter(&folder_of(paths))?; + set_title("JustTools"); + let Some(command) = crate::selector::pick()? else { + return Ok(Outcome::Closed); + }; + if !crate::launcher::supports(command) { + // Live dashboards own the screen until they exit. + crate::commands::dispatch(command, Vec::new())?; + return Ok(Outcome::Closed); + } + return Ok(run_configured(command, crate::launcher::run(command)?)); + }; + match action.run { + Run::Headless => run_headless(action, tool, paths), + Run::Launcher | Run::LauncherHere => { + if !interactive() { + return Err(needs_terminal()); + } + let directory = folder_of(paths); + enter(&directory)?; + set_title(&format!("JustTools · {}", tool.name())); + let configured = if action.run == Run::Launcher { + let inputs = launcher_inputs(&accepted(tool, paths), &directory); + crate::launcher::run_with_input(tool.command(), &inputs)? + } else { + crate::launcher::run(tool.command())? + }; + Ok(run_configured(tool.command(), configured)) + } + Run::Browse => unreachable!("browse entries carry no tool"), + } +} + +fn read_list(path: &Path) -> ToolResult<Vec<PathBuf>> { + let text = std::fs::read_to_string(path).map_err(|error| { + ToolError::new( + "just", + format!("cannot read the selection {}: {error}", path.display()), + ) + })?; + Ok(text + .trim_start_matches('\u{feff}') + .lines() + .map(|line| line.trim_end_matches('\r')) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .collect()) +} + +/// `just context run <entry> [--list FILE] [path ...]` +fn run_entry(args: Vec<OsString>) -> ToolResult { + let mut id = None; + let mut list = None; + let mut paths = Vec::new(); + let mut index = 0; + while index < args.len() { + if args[index] == "--list" { + index += 1; + list = Some( + args.get(index) + .map(PathBuf::from) + .ok_or_else(|| usage("--list needs a file"))?, + ); + } else if id.is_none() { + id = Some(args[index].to_string_lossy().into_owned()); + } else { + paths.push(PathBuf::from(&args[index])); + } + index += 1; + } + let id = id.ok_or_else(|| usage("context run needs a menu entry"))?; + if let Some(list) = list { + let listed = read_list(&list); + // The list is a one-run handover from Explorer, not user data. + std::fs::remove_file(&list).ok(); + paths.extend(listed?); + } + let action = + menu::action(&id).ok_or_else(|| usage(format!("unknown context-menu entry: {id}")))?; + let outcome = perform(&action, &paths); + if !interactive() { + return match outcome? { + Outcome::Ran { failed: true } => { + Err(ToolError::new("just", "the context-menu entry failed")) + } + _ => Ok(()), + }; + } + let failed = match outcome { + Ok(Outcome::Closed) => return Ok(()), + Ok(Outcome::Ran { failed }) => failed, + Err(error) if error.exit_code() == 130 => return Ok(()), + Err(error) => { + report(&error); + true + } + }; + hold(failed, action.hold); + // The failure has been shown and acknowledged. A nonzero exit status + // would only make Windows Terminal keep the finished tab open. + Ok(()) +} + +fn key_pressed(timeout: Duration) -> Option<KeyCode> { + if !event::poll(timeout).unwrap_or(false) { + return None; + } + match event::read() { + Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => Some(key.code), + _ => None, + } +} + +fn closes(key: KeyCode) -> bool { + matches!(key, KeyCode::Enter | KeyCode::Esc | KeyCode::Char('q')) +} + +/// Keep the result readable, then close the window without being asked. +/// +/// A plain success closes after a moment. A failure, or a result meant to be +/// read, gets longer. Enter closes at once and any other key stops the +/// countdown, so nothing disappears while it is being read. +fn hold(failed: bool, keep: bool) { + let mut out = io::stdout(); + if crossterm::terminal::enable_raw_mode().is_err() { + return; + } + let verdict = if failed { + "Finished with errors.".red().bold() + } else { + "Done.".green().bold() + }; + let delay = if failed || keep { + READ_DELAY + } else { + CLOSE_DELAY + }; + let deadline = Instant::now() + delay; + let mut pinned = false; + write!(out, "\r\n").ok(); + loop { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + break; + } + let hint = format!( + "Closing in {} s · Enter closes now · any other key keeps this window open", + left.as_secs() + 1 + ); + write!(out, "\r{verdict} {} ", hint.dark_grey()).ok(); + out.flush().ok(); + match key_pressed(left.min(Duration::from_millis(200))) { + Some(key) if closes(key) => break, + Some(_) => { + pinned = true; + break; + } + None => {} + } + } + if pinned { + let hint = format!("Press Enter to close this window.{:44}", ""); + write!(out, "\r{verdict} {}", hint.dark_grey()).ok(); + out.flush().ok(); + while !key_pressed(Duration::from_secs(3600)).is_some_and(closes) {} + } + crossterm::terminal::disable_raw_mode().ok(); + writeln!(out).ok(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(invocation: &Invocation) -> Vec<String> { + invocation + .args + .iter() + .map(|value| value.to_string_lossy().into_owned()) + .collect() + } + + fn paths(values: &[&str]) -> Vec<PathBuf> { + values.iter().map(PathBuf::from).collect() + } + + fn planned(id: &str, values: &[&str]) -> Vec<Invocation> { + let action = menu::action(id).unwrap(); + plan(&action, action.tool.unwrap(), &paths(values)).unwrap() + } + + #[test] + fn a_resolution_entry_becomes_one_run_named_from_the_source_folder() { + let runs = planned( + "video.1080", + &["clips/a.mov", "clips/b.mp4", "clips/notes.txt"], + ); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].directory, PathBuf::from("clips")); + assert_eq!( + strings(&runs[0]), + ["--resolution", "1080p", "a.mov", "b.mp4"] + ); + let action = menu::action("video.1080").unwrap(); + assert_eq!( + headless_line(Tool::Video, &action, &runs[0]), + "justvideo --resolution 1080p a.mov b.mp4" + ); + } + + #[test] + fn source_consuming_converters_write_beside_each_source_and_keep_it() { + let runs = planned("webp", &["one/a.png", "two/b.jpg", "one/c.png"]); + assert_eq!(runs.len(), 2); + assert_eq!(runs[0].directory, PathBuf::from("one")); + assert_eq!(strings(&runs[0]), ["--output", ".", "a.png", "c.png"]); + assert_eq!(runs[1].directory, PathBuf::from("two")); + assert_eq!(strings(&runs[1]), ["--output", ".", "b.jpg"]); + } + + #[test] + fn single_input_operations_run_once_per_file_and_merge_runs_once() { + let split = planned("pdf.split", &["docs/a.pdf", "docs/b.pdf"]); + assert_eq!(split.len(), 2); + assert_eq!(strings(&split[1]), ["split", "b.pdf"]); + let merge = planned("pdf.merge", &["docs/a.pdf", "docs/b.pdf"]); + assert_eq!(merge.len(), 1); + assert_eq!(strings(&merge[0]), ["merge", "a.pdf", "b.pdf"]); + + let action = menu::action("pdf.merge").unwrap(); + let error = plan(&action, Tool::Pdf, &paths(&["docs/a.pdf"])).unwrap_err(); + assert!(error.contains("at least 2")); + } + + #[test] + fn pasting_runs_inside_the_folder_that_was_clicked() { + let folder = tempfile::tempdir().unwrap(); + let action = menu::action("paste").unwrap(); + let runs = plan(&action, Tool::Paste, &[folder.path().to_path_buf()]).unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].directory, folder.path()); + assert_eq!(strings(&runs[0]), ["--clipboard"]); + assert_eq!( + headless_line(Tool::Paste, &action, &runs[0]), + "justpaste --clipboard" + ); + } + + #[test] + fn option_like_names_are_anchored_and_other_folders_stay_absolute() { + let runs = planned("mp3", &["music/-intro.wav", "other/b.flac"]); + let args = strings(&runs[0]); + assert_eq!(PathBuf::from(&args[0]), Path::new(".").join("-intro.wav")); + assert_eq!(PathBuf::from(&args[1]), PathBuf::from("other/b.flac")); + } + + #[test] + fn unreadable_selections_are_refused_and_long_ones_are_abbreviated() { + let action = menu::action("optimize").unwrap(); + let error = plan(&action, Tool::Optimize, &paths(&["a.mp4"])).unwrap_err(); + assert!(error.contains("JustOptimize")); + + let many: Vec<String> = (0..9).map(|index| format!("p/{index}.png")).collect(); + let many: Vec<&str> = many.iter().map(String::as_str).collect(); + let runs = planned("resize.2560", &many); + assert_eq!( + headless_line( + Tool::Resize, + &menu::action("resize.2560").unwrap(), + &runs[0] + ), + "justresize --max 2560 0.png 1.png 2.png 3.png … (+5 more)" + ); + } + + #[test] + fn launcher_input_quotes_names_the_row_would_split() { + let row = launcher_inputs( + &paths(&["clips/plain.mov", "clips/my clip.mov", "clips/a;b.mov"]), + Path::new("clips"), + ); + assert_eq!(row, r#"plain.mov; "my clip.mov"; "a;b.mov""#); + } + + #[test] + fn selection_lists_ignore_blank_lines_and_a_byte_order_mark() { + let directory = tempfile::tempdir().unwrap(); + let list = directory.path().join("list.txt"); + std::fs::write(&list, "\u{feff}C:\\a b\\one.png\r\n\r\nC:\\two.png\n").unwrap(); + assert_eq!( + read_list(&list).unwrap(), + paths(&["C:\\a b\\one.png", "C:\\two.png"]) + ); + } +} diff --git a/src/context/package.rs b/src/context/package.rs new file mode 100644 index 0000000..b7f2257 --- /dev/null +++ b/src/context/package.rs @@ -0,0 +1,177 @@ +//! The package identity that puts JustTools in the Windows 11 context menu. +//! +//! Windows 11 only shows entries in its top-level menu for apps that have a +//! package identity. JustTools stays a portable folder, so it registers a +//! "sparse" package: a manifest plus logos that point back at the installed +//! folder as their external location. + +use std::fs; +use std::path::Path; + +use justtools_menu as menu; + +use crate::error::{ToolError, ToolResult}; + +pub const NAME: &str = "JustGains.JustTools"; +/// The subject used when no signing certificate dictates one. +pub const DEFAULT_PUBLISHER: &str = "CN=JustGains"; +pub const MANIFEST: &str = "AppxManifest.xml"; + +const LOGOS: [(&str, &[u8]); 3] = [ + ( + "logo-44.png", + include_bytes!("../../explorer/icons/logo-44.png"), + ), + ( + "logo-150.png", + include_bytes!("../../explorer/icons/logo-150.png"), + ), + ( + "logo-store.png", + include_bytes!("../../explorer/icons/logo-store.png"), + ), +]; + +fn architecture() -> &'static str { + if cfg!(target_arch = "aarch64") { + "arm64" + } else if cfg!(target_arch = "x86") { + "x86" + } else { + "x64" + } +} + +fn escape(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} + +/// Every Explorer item type the menu attaches to. +pub fn item_types() -> Vec<String> { + let mut types = vec!["Directory".to_owned(), r"Directory\Background".to_owned()]; + types.extend( + menu::extensions() + .into_iter() + .map(|extension| format!(".{extension}")), + ); + types +} + +/// The package manifest for this build, signed or registered as `publisher`. +pub fn manifest(publisher: &str) -> String { + let class = menu::CLASS_ID; + let version = format!("{}.0", env!("CARGO_PKG_VERSION")); + let verbs: String = item_types() + .iter() + .map(|kind| { + format!( + " <desktop5:ItemType Type=\"{}\">\n <desktop5:Verb Id=\"JustTools\" Clsid=\"{class}\" />\n </desktop5:ItemType>\n", + escape(kind) + ) + }) + .collect(); + format!( + r#"<?xml version="1.0" encoding="utf-8"?> +<Package + xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" + xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" + xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10" + xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" + xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4" + xmlns:desktop5="http://schemas.microsoft.com/appx/manifest/desktop/windows10/5" + xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10" + IgnorableNamespaces="uap uap10 rescap desktop4 desktop5 com"> + <Identity Name="{NAME}" Publisher="{publisher}" Version="{version}" ProcessorArchitecture="{architecture}" /> + <Properties> + <DisplayName>JustTools</DisplayName> + <PublisherDisplayName>JustGains</PublisherDisplayName> + <Logo>Assets\logo-store.png</Logo> + <uap10:AllowExternalContent>true</uap10:AllowExternalContent> + </Properties> + <Resources> + <Resource Language="en-us" /> + </Resources> + <Dependencies> + <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.22000.0" MaxVersionTested="10.0.26100.0" /> + </Dependencies> + <Capabilities> + <rescap:Capability Name="runFullTrust" /> + <rescap:Capability Name="unvirtualizedResources" /> + </Capabilities> + <Applications> + <Application Id="JustTools" Executable="just.exe" uap10:TrustLevel="mediumIL" uap10:RuntimeBehavior="win32App"> + <uap:VisualElements AppListEntry="none" DisplayName="JustTools" Description="JustTools File Explorer context menu" BackgroundColor="transparent" Square150x150Logo="Assets\logo-150.png" Square44x44Logo="Assets\logo-44.png" /> + <Extensions> + <desktop4:Extension Category="windows.fileExplorerContextMenus"> + <desktop4:FileExplorerContextMenus> +{verbs} </desktop4:FileExplorerContextMenus> + </desktop4:Extension> + <com:Extension Category="windows.comServer"> + <com:ComServer> + <com:SurrogateServer DisplayName="JustTools context menu"> + <com:Class Id="{class}" Path="{library}" ThreadingModel="STA" /> + </com:SurrogateServer> + </com:ComServer> + </com:Extension> + </Extensions> + </Application> + </Applications> +</Package> +"#, + publisher = escape(publisher), + architecture = architecture(), + library = menu::SHELL_LIBRARY, + ) +} + +/// Write the manifest and its logos as the layout `makeappx pack` and +/// `Add-AppxPackage -Register` both read. +pub fn write_layout(directory: &Path, publisher: &str) -> ToolResult { + let failed = |path: &Path, error: std::io::Error| { + ToolError::new("just", format!("cannot write {}: {error}", path.display())) + }; + let assets = directory.join("Assets"); + fs::create_dir_all(&assets).map_err(|error| failed(&assets, error))?; + for (name, bytes) in LOGOS { + let path = assets.join(name); + fs::write(&path, bytes).map_err(|error| failed(&path, error))?; + } + let path = directory.join(MANIFEST); + fs::write(&path, manifest(publisher)).map_err(|error| failed(&path, error)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manifest_registers_every_item_type_for_the_shared_class() { + let manifest = manifest("CN=Example & Co"); + assert!(manifest.contains(r#"Publisher="CN=Example & Co""#)); + assert!(manifest.contains(&format!(r#"Version="{}.0""#, env!("CARGO_PKG_VERSION")))); + assert!(manifest.contains(r#"<desktop5:ItemType Type=".mp4">"#)); + assert!(manifest.contains(r#"<desktop5:ItemType Type="Directory\Background">"#)); + assert!(!manifest.contains(r#"Type=".ts""#)); + assert_eq!( + manifest.matches(menu::CLASS_ID).count(), + item_types().len() + 1 + ); + assert!(manifest.contains(r#"Path="justtools_shell.dll""#)); + } + + #[test] + fn layout_holds_the_manifest_and_every_logo_it_names() { + let directory = tempfile::tempdir().unwrap(); + write_layout(directory.path(), DEFAULT_PUBLISHER).unwrap(); + let manifest = fs::read_to_string(directory.path().join(MANIFEST)).unwrap(); + for (name, _) in LOGOS { + assert!(manifest.contains(&format!(r"Assets\{name}"))); + let logo = fs::read(directory.path().join("Assets").join(name)).unwrap(); + assert!(logo.starts_with(b"\x89PNG")); + } + } +} diff --git a/src/context/register.rs b/src/context/register.rs new file mode 100644 index 0000000..90de095 --- /dev/null +++ b/src/context/register.rs @@ -0,0 +1,382 @@ +//! Adding and removing the File Explorer entry on Windows. +//! +//! Two registrations exist, and at most one is active: +//! +//! * **Package** — a sparse package identity, which is the only way into the +//! top-level Windows 11 menu. It needs either a signed `justtools-shell.msix` +//! beside `just.exe` or Developer Mode for an unsigned registration. +//! * **Classic** — per-user registry verbs, which need nothing but appear +//! under "Show more options" on Windows 11. +//! +//! Both are per-user, need no elevation, and are fully undone by `uninstall`. + +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use justtools_menu as menu; +use winreg::RegKey; +use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; + +use super::package; +use crate::error::{ToolError, ToolResult}; + +const VERB: &str = "JustTools"; +const CLASSES: &str = r"Software\Classes"; +/// The signed package a release ships beside `just.exe`. +const SIGNED_PACKAGE: &str = "justtools-shell.msix"; +/// Where an unsigned Developer Mode registration keeps its manifest. +const LAYOUT_DIRECTORY: &str = "context-menu"; +const WINDOWS_11_BUILD: u32 = 22_000; +/// Where the folder that owns the registration is remembered, so another +/// copy of JustTools never refreshes or removes a menu it did not install. +const STATE_KEY: &str = r"Software\JustGains\JustTools"; +const STATE_VALUE: &str = "ContextMenuDirectory"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Tier { + Package, + Classic, +} + +pub struct Status { + /// The registered package version, when the package is present. + pub package: Option<String>, + pub classic: bool, + /// The JustTools folder the active registration points at. + pub owner: Option<PathBuf>, + /// Whether that folder is the one this executable runs from. + pub owned: bool, + pub library: PathBuf, + pub windows_11: bool, + pub developer_mode: bool, + pub signed_package: bool, +} + +fn error(message: impl Into<String>) -> ToolError { + ToolError::new("just", message) +} + +fn class_key() -> String { + format!(r"{CLASSES}\CLSID\{{{}}}", menu::CLASS_ID) +} + +fn verb_parent(item_type: &str) -> String { + if item_type.starts_with('.') { + format!(r"{CLASSES}\SystemFileAssociations\{item_type}\shell") + } else { + format!(r"{CLASSES}\{item_type}\shell") + } +} + +fn bin_directory() -> ToolResult<PathBuf> { + let executable = std::env::current_exe() + .map_err(|error| self::error(format!("cannot locate just.exe: {error}")))?; + executable + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| error("cannot locate the JustTools folder")) +} + +fn windows_build() -> u32 { + RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion") + .and_then(|key| key.get_value::<String, _>("CurrentBuildNumber")) + .ok() + .and_then(|build| build.parse().ok()) + .unwrap_or(0) +} + +fn developer_mode() -> bool { + RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey(r"SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock") + .and_then(|key| key.get_value::<u32, _>("AllowDevelopmentWithoutDevLicense")) + .is_ok_and(|value| value == 1) +} + +fn quote(path: &Path) -> String { + format!("'{}'", path.to_string_lossy().replace('\'', "''")) +} + +/// Run a Windows PowerShell script, returning its trimmed output or its error. +/// +/// Windows PowerShell 5.1 ships with every supported Windows and carries the +/// Appx cmdlets; PowerShell 7 only reaches them through a compatibility shim. +fn powershell(script: &str) -> Result<String, String> { + let root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into()); + let shell = Path::new(&root).join(r"System32\WindowsPowerShell\v1.0\powershell.exe"); + let output = Command::new(shell) + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + ]) + .arg(format!( + "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; {script}" + )) + .output() + .map_err(|error| format!("cannot run Windows PowerShell: {error}"))?; + let text = |bytes: &[u8]| String::from_utf8_lossy(bytes).trim().to_owned(); + if output.status.success() { + Ok(text(&output.stdout)) + } else { + let message = text(&output.stderr); + // The first line names the failure; the rest is PowerShell's position + // and category detail. + Err(message + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("Windows PowerShell reported a failure") + .to_owned()) + } +} + +fn package_version() -> Option<String> { + powershell(&format!( + "(Get-AppxPackage -Name '{}').Version", + package::NAME + )) + .ok() + .filter(|version| !version.is_empty()) +} + +fn classic_registered() -> bool { + RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(class_key()) + .is_ok() +} + +fn owner() -> Option<PathBuf> { + RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(STATE_KEY) + .and_then(|key| key.get_value::<String, _>(STATE_VALUE)) + .ok() + .filter(|directory| !directory.is_empty()) + .map(PathBuf::from) +} + +fn remember_owner(bin: &Path) -> std::io::Result<()> { + let (key, _) = RegKey::predef(HKEY_CURRENT_USER).create_subkey(STATE_KEY)?; + key.set_value(STATE_VALUE, &bin.to_string_lossy().as_ref()) +} + +fn forget_owner() -> std::io::Result<()> { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + if let Ok(key) = hkcu.open_subkey_with_flags(STATE_KEY, winreg::enums::KEY_SET_VALUE) { + ignore_missing(key.delete_value(STATE_VALUE))?; + } + Ok(()) +} + +/// Whether the registration, if any, belongs to this copy of JustTools. +/// `None` means this user has no JustTools menu at all. Unlike [`status`], +/// this reads only the registry, so it is cheap enough for every install. +pub fn ownership() -> ToolResult<Option<bool>> { + let bin = bin_directory()?; + Ok(owner().map(|owner| crate::common::same_path(&owner, &bin))) +} + +pub fn library_present() -> bool { + bin_directory().is_ok_and(|bin| bin.join(menu::SHELL_LIBRARY).is_file()) +} + +pub fn status() -> ToolResult<Status> { + let bin = bin_directory()?; + let owner = owner(); + Ok(Status { + package: package_version(), + classic: classic_registered(), + owned: owner + .as_deref() + .is_some_and(|owner| crate::common::same_path(owner, &bin)), + owner, + library: bin.join(menu::SHELL_LIBRARY), + windows_11: windows_build() >= WINDOWS_11_BUILD, + developer_mode: developer_mode(), + signed_package: bin.join(SIGNED_PACKAGE).is_file(), + }) +} + +fn notify_shell() { + use windows_sys::Win32::UI::Shell::{SHCNE_ASSOCCHANGED, SHCNF_IDLIST, SHChangeNotify}; + // Explorer caches verbs per file type until it is told they changed. + unsafe { + SHChangeNotify( + SHCNE_ASSOCCHANGED as i32, + SHCNF_IDLIST, + std::ptr::null(), + std::ptr::null(), + ); + } +} + +fn remove_package() -> Result<(), String> { + powershell(&format!( + "Get-AppxPackage -Name '{}' | Remove-AppxPackage", + package::NAME + )) + .map(drop) +} + +fn register_package(bin: &Path) -> Result<(), String> { + // A signed package and a Developer Mode registration cannot replace each + // other in place, so any earlier registration is removed first. + remove_package()?; + let signed = bin.join(SIGNED_PACKAGE); + if signed.is_file() { + return powershell(&format!( + "Add-AppxPackage -Path {} -ExternalLocation {}", + quote(&signed), + quote(bin) + )) + .map(drop); + } + let layout = bin.join(LAYOUT_DIRECTORY); + package::write_layout(&layout, package::DEFAULT_PUBLISHER) + .map_err(|error| error.message().to_owned())?; + powershell(&format!( + "Add-AppxPackage -Register {} -ExternalLocation {}", + quote(&layout.join(package::MANIFEST)), + quote(bin) + )) + .map(drop) +} + +fn register_classic(library: &Path) -> std::io::Result<()> { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let class_id = format!("{{{}}}", menu::CLASS_ID); + let (class, _) = hkcu.create_subkey(class_key())?; + class.set_value("", &"JustTools context menu")?; + let (server, _) = class.create_subkey("InprocServer32")?; + server.set_value("", &library.to_string_lossy().as_ref())?; + server.set_value("ThreadingModel", &"Apartment")?; + let icon = format!("{},-{}", library.display(), menu::Icon::Brand.resource_id()); + for item_type in package::item_types() { + let (verb, _) = hkcu.create_subkey(format!(r"{}\{VERB}", verb_parent(&item_type)))?; + verb.set_value("", &menu::ROOT_TITLE)?; + verb.set_value("MUIVerb", &menu::ROOT_TITLE)?; + verb.set_value("Icon", &icon)?; + verb.set_value("ExplorerCommandHandler", &class_id)?; + } + Ok(()) +} + +fn ignore_missing(result: std::io::Result<()>) -> std::io::Result<()> { + match result { + Err(error) if error.kind() != ErrorKind::NotFound => Err(error), + _ => Ok(()), + } +} + +/// Remove one verb and any parent keys it was the only reason for. +fn remove_verb(hkcu: &RegKey, parent: &str) -> std::io::Result<()> { + ignore_missing(hkcu.delete_subkey_all(format!(r"{parent}\{VERB}")))?; + // Only keys left with nothing in them go, so other software's verbs and + // values stay exactly where they were. + let unused = |key: &str| { + hkcu.open_subkey(key) + .and_then(|key| key.query_info()) + .is_ok_and(|info| info.sub_keys == 0 && info.values == 0) + }; + let mut key = parent; + while key.len() > CLASSES.len() && unused(key) && hkcu.delete_subkey(key).is_ok() { + match key.rsplit_once('\\') { + Some((up, _)) if !up.ends_with("SystemFileAssociations") => key = up, + _ => break, + } + } + Ok(()) +} + +fn remove_classic() -> std::io::Result<()> { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let mut parents: Vec<String> = package::item_types() + .iter() + .map(|item_type| verb_parent(item_type)) + .collect(); + // An older build may have registered types this one no longer lists. + let associations = format!(r"{CLASSES}\SystemFileAssociations"); + if let Ok(key) = hkcu.open_subkey(&associations) { + parents.extend( + key.enum_keys() + .filter_map(Result::ok) + .map(|name| format!(r"{associations}\{name}\shell")), + ); + } + parents.sort_unstable(); + parents.dedup(); + let class_id = format!("{{{}}}", menu::CLASS_ID); + for parent in parents { + let ours = hkcu + .open_subkey(format!(r"{parent}\{VERB}")) + .and_then(|verb| verb.get_value::<String, _>("ExplorerCommandHandler")) + .is_ok_and(|handler| handler.eq_ignore_ascii_case(&class_id)); + if ours { + remove_verb(&hkcu, &parent)?; + } + } + ignore_missing(hkcu.delete_subkey_all(class_key())) +} + +/// Register the menu and report which tier ended up active, with the reason +/// the package tier was not used when it was not. +pub fn install(classic_only: bool) -> ToolResult<(Tier, Option<String>)> { + let bin = bin_directory()?; + let library = bin.join(menu::SHELL_LIBRARY); + if !library.is_file() { + return Err(error(format!( + "{} is not beside just.exe in {}; it ships in the Windows release archive and is copied by `just install`", + menu::SHELL_LIBRARY, + bin.display() + ))); + } + let registry = + |error: std::io::Error| self::error(format!("cannot update the registry: {error}")); + let reason = if classic_only { + None + } else if windows_build() < WINDOWS_11_BUILD { + Some("this is Windows 10, which has a single context menu".to_owned()) + } else if !bin.join(SIGNED_PACKAGE).is_file() && !developer_mode() { + Some(format!( + "the top-level Windows 11 menu needs a signed {SIGNED_PACKAGE} beside just.exe or Developer Mode" + )) + } else { + match register_package(&bin) { + Ok(()) => { + // Windows also lists packaged verbs under "Show more + // options", so the classic verbs would only duplicate them. + remove_classic().map_err(registry)?; + remember_owner(&bin).map_err(registry)?; + notify_shell(); + return Ok((Tier::Package, None)); + } + Err(message) => Some(format!("Windows declined the package: {message}")), + } + }; + if classic_only { + remove_package().map_err(error)?; + } + remove_classic().map_err(registry)?; + register_classic(&library).map_err(registry)?; + remember_owner(&bin).map_err(registry)?; + notify_shell(); + Ok((Tier::Classic, reason)) +} + +pub fn uninstall() -> ToolResult { + let bin = bin_directory()?; + remove_package().map_err(|message| error(format!("cannot remove the package: {message}")))?; + remove_classic() + .and_then(|()| forget_owner()) + .map_err(|error| self::error(format!("cannot update the registry: {error}")))?; + let layout = bin.join(LAYOUT_DIRECTORY); + if layout.join(package::MANIFEST).is_file() { + std::fs::remove_dir_all(&layout) + .map_err(|error| self::error(format!("cannot remove {}: {error}", layout.display())))?; + } + notify_shell(); + Ok(()) +} diff --git a/src/deps.rs b/src/deps.rs index 9367f1e..071f9fb 100644 --- a/src/deps.rs +++ b/src/deps.rs @@ -101,6 +101,7 @@ enum Dependency { Pngquant, Cwebp, Git, + YtDlp, } impl Dependency { @@ -110,6 +111,7 @@ impl Dependency { "pngquant" => Some(Self::Pngquant), "cwebp" => Some(Self::Cwebp), "git" => Some(Self::Git), + "yt-dlp" => Some(Self::YtDlp), _ => None, } } @@ -120,6 +122,7 @@ impl Dependency { Self::Pngquant => "pngquant", Self::Cwebp => "cwebp", Self::Git => "Git", + Self::YtDlp => "yt-dlp", } } @@ -129,6 +132,7 @@ impl Dependency { Self::Pngquant => &["pngquant"], Self::Cwebp => &["cwebp"], Self::Git => &["git"], + Self::YtDlp => &["yt-dlp"], } } } @@ -344,7 +348,8 @@ struct SystemRunner; impl DependencyRunner for SystemRunner { fn find(&mut self, program: &str) -> Option<PathBuf> { - find_executable(program) + // A copy on PATH always wins over the one JustTools downloaded. + find_executable(program).or_else(|| crate::managed::find(program)) } fn run(&mut self, command: &CommandSpec) -> io::Result<Option<i32>> { @@ -394,7 +399,13 @@ enum RequestPolicy { ResolveOnly(String), } -const DEPENDENCY_OVERRIDES: [&str; 4] = ["FFMPEG_BIN", "PNGQUANT_BIN", "CWEBP_BIN", "GIT_BIN"]; +const DEPENDENCY_OVERRIDES: [&str; 5] = [ + "FFMPEG_BIN", + "PNGQUANT_BIN", + "CWEBP_BIN", + "GIT_BIN", + "YTDLP_BIN", +]; fn request_policy(requested: &str) -> RequestPolicy { request_policy_with(requested, |name| env::var_os(name)) @@ -470,6 +481,7 @@ fn windows_plan<R: DependencyRunner>( Dependency::Ffmpeg => "Gyan.FFmpeg", Dependency::Cwebp => "Google.Libwebp", Dependency::Git => "Git.Git", + Dependency::YtDlp => "yt-dlp.yt-dlp", Dependency::Pngquant => unreachable!(), }; let winget = runner @@ -614,18 +626,22 @@ fn package_for(installer: Installer, dependency: Dependency) -> &'static str { (Installer::Homebrew, Dependency::Pngquant) => "pngquant", (Installer::Homebrew, Dependency::Cwebp) => "webp", (Installer::Homebrew, Dependency::Git) => "git", + (Installer::Homebrew, Dependency::YtDlp) => "yt-dlp", (Installer::Apt, Dependency::Ffmpeg) => "ffmpeg", (Installer::Apt, Dependency::Pngquant) => "pngquant", (Installer::Apt, Dependency::Cwebp) => "webp", (Installer::Apt, Dependency::Git) => "git", + (Installer::Apt, Dependency::YtDlp) => "yt-dlp", (Installer::Dnf, Dependency::Ffmpeg) => "ffmpeg-free", (Installer::Dnf, Dependency::Pngquant) => "pngquant", (Installer::Dnf, Dependency::Cwebp) => "libwebp-tools", (Installer::Dnf, Dependency::Git) => "git", + (Installer::Dnf, Dependency::YtDlp) => "yt-dlp", (Installer::Pacman, Dependency::Ffmpeg) => "ffmpeg", (Installer::Pacman, Dependency::Pngquant) => "pngquant", (Installer::Pacman, Dependency::Cwebp) => "libwebp-utils", (Installer::Pacman, Dependency::Git) => "git", + (Installer::Pacman, Dependency::YtDlp) => "yt-dlp", _ => unreachable!("package requested for an installer without package names"), } } @@ -701,8 +717,78 @@ fn ensure_dependency<R: DependencyRunner, I: DependencyInteraction>( } } -/// Resolve a dependency, offering a confirmed native installation only for the -/// canonical names `ffmpeg`, `pngquant`, `cwebp`, and `git`. +/// Resolve a dependency, honoring an explicit `*_BIN` override before falling +/// back to the standard executable name. +/// +/// An override that names an existing file is used as-is; any other value is +/// resolved on `PATH` without offering an installation. +pub fn require_override(tool: &str, variable: &str, standard: &str) -> ToolResult<PathBuf> { + let Some(requested) = env::var_os(variable).filter(|value| !value.is_empty()) else { + return require(tool, standard); + }; + let path = PathBuf::from(&requested); + if path.is_file() { + return Ok(path); + } + let text = requested.to_str().ok_or_else(|| { + ToolError::new( + tool, + format!( + "{variable} points to a missing non-UTF-8 path: {}", + path.to_string_lossy() + ), + ) + })?; + require(tool, text) +} + +/// Find yt-dlp wherever it already is, without installing or downloading it. +pub fn installed_yt_dlp() -> Option<PathBuf> { + match env::var_os("YTDLP_BIN").filter(|value| !value.is_empty()) { + Some(explicit) => { + let path = PathBuf::from(&explicit); + if path.is_file() { + Some(path) + } else { + explicit.to_str().and_then(find_executable) + } + } + None => find_executable("yt-dlp").or_else(|| crate::managed::find("yt-dlp")), + } +} + +/// Download yt-dlp or FFmpeg into the per-user folder when it is missing. +/// +/// These two are fetched without asking: both ship as self-contained vendor +/// builds, nothing outside JustTools' own folder changes, and every download is +/// verified. A failure is reported and resolution falls back to the platform +/// package manager, which still asks first. +fn download_if_missing<R: DependencyRunner>( + tool: &str, + requested: &str, + dependency: Dependency, + runner: &mut R, +) { + let program = match dependency { + Dependency::Ffmpeg => crate::managed::Program::Ffmpeg, + Dependency::YtDlp => crate::managed::Program::YtDlp, + _ => return, + }; + if !crate::managed::enabled() + || inspect_dependency(dependency, requested, runner) + .1 + .is_empty() + { + return; + } + if let Err(message) = crate::managed::fetch(tool, program) { + eprintln!("{tool}: automatic download failed: {message}"); + } +} + +/// Resolve a dependency. Missing yt-dlp and FFmpeg are downloaded +/// automatically; otherwise a confirmed native installation is offered for the +/// canonical names `ffmpeg`, `pngquant`, `cwebp`, `git`, and `yt-dlp`. /// /// Explicit paths, custom executable names, and values supplied through a /// `*_BIN` override are resolve-only and never launch an installer. @@ -723,6 +809,7 @@ pub fn require(tool: &str, requested: &str) -> ToolResult<PathBuf> { let RequestPolicy::Install(dependency) = policy else { unreachable!(); }; + download_if_missing(tool, requested, dependency, &mut runner); let platform = Platform::current().map_err(|error| error.into_tool_error(tool))?; let mut interaction = SystemInteraction::new(); match ensure_dependency( @@ -968,6 +1055,13 @@ mod tests { (Platform::Windows, Dependency::Ffmpeg, "Gyan.FFmpeg"), (Platform::Windows, Dependency::Cwebp, "Google.Libwebp"), (Platform::Windows, Dependency::Git, "Git.Git"), + (Platform::Windows, Dependency::YtDlp, "yt-dlp.yt-dlp"), + (Platform::MacOs, Dependency::YtDlp, "brew install yt-dlp"), + ( + Platform::Linux, + Dependency::YtDlp, + "apt-get install -y yt-dlp", + ), (Platform::MacOs, Dependency::Cwebp, "brew install webp"), ( Platform::Linux, diff --git a/src/install.rs b/src/install.rs index 0d52e9d..4306fcb 100644 --- a/src/install.rs +++ b/src/install.rs @@ -51,7 +51,8 @@ replaced. On Windows, also installs PowerShell launchers for mkcd, justmkcd, just, claude_, and codex_. Shell integration loads automatically; no init or profile -edit is needed. +edit is needed. The File Explorer context-menu extension is installed when it +sits beside the executable; `just context install` then adds the menu. Options: --bin-dir DIR Installation directory @@ -147,6 +148,25 @@ fn native_names() -> Vec<String> { command_names().into_iter().map(executable_name).collect() } +/// Files a Windows release ships beside `just.exe` for the File Explorer +/// context menu: the shell extension and, when the release is signed, its +/// package. Either may be absent, so they are installed only when present. +const COMPANION_FILES: [&str; 2] = [justtools_menu::SHELL_LIBRARY, "justtools-shell.msix"]; + +fn companion_files(source: &Path) -> Vec<(&'static str, PathBuf)> { + if !cfg!(windows) { + return Vec::new(); + } + let Some(directory) = source.parent() else { + return Vec::new(); + }; + COMPANION_FILES + .into_iter() + .map(|name| (name, directory.join(name))) + .filter(|(_, path)| path.is_file()) + .collect() +} + fn installed_names() -> Vec<String> { let mut names = native_names(); names.extend( @@ -157,6 +177,18 @@ fn installed_names() -> Vec<String> { names } +/// Everything this installation writes: the fixed names plus the companions +/// found beside `source`. +fn installed_names_from(source: &Path) -> Vec<String> { + let mut names = installed_names(); + names.extend( + companion_files(source) + .into_iter() + .map(|(name, _)| name.to_owned()), + ); + names +} + fn legacy_candidate_names() -> BTreeSet<String> { let mut names = BTreeSet::new(); for command in command_names() { @@ -248,9 +280,9 @@ fn files_identical(left: &Path, right: &Path) -> bool { } } -fn manifest_contents() -> String { +fn manifest_contents(source: &Path) -> String { let mut contents = format!("{MANIFEST_HEADER}\n"); - for name in installed_names() { + for name in installed_names_from(source) { contents.push_str(&name); contents.push('\n'); } @@ -258,7 +290,12 @@ fn manifest_contents() -> String { } fn discover_managed_files(source: &Path, bin: &Path) -> ToolResult<Vec<PathBuf>> { - let allowed_names: HashSet<_> = installed_names().into_iter().collect(); + // Companions are always recognized, so an upgrade from a build that had + // them to one that lacks them still moves the old ones aside. + let allowed_names: HashSet<_> = installed_names() + .into_iter() + .chain(COMPANION_FILES.map(str::to_owned)) + .collect(); let manifest = bin.join(MANIFEST_NAME); let mut managed = legacy_files(bin); @@ -290,7 +327,12 @@ fn discover_managed_files(source: &Path, bin: &Path) -> ToolResult<Vec<PathBuf>> } for name in lines.filter(|name| allowed_names.contains(*name)) { let path = bin.join(name); - if path.is_file() && !common::same_path(source, &path) { + if path.is_file() + && !common::same_path(source, &path) + && !companion_files(source) + .iter() + .any(|(_, companion)| common::same_path(companion, &path)) + { managed.push(path); } } @@ -315,11 +357,15 @@ fn discover_managed_files(source: &Path, bin: &Path) -> ToolResult<Vec<PathBuf>> } let managed_keys: HashSet<_> = managed.iter().map(|path| path_key(path)).collect(); - for name in &allowed_names { + let incoming: HashSet<_> = installed_names_from(source).into_iter().collect(); + for name in &incoming { let path = bin.join(name); if path.exists() && !managed_keys.contains(&path_key(&path)) && !common::same_path(source, &path) + && !companion_files(source) + .iter() + .any(|(_, companion)| common::same_path(companion, &path)) { return Err(ToolError::new( "just", @@ -462,7 +508,18 @@ fn stage_installation(source: &Path, bin: &Path) -> ToolResult<tempfile::TempDir ) })?; } - fs::write(stage.path().join(MANIFEST_NAME), manifest_contents()) + for (name, companion) in companion_files(source) { + if common::same_path(&companion, &bin.join(name)) { + continue; + } + fs::copy(&companion, stage.path().join(name)).map_err(|error| { + ToolError::new( + "just", + format!("cannot stage {}: {error}", companion.display()), + ) + })?; + } + fs::write(stage.path().join(MANIFEST_NAME), manifest_contents(source)) .map_err(|error| ToolError::new("just", format!("cannot stage manifest: {error}")))?; Ok(stage) } @@ -514,14 +571,19 @@ fn install_transaction( } let main_name = executable_name("just"); - let mut targets = installed_names(); + let mut targets = installed_names_from(source); targets.retain(|name| name != &main_name); targets.insert(0, main_name.clone()); targets.push(MANIFEST_NAME.to_owned()); let mut committed = Vec::new(); for name in targets { let destination = bin.join(&name); - if name == main_name && common::same_path(source, &destination) { + if common::same_path(source, &destination) + || companion_files(source) + .iter() + .any(|(_, companion)| common::same_path(companion, &destination)) + { + // Reinstalling from the destination leaves the running files alone. continue; } if fail_after_for_test.is_some_and(|limit| committed.len() >= limit) { @@ -592,6 +654,17 @@ pub fn run(args: Vec<OsString>) -> ToolResult { "just: enable shell helpers with just init <powershell|bash|zsh|fish>; see docs/mkcd.md" ); } + #[cfg(windows)] + { + // The registration belongs to the installed copy, which also knows + // whether a menu is registered at all. + let refreshed = std::process::Command::new(bin.join(executable_name("just"))) + .args(["context", "refresh"]) + .status(); + if !refreshed.is_ok_and(|status| status.success()) { + eprintln!("just: run `just context install` to refresh the File Explorer menu"); + } + } if options.add_path && !crate::pathing::contains(&bin) { let add = options.yes || common::confirm("just", &format!("just: add {} to PATH", bin.display()))?; @@ -609,7 +682,11 @@ mod tests { use super::*; fn write_manifest(bin: &Path) { - fs::write(bin.join(MANIFEST_NAME), manifest_contents()).unwrap(); + fs::write( + bin.join(MANIFEST_NAME), + manifest_contents(Path::new("downloaded")), + ) + .unwrap(); } #[test] @@ -701,6 +778,39 @@ mod tests { assert!(bin.join(MANIFEST_NAME).is_file()); } + #[test] + #[cfg(windows)] + fn context_menu_companions_install_and_are_retired_with_the_build_that_had_them() { + let directory = tempfile::tempdir().unwrap(); + let release = directory.path().join("release"); + let bin = directory.path().join("bin"); + fs::create_dir(&release).unwrap(); + fs::create_dir(&bin).unwrap(); + let source = release.join("just.exe"); + fs::write(&source, b"native suite").unwrap(); + fs::write(release.join(justtools_menu::SHELL_LIBRARY), b"extension").unwrap(); + + let managed = discover_managed_files(&source, &bin).unwrap(); + install_transaction(&source, &bin, &managed, None).unwrap(); + let library = bin.join(justtools_menu::SHELL_LIBRARY); + assert_eq!(fs::read(&library).unwrap(), b"extension"); + assert!( + fs::read_to_string(bin.join(MANIFEST_NAME)) + .unwrap() + .contains(justtools_menu::SHELL_LIBRARY) + ); + + // A later build without the extension must not leave a stale one + // serving a menu the new executable no longer matches. + let plain = directory.path().join("plain.exe"); + fs::write(&plain, b"newer suite").unwrap(); + let managed = discover_managed_files(&plain, &bin).unwrap(); + assert!(managed.iter().any(|path| common::same_path(path, &library))); + install_transaction(&plain, &bin, &managed, None).unwrap(); + assert!(!library.exists()); + assert_eq!(fs::read(bin.join("just.exe")).unwrap(), b"newer suite"); + } + #[test] fn reinstall_from_destination_never_moves_running_main() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/launcher.rs b/src/launcher.rs index 4702083..d1601e7 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -95,12 +95,27 @@ pub fn supports(command: &str) -> bool { } pub fn run(command: &str) -> ToolResult<Option<Vec<OsString>>> { + run_with_input(command, "") +} + +/// Open the launcher with its input row already filled in, as the File +/// Explorer context menu does for the selected files. The row stays editable +/// and, like every input, is never saved. +pub fn run_with_input(command: &str, input: &str) -> ToolResult<Option<Vec<OsString>>> { let tool = spec(command) .ok_or_else(|| ToolError::usage("just", format!("unknown tool: {command}")))?; let store = preferences::Store::load().map_err(|error| { ToolError::new(command, format!("could not load saved defaults: {error:#}")) })?; let mut app = App::new(tool, store); + if !input.is_empty() + && let Some(entry) = app + .values + .iter_mut() + .find(|entry| matches!(entry.field.kind, Kind::Input { .. })) + { + entry.value = input.to_owned(); + } ratatui::run(|terminal| app.run(terminal)) .map_err(|error| ToolError::new(command, format!("terminal UI failed: {error}")))?; if !app.run { @@ -544,6 +559,31 @@ impl App { format!("{}/{}", absolute.display(), pattern) } + fn has_url(&self) -> bool { + split_inputs(self.value("input")).iter().any(|value| { + let lower = value.trim().to_ascii_lowercase(); + lower.starts_with("http://") || lower.starts_with("https://") + }) + } + + /// Downloads have no source folder, so they land in --output or the + /// current folder under the title yt-dlp saved. + fn download_note(&self, pattern: &str) -> String { + if !self.has_url() { + return String::new(); + } + let output = self.value("output").trim().to_owned(); + let directory = if output.is_empty() { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + } else { + absolute_path(&output) + }; + format!( + " | URL downloads -> {}/{pattern} (yt-dlp)", + directory.display() + ) + } + fn output_policy(&self) -> (String, String) { let separate_output = !self.value("output").trim().is_empty(); match self.tool.name { @@ -676,14 +716,23 @@ impl App { .into(), ) } - "justvideo" => ( + "justpaste" => ( format!( "Output: {}", + self.destination("<the name the link's server gives the file>") + .replace("beside each source as", "the current folder as") + ), + "Overwrite: never; a taken name is saved as \"name (2).ext\".".into(), + ), + "justvideo" => ( + format!( + "Output: {}{}", self.destination(if self.enabled("replace") { "<name>.mp4" } else { "<name>-web.mp4" - }) + }), + self.download_note("<title> [<id>].mp4") ), if self.enabled("replace") && !separate_output { "Overwrite: source replaced/removed only after the MP4 is safely installed." @@ -700,8 +749,9 @@ impl App { }; ( format!( - "Output: {}", - self.destination(&format!("<name>.{extension}")) + "Output: {}{}", + self.destination(&format!("<name>.{extension}")), + self.download_note(&format!("<title> [<id>].{extension}")) ), if self.enabled("replace") && !separate_output { "Overwrite: source replaced/removed only after safe output installation." @@ -1011,7 +1061,7 @@ fn split_inputs(value: &str) -> Vec<String> { result } -fn quote(value: &str) -> String { +pub(crate) fn quote(value: &str) -> String { if !value.is_empty() && value .chars() @@ -1295,12 +1345,26 @@ fn media_spec(name: &'static str) -> Tool { "Create streaming-ready video", vec![ input( - "Input files / folders", - "Use semicolons between multiple inputs. Blank processes the current folder.", + "Input files / folders / URLs", + "Use semicolons between multiple inputs. An http(s) URL downloads with yt-dlp. Blank processes the current folder.", true, Some("."), false, ), + choices( + "resolution", + "Max resolution", + "Largest output frame. Smaller sources are never upscaled.", + "720", + vec![ + choice("480p", "480", &["--resolution", "480p"]), + choice("720p", "720", &[]), + choice("1080p", "1080", &["--resolution", "1080p"]), + choice("1440p", "1440", &["--resolution", "1440p"]), + choice("4K (2160p)", "2160", &["--resolution", "4k"]), + choice("Source", "source", &["--resolution", "source"]), + ], + ), number( "crf", "Video CRF", @@ -1334,6 +1398,13 @@ fn media_spec(name: &'static str) -> Tool { "--replace", true, ), + toggle( + "playlist", + "Download playlists", + "Download every entry when an input URL is a playlist.", + "--playlist", + true, + ), ], jobs(2), ), @@ -1342,8 +1413,8 @@ fn media_spec(name: &'static str) -> Tool { "Create compact AAC audio", vec![ input( - "Input files / folders", - "Use semicolons between multiple inputs. Blank processes the current folder.", + "Input files / folders / URLs", + "Use semicolons between multiple inputs. An http(s) URL downloads with yt-dlp. Blank processes the current folder.", true, Some("."), false, @@ -1380,6 +1451,13 @@ fn media_spec(name: &'static str) -> Tool { "--replace", true, ), + toggle( + "playlist", + "Download playlists", + "Download every entry when an input URL is a playlist.", + "--playlist", + true, + ), ], jobs(2), ), @@ -1388,8 +1466,8 @@ fn media_spec(name: &'static str) -> Tool { "Create high-quality MP3 audio", vec![ input( - "Input files / folders", - "Use semicolons between multiple inputs. Blank processes the current folder.", + "Input files / folders / URLs", + "Use semicolons between multiple inputs. An http(s) URL downloads with yt-dlp. Blank processes the current folder.", true, Some("."), false, @@ -1428,6 +1506,13 @@ fn media_spec(name: &'static str) -> Tool { "--replace", true, ), + toggle( + "playlist", + "Download playlists", + "Download every entry when an input URL is a playlist.", + "--playlist", + true, + ), ], jobs(2), ), @@ -1436,8 +1521,8 @@ fn media_spec(name: &'static str) -> Tool { "Create editing-ready WAV audio", vec![ input( - "Input files / folders", - "Use semicolons between multiple inputs. Blank processes the current folder.", + "Input files / folders / URLs", + "Use semicolons between multiple inputs. An http(s) URL downloads with yt-dlp. Blank processes the current folder.", true, Some("."), false, @@ -1476,6 +1561,13 @@ fn media_spec(name: &'static str) -> Tool { "--replace", true, ), + toggle( + "playlist", + "Download playlists", + "Download every entry when an input URL is a playlist.", + "--playlist", + true, + ), ], jobs(2), ), @@ -1964,6 +2056,81 @@ fn spec(name: &str) -> Option<Tool> { ), ], }, + "justpaste" => Tool { + name: "justpaste", + title: "JustPaste", + summary: "Download whatever a link points at", + fields: vec![ + input( + "Links", + "Use semicolons between several links. Blank pastes the links in the clipboard.", + true, + Some("--clipboard"), + false, + ), + text( + "output", + "Output folder", + "Optional destination folder. Blank saves into the current folder.", + "", + "--output", + true, + ), + toggle( + "playlist", + "Download playlists", + "Download every entry when a link is a playlist.", + "--playlist", + true, + ), + toggle( + "dry_run", + "Dry run", + "List the links without any network access. This one-run safety action is not saved.", + "--dry-run", + false, + ), + ], + }, + "justip" => Tool { + name: "justip", + title: "JustIP", + summary: "Show the public IP address this machine presents", + fields: vec![ + choices( + "family", + "Address family", + "Both reports IPv4 and IPv6 together; either one narrows the lookup.", + "both", + vec![ + choice("Both", "both", &[]), + choice("IPv4 only", "ipv4", &["--ipv4"]), + choice("IPv6 only", "ipv6", &["--ipv6"]), + ], + ), + choices( + "format", + "Output format", + "Labeled reads best; plain and JSON are for scripts and pipelines.", + "labeled", + vec![ + choice("Labeled", "labeled", &[]), + choice("Plain addresses", "plain", &["--plain"]), + choice("JSON", "json", &["--json"]), + ], + ), + number( + "timeout", + "Timeout (seconds)", + "How long each family's lookup may take before it is reported unavailable.", + 5, + "--timeout", + 1, + 60, + 1, + ), + ], + }, "justport" => Tool { name: "justport", title: "JustPort", @@ -2344,12 +2511,56 @@ mod tests { } } + #[test] + fn ip_launcher_defaults_to_both_families_and_narrows_on_request() { + let mut app = app_for("justip"); + // Reporting both families is the default, so the bare command carries + // no switch at all. + assert_eq!(app.command(), "justip"); + for (id, value) in [("family", "ipv6"), ("format", "plain"), ("timeout", "20")] { + let entry = app + .values + .iter_mut() + .find(|entry| entry.field.id == id) + .unwrap(); + assert!(entry.field.persistent, "{id} should be remembered"); + entry.value = value.into(); + } + assert_eq!(app.command(), "justip --ipv6 --plain --timeout 20"); + } + #[test] fn default_jpg_command_is_explicitly_headless() { let app = app_for("justjpg"); assert_eq!(app.command(), "justjpg ."); } + #[test] + fn media_launcher_forwards_urls_and_shows_the_download_destination() { + let mut app = app_for("justaudio"); + app.values + .iter_mut() + .find(|value| value.field.id == "input") + .unwrap() + .value = "https://example.test/watch?v=1".into(); + let playlist = app + .values + .iter_mut() + .find(|value| value.field.id == "playlist") + .unwrap(); + assert!(playlist.field.persistent); + playlist.value = "true".into(); + let command = app.command(); + assert!( + command.contains("https://example.test/watch?v=1"), + "{command}" + ); + assert!(command.contains("--playlist"), "{command}"); + let (destination, _) = app.output_policy(); + assert!(destination.contains("<title> [<id>].m4a"), "{destination}"); + assert!(destination.contains("yt-dlp"), "{destination}"); + } + #[test] fn mkcd_launcher_protects_literal_directory_and_never_saves_it() { let mut app = app_for("justmkcd"); diff --git a/src/main.rs b/src/main.rs index e30cadc..1c65cc2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,12 @@ mod commands; mod common; mod console_ui; +mod context; mod deps; mod error; mod install; mod launcher; +mod managed; mod pathing; mod preferences; mod selector; diff --git a/src/managed.rs b/src/managed.rs new file mode 100644 index 0000000..876e23d --- /dev/null +++ b/src/managed.rs @@ -0,0 +1,362 @@ +//! Private copies of yt-dlp and FFmpeg for machines that have neither. +//! +//! When one of them is not on `PATH`, JustTools downloads the vendor's own +//! build into a per-user folder and uses it from there. Nothing is installed +//! system-wide, `PATH` is not edited, and a copy already on `PATH` always +//! wins. Every download is checked against the SHA-256 the vendor publishes +//! beside it and is moved into place only after it verifies. +//! +//! `JUSTTOOLS_NO_DOWNLOAD=1` turns this off, and `JUSTTOOLS_DEPS_DIR` moves +//! the folder. + +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use sha2::{Digest, Sha256}; +use ureq::ResponseExt; + +const YT_DLP_RELEASE: &str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download"; +/// Gyan Doshi's release build, the same one WinGet's `Gyan.FFmpeg` installs. +const FFMPEG_WINDOWS: &str = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"; +/// Martin Riedl's static release builds, one zip per program. +const FFMPEG_UNIX: &str = "https://ffmpeg.martin-riedl.de/redirect/latest"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Program { + YtDlp, + Ffmpeg, +} + +impl Program { + pub fn from_name(name: &str) -> Option<Self> { + match name { + "yt-dlp" => Some(Self::YtDlp), + "ffmpeg" | "ffprobe" => Some(Self::Ffmpeg), + _ => None, + } + } + + fn label(self) -> &'static str { + match self { + Self::YtDlp => "yt-dlp", + Self::Ffmpeg => "FFmpeg", + } + } +} + +/// Whether automatic downloads are allowed in this environment. +pub fn enabled() -> bool { + !std::env::var("JUSTTOOLS_NO_DOWNLOAD").is_ok_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + +/// The folder that holds the downloaded programs. +pub fn directory() -> Option<PathBuf> { + if let Some(directory) = + std::env::var_os("JUSTTOOLS_DEPS_DIR").filter(|value| !value.is_empty()) + { + return Some(PathBuf::from(directory)); + } + directories::BaseDirs::new().map(|base| base.data_local_dir().join("JustTools/deps/bin")) +} + +fn executable(name: &str) -> String { + if cfg!(windows) { + format!("{name}.exe") + } else { + name.to_owned() + } +} + +/// A previously downloaded program, by its plain name. +pub fn find(name: &str) -> Option<PathBuf> { + Program::from_name(name)?; + let path = directory()?.join(executable(name)); + path.is_file().then_some(path) +} + +fn agent() -> ureq::Agent { + ureq::Agent::config_builder() + .timeout_connect(Some(Duration::from_secs(20))) + .timeout_recv_response(Some(Duration::from_secs(60))) + .user_agent(concat!("JustTools/", env!("CARGO_PKG_VERSION"))) + .build() + .into() +} + +fn text(agent: &ureq::Agent, url: &str) -> Result<String, String> { + agent + .get(url) + .call() + .and_then(|mut response| { + response + .body_mut() + .with_config() + .limit(1024 * 1024) + .read_to_string() + }) + .map_err(|error| format!("cannot read {url}: {error}")) +} + +/// The hash a checksum listing gives for `name`. A listing that holds a lone +/// hash, as a `.sha256` sidecar often does, names whatever it sits beside. +fn listed_hash(listing: &str, name: &str) -> Option<String> { + let valid = |hash: &str| hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()); + let mut lone = None; + for line in listing.lines() { + let mut parts = line.split_whitespace(); + let Some(hash) = parts.next().filter(|hash| valid(hash)) else { + continue; + }; + match parts.next() { + Some(file) if file.trim_start_matches('*') == name => { + return Some(hash.to_ascii_lowercase()); + } + Some(_) => {} + None => lone = Some(hash.to_ascii_lowercase()), + } + } + lone +} + +/// Download `url` into `directory` under a temporary name, returning the file, +/// its SHA-256, and the address it finally came from. +fn download( + agent: &ureq::Agent, + url: &str, + directory: &Path, +) -> Result<(tempfile::NamedTempFile, String, String), String> { + let mut response = agent + .get(url) + .call() + .map_err(|error| format!("cannot download {url}: {error}"))?; + let source = response.get_uri().to_string(); + let mut file = tempfile::Builder::new() + .prefix(".download-") + .tempfile_in(directory) + .map_err(|error| format!("cannot write in {}: {error}", directory.display()))?; + let mut hasher = Sha256::new(); + let mut reader = response.body_mut().as_reader(); + let mut buffer = vec![0_u8; 256 * 1024]; + loop { + if crate::common::interrupted() { + return Err("interrupted".into()); + } + let count = reader + .read(&mut buffer) + .map_err(|error| format!("the download of {url} stopped: {error}"))?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + file.write_all(&buffer[..count]) + .map_err(|error| format!("cannot write the download: {error}"))?; + } + file.flush() + .map_err(|error| format!("cannot write the download: {error}"))?; + let hash = hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + Ok((file, hash, source)) +} + +fn verify(actual: &str, listing: &str, name: &str, url: &str) -> Result<(), String> { + match listed_hash(listing, name) { + Some(expected) if expected == actual => Ok(()), + Some(expected) => Err(format!( + "{url} failed verification: expected SHA-256 {expected}, received {actual}" + )), + None => Err(format!("the vendor published no SHA-256 for {name}")), + } +} + +/// Put a finished file in place and make it runnable. +fn install(file: tempfile::NamedTempFile, destination: &Path) -> Result<(), String> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(file.path(), fs::Permissions::from_mode(0o755)).map_err(|error| { + format!("cannot mark {} executable: {error}", destination.display()) + })?; + } + file.persist(destination) + .map(drop) + .map_err(|error| format!("cannot install {}: {}", destination.display(), error.error)) +} + +/// Copy the named programs out of a zip archive, wherever they sit in it. +fn unpack(archive: &Path, programs: &[&str], directory: &Path) -> Result<(), String> { + let broken = + |error: &dyn std::fmt::Display| format!("the FFmpeg archive is unreadable: {error}"); + let file = File::open(archive).map_err(|error| broken(&error))?; + let mut zip = zip::ZipArchive::new(file).map_err(|error| broken(&error))?; + for program in programs { + let wanted = executable(program); + let index = (0..zip.len()) + .find(|index| { + zip.by_index(*index).is_ok_and(|entry| { + entry.is_file() + && entry.name().rsplit(['/', '\\']).next() == Some(wanted.as_str()) + }) + }) + .ok_or_else(|| format!("the FFmpeg archive does not contain {wanted}"))?; + let mut entry = zip.by_index(index).map_err(|error| broken(&error))?; + let mut output = tempfile::Builder::new() + .prefix(".download-") + .tempfile_in(directory) + .map_err(|error| format!("cannot write in {}: {error}", directory.display()))?; + std::io::copy(&mut entry, &mut output).map_err(|error| broken(&error))?; + output.flush().map_err(|error| broken(&error))?; + install(output, &directory.join(&wanted))?; + } + Ok(()) +} + +fn yt_dlp_asset() -> Result<&'static str, String> { + Ok(match (std::env::consts::OS, std::env::consts::ARCH) { + ("windows", "aarch64") => "yt-dlp_arm64.exe", + ("windows", _) => "yt-dlp.exe", + ("macos", _) => "yt-dlp_macos", + ("linux", "x86_64") => "yt-dlp_linux", + ("linux", "aarch64") => "yt-dlp_linux_aarch64", + (os, arch) => return Err(format!("yt-dlp publishes no build for {os} {arch}")), + }) +} + +fn fetch_yt_dlp(agent: &ureq::Agent, directory: &Path) -> Result<(), String> { + let asset = yt_dlp_asset()?; + let url = format!("{YT_DLP_RELEASE}/{asset}"); + let listing = text(agent, &format!("{YT_DLP_RELEASE}/SHA2-256SUMS"))?; + let (file, hash, _) = download(agent, &url, directory)?; + verify(&hash, &listing, asset, &url)?; + install(file, &directory.join(executable("yt-dlp"))) +} + +fn fetch_ffmpeg(agent: &ureq::Agent, directory: &Path) -> Result<(), String> { + if cfg!(windows) { + // The x64 build also runs on Windows on ARM. + let (archive, hash, source) = download(agent, FFMPEG_WINDOWS, directory)?; + let listing = text(agent, &format!("{source}.sha256"))?; + let name = source.rsplit('/').next().unwrap_or_default().to_owned(); + verify(&hash, &listing, &name, &source)?; + return unpack(archive.path(), &["ffmpeg", "ffprobe"], directory); + } + let os = match std::env::consts::OS { + "macos" => "macos", + "linux" => "linux", + other => return Err(format!("no FFmpeg build is published for {other}")), + }; + let arch = match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + other => return Err(format!("no FFmpeg build is published for {os} {other}")), + }; + for program in ["ffprobe", "ffmpeg"] { + let url = format!("{FFMPEG_UNIX}/{os}/{arch}/release/{program}.zip"); + let (archive, hash, source) = download(agent, &url, directory)?; + let listing = text(agent, &format!("{source}.sha256"))?; + verify(&hash, &listing, &format!("{program}.zip"), &source)?; + unpack(archive.path(), &[program], directory)?; + } + Ok(()) +} + +/// Download `program` into the managed folder, replacing any earlier copy. +pub fn fetch(tool: &str, program: Program) -> Result<PathBuf, String> { + let directory = directory().ok_or("cannot locate a per-user data folder")?; + fs::create_dir_all(&directory) + .map_err(|error| format!("cannot create {}: {error}", directory.display()))?; + eprintln!( + "{tool}: {} is not installed; downloading it to {}", + program.label(), + directory.display() + ); + let agent = agent(); + match program { + Program::YtDlp => fetch_yt_dlp(&agent, &directory)?, + Program::Ffmpeg => fetch_ffmpeg(&agent, &directory)?, + } + eprintln!("{tool}: {} is ready", program.label()); + Ok(directory) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn checksum_listings_match_by_name_or_stand_alone() { + let hash = "a".repeat(64); + let other = "b".repeat(64); + let listing = format!("{other} yt-dlp\n{hash} yt-dlp.exe\n{other} *yt-dlp_macos\n"); + assert_eq!(listed_hash(&listing, "yt-dlp.exe").as_deref(), Some(&*hash)); + assert_eq!( + listed_hash(&listing, "yt-dlp_macos").as_deref(), + Some(&*other) + ); + assert_eq!(listed_hash(&listing, "yt-dlp_linux"), None); + assert_eq!( + listed_hash(&hash.to_ascii_uppercase(), "ffmpeg.zip").as_deref(), + Some(&*hash) + ); + assert_eq!(listed_hash("not a hash ffmpeg.zip", "ffmpeg.zip"), None); + + assert!(verify(&hash, &listing, "yt-dlp.exe", "https://example.test/a").is_ok()); + let mismatch = verify(&other, &listing, "yt-dlp.exe", "https://example.test/a"); + assert!(mismatch.unwrap_err().contains("failed verification")); + } + + #[test] + fn only_the_two_managed_programs_are_ever_looked_up() { + assert_eq!(Program::from_name("ffprobe"), Some(Program::Ffmpeg)); + assert_eq!(Program::from_name("yt-dlp"), Some(Program::YtDlp)); + assert_eq!(Program::from_name("git"), None); + assert_eq!(find("git"), None); + assert!(yt_dlp_asset().is_ok()); + } + + #[test] + fn archives_yield_their_programs_from_any_folder() { + let directory = tempfile::tempdir().unwrap(); + let archive = directory.path().join("build.zip"); + let mut writer = zip::ZipWriter::new(File::create(&archive).unwrap()); + let options = zip::write::SimpleFileOptions::default(); + writer + .start_file( + format!("ffmpeg-9.0-build/bin/{}", executable("ffmpeg")), + options, + ) + .unwrap(); + writer.write_all(b"encoder").unwrap(); + writer + .start_file( + format!("ffmpeg-9.0-build/bin/{}", executable("ffprobe")), + options, + ) + .unwrap(); + writer.write_all(b"prober").unwrap(); + writer.finish().unwrap(); + + let bin = directory.path().join("bin"); + fs::create_dir(&bin).unwrap(); + unpack(&archive, &["ffmpeg", "ffprobe"], &bin).unwrap(); + assert_eq!( + fs::read(bin.join(executable("ffmpeg"))).unwrap(), + b"encoder" + ); + assert_eq!( + fs::read(bin.join(executable("ffprobe"))).unwrap(), + b"prober" + ); + let missing = unpack(&archive, &["ffplay"], &bin).unwrap_err(); + assert!(missing.contains("does not contain")); + } +} diff --git a/src/selector.rs b/src/selector.rs index e4495d4..d2967e4 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -35,6 +35,9 @@ fn list() { println!("\nrun: just <tool> [args] (e.g. `just qr hello`, `just help video`)"); println!("defaults: just --defaults-path (interactive changes save automatically)"); println!("shell: just init <powershell|bash|zsh|fish> (mkcd, claude_, codex_)"); + if cfg!(windows) { + println!("explorer: just context install (right-click menu for files and folders)"); + } } fn normalized_tool(value: &str) -> String { @@ -53,6 +56,12 @@ pub fn run(args: Vec<OsString>) -> ToolResult { if args.first().is_some_and(|arg| arg == "install") { return crate::install::run(args.into_iter().skip(1).collect()); } + if args.first().is_some_and(|arg| arg == "context") { + return crate::context::run(args.into_iter().skip(1).collect()); + } + if args.first().is_some_and(|arg| arg == "deps") { + return fetch_dependencies(&args[1..]); + } if args.first().is_some_and(|arg| arg == "init") { return crate::shell::run(args.into_iter().skip(1).collect()); } @@ -138,6 +147,37 @@ pub fn run(args: Vec<OsString>) -> ToolResult { } } +/// `just deps fetch <yt-dlp|ffmpeg> ...` downloads the managed copy of each +/// named program now, whether or not one is already on `PATH`. +fn fetch_dependencies(args: &[OsString]) -> ToolResult { + let usage = || ToolError::usage("just", "usage: just deps fetch <yt-dlp|ffmpeg> ..."); + let (Some(command), names) = (args.first(), args.get(1..).unwrap_or_default()) else { + return Err(usage()); + }; + if command != "fetch" || names.is_empty() { + return Err(usage()); + } + for name in names { + let program = name + .to_str() + .and_then(crate::managed::Program::from_name) + .ok_or_else(usage)?; + let directory = crate::managed::fetch("just", program) + .map_err(|message| ToolError::new("just", message))?; + println!("{}", directory.display()); + } + Ok(()) +} + +/// Let the user choose a tool from the browser without running it. +pub(crate) fn pick() -> ToolResult<Option<&'static str>> { + let selection = choose(None) + .map_err(|error| ToolError::new("just", format!("terminal UI failed: {error}")))?; + Ok(selection + .and_then(|index| COMMANDS.get(index)) + .map(|command| command.name)) +} + struct App { selected: usize, table: TableState, diff --git a/tests/native_cli.rs b/tests/native_cli.rs index 806ded0..577d920 100644 --- a/tests/native_cli.rs +++ b/tests/native_cli.rs @@ -11,12 +11,14 @@ const COMMANDS: &[&str] = &[ "justbunt", "justcommit", "justcrop", + "justip", "justjpg", "justjson", "justlinks", "justmkcd", "justmp3", "justoptimize", + "justpaste", "justpdf", "justpng", "justport", @@ -225,6 +227,105 @@ fn install_creates_native_aliases_and_backs_up_legacy_scripts() { assert!(String::from_utf8_lossy(&bunt_help.stdout).contains("justbunt")); } +/// A stand-in for a public address service, so the tests never reach the +/// network. It answers `connections` requests with the same response. +fn fake_ip_service( + status: &'static str, + body: &'static str, + connections: usize, +) -> (String, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + for _ in 0..connections { + let (mut stream, _) = listener.accept().unwrap(); + // A GET has no body, so its headers are the whole request. + // Answering before they arrive would leave the client writing + // into a closed socket. + let mut request = Vec::new(); + let mut buffer = [0_u8; 2048]; + while !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0, "request ended before its headers"); + request.extend_from_slice(&buffer[..count]); + } + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .unwrap(); + } + }); + (format!("http://{address}/"), handle) +} + +#[test] +fn justip_reports_both_families_without_a_switch() { + let (ipv4, ipv4_service) = fake_ip_service("200 OK", "203.0.113.7\n", 1); + let (ipv6, ipv6_service) = fake_ip_service("200 OK", "2001:db8::7\n", 1); + let output = Command::new(binary()) + .args(["ip", "--json"]) + .env("JUSTIP_IPV4_URL", &ipv4) + .env("JUSTIP_IPV6_URL", &ipv6) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + r#"{"ipv4":"203.0.113.7","ipv6":"2001:db8::7"}"# + ); + ipv4_service.join().unwrap(); + ipv6_service.join().unwrap(); +} + +#[test] +fn justip_falls_back_and_refuses_an_answer_from_the_wrong_family() { + let (failing, failing_service) = fake_ip_service("500 Internal Server Error", "nope", 1); + let (working, working_service) = fake_ip_service("200 OK", "203.0.113.7", 1); + // The IPv6 service answers with an IPv4 address, which must be discarded + // rather than reported under the wrong label. + let (confused, confused_service) = fake_ip_service("200 OK", "203.0.113.7", 1); + let output = Command::new(binary()) + .args(["ip", "--plain"]) + .env("JUSTIP_IPV4_URL", format!("{failing};{working}")) + .env("JUSTIP_IPV6_URL", &confused) + .output() + .unwrap(); + // One family answering is still an answer, so the run succeeds. + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), "203.0.113.7\n"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("IPv6 unavailable"), "{stderr}"); + assert!(stderr.contains("wrong address family"), "{stderr}"); + failing_service.join().unwrap(); + working_service.join().unwrap(); + confused_service.join().unwrap(); +} + +#[test] +fn justip_fails_when_no_requested_family_answers() { + let (failing, failing_service) = fake_ip_service("500 Internal Server Error", "nope", 1); + let output = Command::new(binary()) + .args(["ip", "-4"]) + .env("JUSTIP_IPV4_URL", &failing) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("no public address available"), "{stderr}"); + failing_service.join().unwrap(); +} + #[test] fn bunt_snapshot_runs_through_short_dispatch() { let snapshot = run(&["bunt", "--snapshot"]); @@ -650,6 +751,8 @@ fn missing_dependency_never_installs_without_a_terminal() { .arg("video") .arg(&input) .env("PATH", &fake_bin) + // Without this the missing FFmpeg would simply be downloaded. + .env("JUSTTOOLS_NO_DOWNLOAD", "1") .env_remove("FFMPEG_BIN") .output() .unwrap(); @@ -660,6 +763,38 @@ fn missing_dependency_never_installs_without_a_terminal() { assert!(!directory.path().join("clip-web.mp4").exists()); } +#[test] +fn media_url_dry_run_reports_the_download_without_network_access() { + let directory = tempfile::tempdir().unwrap(); + let result = Command::new(binary()) + .args(["video", "--dry-run", "https://example.test/watch?v=1"]) + .current_dir(directory.path()) + .env("PATH", directory.path()) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let report = String::from_utf8_lossy(&result.stdout); + assert!(report.contains("1 download(s) with yt-dlp"), "{report}"); + assert!( + report.contains("https://example.test/watch?v=1 -> "), + "{report}" + ); + assert!(report.contains("<title> [<id>].mp4"), "{report}"); + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 0); +} + +#[test] +fn image_tools_refuse_urls_instead_of_reading_them_as_paths() { + let result = run(["png", "https://example.test/logo.png"].as_ref()); + assert_eq!(result.status.code(), Some(2)); + let error = String::from_utf8_lossy(&result.stderr); + assert!(error.contains("reads local files"), "{error}"); +} + #[test] fn zip_uses_the_git_file_set_and_writes_a_readable_archive() { let directory = tempfile::tempdir().unwrap(); @@ -1189,3 +1324,337 @@ fn invalid_selector_option_uses_the_standard_usage_exit() { assert_eq!(result.status.code(), Some(2)); assert!(String::from_utf8_lossy(&result.stderr).contains("Try 'just --help'")); } + +#[test] +fn context_menu_entries_run_the_same_headless_command_they_show() { + let directory = tempfile::tempdir().unwrap(); + let image = directory.path().join("wide photo.png"); + let unrelated = directory.path().join("notes.txt"); + image::RgbaImage::from_pixel(1200, 600, image::Rgba([20, 80, 160, 255])) + .save(&image) + .unwrap(); + fs::write(&unrelated, "not an image").unwrap(); + // Explorer hands the selection over in a list file that is consumed. + let list = directory.path().join("selection.txt"); + fs::write( + &list, + format!("{}\r\n{}\r\n", image.display(), unrelated.display()), + ) + .unwrap(); + + let result = Command::new(binary()) + .args(["context", "run", "resize.1024", "--list"]) + .arg(&list) + .output() + .unwrap(); + assert!( + result.status.success(), + "context run failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + let stdout = String::from_utf8_lossy(&result.stdout); + assert!( + stdout.contains("Headless: justresize --max 1024 'wide photo.png'"), + "{stdout}" + ); + assert!(!list.exists(), "the selection list must be consumed"); + assert!(image.is_file(), "the source must be kept"); + assert_eq!( + image::image_dimensions(directory.path().join("wide photo-resized.png")).unwrap(), + (1024, 512) + ); +} + +#[test] +fn context_menu_refuses_unknown_entries_and_selections_a_tool_cannot_read() { + let unknown = run(&["context", "run", "video.9000", "clip.mp4"]); + assert_eq!(unknown.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&unknown.stderr).contains("unknown context-menu entry")); + + let unreadable = run(&["context", "run", "optimize", "song.mp3"]); + assert_eq!(unreadable.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&unreadable.stderr).contains("JustOptimize reads")); + + // Launcher entries are interactive, so they never run without a terminal. + let launcher = run(&["context", "run", "video.options", "clip.mp4"]); + assert_eq!(launcher.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&launcher.stderr).contains("needs a terminal")); + + let help = run(&["context", "--help"]); + assert!(help.status.success()); + assert!(String::from_utf8_lossy(&help.stdout).contains("just context install")); +} + +#[test] +fn context_package_layout_is_ready_for_signing() { + let directory = tempfile::tempdir().unwrap(); + let layout = directory.path().join("package"); + let result = Command::new(binary()) + .args([ + "context", + "package", + "--publisher", + "CN=Example", + "--output", + ]) + .arg(&layout) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let manifest = fs::read_to_string(layout.join("AppxManifest.xml")).unwrap(); + assert!(manifest.contains(r#"Publisher="CN=Example""#)); + assert!(manifest.contains(r#"<desktop5:ItemType Type=".mp4">"#)); + assert!(layout.join("Assets").join("logo-44.png").is_file()); +} + +#[test] +fn video_resolution_is_validated_before_any_file_is_touched() { + let directory = tempfile::tempdir().unwrap(); + let clip = directory.path().join("clip.mp4"); + fs::write(&clip, b"not really a video").unwrap(); + let preview = Command::new(binary()) + .args(["video", "--resolution", "4k", "--dry-run"]) + .arg(&clip) + .output() + .unwrap(); + assert!( + preview.status.success(), + "{}", + String::from_utf8_lossy(&preview.stderr) + ); + assert!(String::from_utf8_lossy(&preview.stdout).contains("clip-web.mp4")); + + let invalid = Command::new(binary()) + .args(["video", "--resolution", "900p", "--dry-run"]) + .arg(&clip) + .output() + .unwrap(); + assert_eq!(invalid.status.code(), Some(2)); + assert!( + String::from_utf8_lossy(&invalid.stderr) + .contains("resolution must be 480p, 720p, 1080p, 1440p, 4k, or source") + ); +} + +#[test] +#[cfg(not(windows))] +fn context_menu_registration_is_windows_only() { + let result = run(&["context", "install"]); + assert_eq!(result.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&result.stderr).contains("Windows File Explorer")); +} + +/// A stand-in web host for `justpaste`: `/photo` is a JPEG with no extension in +/// its address, `/post` is a page that declares that photo, and anything else +/// is missing. It serves until `requests` connections have been answered. +fn fake_paste_host(requests: usize) -> (String, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + for _ in 0..requests { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 2048]; + while !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0, "request ended before its headers"); + request.extend_from_slice(&buffer[..count]); + } + let request = String::from_utf8_lossy(&request).into_owned(); + let path = request.split_whitespace().nth(1).unwrap_or("/").to_owned(); + let (status, kind, body): (&str, &str, Vec<u8>) = match path.as_str() { + "/photo" => ( + "200 OK", + "image/jpeg", + b"\xff\xd8\xff\xe0 pretend jpeg".to_vec(), + ), + "/post" => ( + "200 OK", + "text/html; charset=utf-8", + b"<html><head><meta property=\"og:image\" content=\"/photo\"></head></html>" + .to_vec(), + ), + _ => ("404 Not Found", "text/plain", b"missing".to_vec()), + }; + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .unwrap(); + stream.write_all(&body).unwrap(); + } + }); + (format!("http://{address}"), handle) +} + +#[test] +fn paste_saves_a_direct_file_and_numbers_a_taken_name() { + let directory = tempfile::tempdir().unwrap(); + let (host, server) = fake_paste_host(2); + for _ in 0..2 { + let result = Command::new(binary()) + .args(["paste", &format!("{host}/photo")]) + .current_dir(directory.path()) + .output() + .unwrap(); + assert!( + result.status.success(), + "justpaste failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + } + server.join().unwrap(); + // The address has no extension, so the content type supplies one. + let first = fs::read(directory.path().join("photo.jpg")).unwrap(); + assert!(first.starts_with(b"\xff\xd8\xff")); + assert_eq!( + fs::read(directory.path().join("photo (2).jpg")).unwrap(), + first + ); + let leftovers: Vec<_> = fs::read_dir(directory.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|name| name.starts_with(".justpaste-")) + .collect(); + assert!( + leftovers.is_empty(), + "temporary files remain: {leftovers:?}" + ); +} + +#[test] +fn paste_falls_back_to_the_media_a_page_declares_when_yt_dlp_is_unavailable() { + let directory = tempfile::tempdir().unwrap(); + let output = directory.path().join("saved"); + let (host, server) = fake_paste_host(2); + let result = Command::new(binary()) + .args(["paste", "--output"]) + .arg(&output) + .arg(format!("{host}/post")) + .env("YTDLP_BIN", "justtools-test-missing-yt-dlp") + .output() + .unwrap(); + server.join().unwrap(); + assert!( + result.status.success(), + "justpaste failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + assert!(output.join("photo.jpg").is_file()); +} + +#[test] +fn paste_reports_a_dead_link_and_previews_without_the_network() { + let directory = tempfile::tempdir().unwrap(); + let (host, server) = fake_paste_host(1); + let dead = Command::new(binary()) + .args(["paste", &format!("{host}/gone")]) + .env("YTDLP_BIN", "justtools-test-missing-yt-dlp") + .current_dir(directory.path()) + .output() + .unwrap(); + server.join().unwrap(); + assert_eq!(dead.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&dead.stderr).contains("could not be fetched")); + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 0); + + // No server is listening for this one: a dry run must not connect. + let preview = run(&["paste", "--dry-run", "https://example.invalid/clip"]); + assert!(preview.status.success()); + assert!(String::from_utf8_lossy(&preview.stdout).contains("https://example.invalid/clip")); + + let not_a_link = run(&["paste", "clip.mp4"]); + assert_eq!(not_a_link.status.code(), Some(2)); + assert!(String::from_utf8_lossy(¬_a_link.stderr).contains("http(s) link")); +} + +/// Downloads the real vendor builds, so it only runs when asked for: +/// `cargo test --test native_cli managed_downloads -- --ignored`. +#[test] +#[ignore = "downloads yt-dlp and FFmpeg from their vendors"] +fn managed_downloads_fetch_verified_programs_that_run() { + let directory = tempfile::tempdir().unwrap(); + let result = Command::new(binary()) + .args(["deps", "fetch", "yt-dlp", "ffmpeg"]) + .env("JUSTTOOLS_DEPS_DIR", directory.path()) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let version = Command::new(directory.path().join(executable_name("yt-dlp"))) + .arg("--version") + .output() + .unwrap(); + assert!(version.status.success(), "yt-dlp does not run"); + let encoders = Command::new(directory.path().join(executable_name("ffmpeg"))) + .args(["-hide_banner", "-encoders"]) + .output() + .unwrap(); + let encoders = String::from_utf8_lossy(&encoders.stdout).into_owned(); + for encoder in ["libx264", "libmp3lame", "aac"] { + assert!(encoders.contains(encoder), "FFmpeg lacks {encoder}"); + } + let probe = Command::new(directory.path().join(executable_name("ffprobe"))) + .arg("-version") + .output() + .unwrap(); + assert!(probe.status.success(), "ffprobe does not run"); + let leftovers = fs::read_dir(directory.path()) + .unwrap() + .filter(|entry| { + entry + .as_ref() + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".download-") + }) + .count(); + assert_eq!(leftovers, 0, "temporary downloads remain"); +} + +#[test] +fn deps_fetch_needs_a_program_it_manages() { + let nothing = run(&["deps", "fetch"]); + assert_eq!(nothing.status.code(), Some(2)); + assert!(String::from_utf8_lossy(¬hing.stderr).contains("just deps fetch")); + let unmanaged = run(&["deps", "fetch", "git"]); + assert_eq!(unmanaged.status.code(), Some(2)); +} + +#[test] +fn a_downloaded_program_is_used_when_path_has_none() { + let directory = tempfile::tempdir().unwrap(); + let managed = directory.path().join("managed"); + let empty = directory.path().join("empty-path"); + fs::create_dir(&managed).unwrap(); + fs::create_dir(&empty).unwrap(); + // Stand-ins: resolving them is the point, and they fail when run. + for name in ["ffmpeg", "ffprobe"] { + fs::write(managed.join(executable_name(name)), b"stand-in").unwrap(); + } + let clip = directory.path().join("clip.mp4"); + fs::write(&clip, b"not decoded before the encoder is checked").unwrap(); + let result = Command::new(binary()) + .arg("video") + .arg(&clip) + .env("PATH", &empty) + .env("JUSTTOOLS_DEPS_DIR", &managed) + .env("JUSTTOOLS_NO_DOWNLOAD", "1") + .env_remove("FFMPEG_BIN") + .output() + .unwrap(); + let error = String::from_utf8_lossy(&result.stderr); + // Reaching the stand-in proves it was resolved; no installer was proposed. + assert!(!result.status.success()); + assert!(!error.contains("interactive confirmation"), "{error}"); + assert!(!error.contains("proposed command"), "{error}"); +} diff --git a/tests/shell/fish.fish b/tests/shell/fish.fish index 6384da1..e79b0a1 100644 --- a/tests/shell/fish.fish +++ b/tests/shell/fish.fish @@ -1,7 +1,7 @@ set -l start "$PWD" set -gx JUSTTOOLS_MKCD_RESULT previous-value mkcd 'my folder [x] café'; or exit 1 -test (path basename "$PWD") = 'my folder [x] café'; or exit 1 +test (basename -- "$PWD") = 'my folder [x] café'; or exit 1 test "$JUSTTOOLS_MKCD_RESULT" = previous-value; or exit 1 set -l inside "$PWD" mkcd --help >/dev/null; or exit 1 @@ -14,9 +14,9 @@ test "$PWD" = "$inside"; or exit 1 if mkcd first second; exit 1; end test ! -e first; or exit 1 just mkcd -p parent/child; or exit 1 -test (path basename "$PWD") = child; or exit 1 +test (basename -- "$PWD") = child; or exit 1 justmkcd -- --literal; or exit 1 -test (path basename "$PWD") = --literal; or exit 1 +test (basename -- "$PWD") = --literal; or exit 1 cd "$start" mkdir stubs printf '%s\n' '#!/bin/sh' 'printf "<%s>\n" "$@"' 'exit 19' > stubs/claude