tooldl finds, downloads, verifies and caches external command-line tools (ffmpeg, yt-dlp, whisper builds, and so on) for desktop apps. You describe a tool once, and tooldl makes sure it is available: it uses a copy already on PATH if there is a good one, and otherwise downloads a pinned, checksum-verified build into a per-user directory.
It has a Wails v2 adapter (wailsdl) that exposes the whole flow to your frontend as bound methods and events.
Status and maintenance
tooldl is written for sasayaki and is only really maintained for use with it. Changes are driven by what sasayaki needs.
It is built to be general purpose, so it should be reasonably stable in other projects too. That is an observation, not a promise: nothing here is a guarantee of fitness, stability or support, and the author accepts no liability from your use of it (see the license).
Repository: https://codeberg.org/its-astro/sasayaki-tooldl
Install
go get codeberg.org/its-astro/sasayaki-tooldl@latest
go get codeberg.org/its-astro/sasayaki-tooldl/wailsdl@latest # only if you use Wails
The core package has no Wails dependency; only wailsdl imports it.
Concepts
- Manager: owns any number of tools. Create it,
Register tools, call Start(ctx) from your app's startup hook.
- Config: describes one tool: name, version, the binaries it provides, and where each one downloads from.
- Binary / Source: a binary has one
Source (URL, SHA256, archive details) per GOOS/GOARCH.
- Candidate: an existing, complete set of binaries that could satisfy a tool (found on
PATH, or in the managed install dir).
- Emitter: a two-method interface used to send events and logs to your UI.
Quick start
import tooldl "codeberg.org/its-astro/sasayaki-tooldl"
mgr := tooldl.NewManager(tooldl.Options{AppName: "myapp"})
err := mgr.Register(tooldl.Config{
Name: "yt-dlp",
Version: "2024.08.06",
Mode: tooldl.AutoInstall,
Binaries: []tooldl.Binary{{
Name: "yt-dlp",
VerifyArgs: []string{"--version"},
Sources: map[string]tooldl.Source{
"linux/amd64": {URL: "https://example.com/yt-dlp_linux", SHA256: "..."},
},
}},
})
mgr.Start(ctx)
if err := mgr.Ensure("yt-dlp"); err != nil {
// declined, or the install failed
}
path := mgr.Path("yt-dlp", "yt-dlp")
Using it with Wails
Wails cannot bind methods that take a context.Context, so the Manager itself is not bound. wailsdl.New returns the manager plus a small Bindings type that is safe to bind.
import (
tooldl "codeberg.org/its-astro/sasayaki-tooldl"
"codeberg.org/its-astro/sasayaki-tooldl/wailsdl"
)
mgr, bindings := wailsdl.New(tooldl.Options{AppName: "myapp"})
cfg := myToolConfig()
cfg.OnDecline = func() { wailsruntime.Quit(app.ctx) } // optional
if err := mgr.Register(cfg); err != nil { panic(err) }
wails.Run(&options.App{
OnStartup: func(ctx context.Context) {
app.ctx = ctx
mgr.Start(ctx) // must run before the frontend calls EnsureTool
},
Bind: []any{app, bindings},
})
Frontend:
import {
EnsureTool,
RespondToInstallPrompt,
} from "../wailsjs/go/wailsdl/Bindings";
import { EventsOn } from "../wailsjs/runtime/runtime";
// Register listeners before calling EnsureTool.
EventsOn("mytool:missing", () => showModal()); // buttons call RespondToInstallPrompt('mytool', true | false)
EventsOn("mytool:progress", (p) => updateBar(p));
EventsOn("mytool:error", (msg) => showError(msg));
await EnsureTool("mytool"); // resolves when ready; rejects on decline or failure
The generated JS import path depends on what Wails generates for your project, so check your wailsjs folder.
Bound methods: EnsureTool(name), RespondToInstallPrompt(name, accept), ToolPath(name, binary), ToolPaths(name).
Manager API
| Method |
Description |
NewManager(Options) |
Create a manager. |
Register(Config) error |
Validate and add a tool. |
Start(ctx) |
Store the app context. Cancelling it aborts in-flight downloads. Must be called before Ensure. |
Ensure(name) error |
Find or install the tool. Blocks until ready. |
Respond(name, accept) |
Answer the install prompt (PromptUser mode). |
Check(ctx, name) |
Report whether the tool is usable. Never installs or prompts. |
Path(name, binary) |
Resolved path of one binary ("" until Ensure succeeds). |
Paths(name) |
Copy of the full binary-name to path map. |
Options
| Field |
Description |
BaseDir |
A folder path to install tools into, used exactly as given. |
AppName |
Your app's name. Used to pick a standard per-user folder when BaseDir is not set. |
Emitter |
Optional. Sends events and log messages to your UI. Without one, they are dropped. |
You must set BaseDir or AppName. If you set neither, Register returns ErrNoBaseDir.
Every tool is installed to:
<base>/<DirName>/<Version>/
DirName and Version come from each tool's Config. DirName defaults to the tool's Name. The <base> part is decided by BaseDir and AppName:
| You set |
<base> is |
only AppName |
<per-user data dir>/<AppName>/bin |
only BaseDir |
exactly BaseDir |
| both |
BaseDir (AppName is ignored) |
| neither |
not allowed (ErrNoBaseDir) |
AppName is the easy option. You give a name, and tooldl puts tools in the standard per-user data folder for the operating system:
tooldl.Options{AppName: "myapp"}
// A tool named "ffmpeg" at version "6.1" installs to:
// Linux: ~/.local/share/myapp/bin/ffmpeg/6.1/ (or $XDG_DATA_HOME/myapp/bin/...)
// macOS: ~/Library/Application Support/myapp/bin/ffmpeg/6.1/
// Windows: %LOCALAPPDATA%\myapp\bin\ffmpeg\6.1\
This is a data folder rather than a cache folder, because the operating system may clear caches and users would have to download the tools again.
BaseDir is for when you want to choose the location yourself, for example a portable app that keeps tools beside its executable, or a temporary folder in tests:
tooldl.Options{BaseDir: "/opt/myapp/tools"}
// A tool named "ffmpeg" at version "6.1" installs to:
// /opt/myapp/tools/ffmpeg/6.1/
AppName is only used to build this default path. It does not affect event names, logging, or which tools are found.
Because the version is part of the path, changing a tool's Version makes tooldl look in a new, empty folder and download it again, leaving the old version where it was. The same happens if you change BaseDir, AppName or DirName: copies installed under the old path are no longer found. This folder is also the second place Ensure looks for an existing tool (after PATH).
Config
| Field |
Description |
Name |
Unique ID. Default event prefix and install dir name. Must be a valid single path segment (see below). |
Version |
Part of the install path. Bump it to force a clean re-download. |
Binaries |
The executables the tool provides (at least one). |
Mode |
PromptUser (default) or AutoInstall. |
ShouldInstall |
Optional func(ctx, Candidate) bool that can reject an existing install. |
SkipSystem |
Ignore PATH; only use the managed dir. |
OnDecline |
Called when the user declines in PromptUser mode. |
EventPrefix |
Overrides the event name prefix. |
DirName |
Overrides the install dir name. |
ForceInstallEnv |
Name of an env var that, when set, ignores existing installs. A testing aid. |
Binary and Source
Binary: Name (the name you would type in a terminal, without an extension), Sources (map keyed by "GOOS/GOARCH", e.g. "linux/amd64"), and VerifyArgs (run after install as a smoke test; empty skips it).
Source:
| Field |
Description |
URL |
Required. |
SHA256 |
Required. Hex hash of the downloaded file (the archive, not the extracted binary). |
Format |
FormatAuto (default, inferred from the URL), FormatRaw, FormatZip or FormatTarGz. |
Member |
Base name of the file to pull out of an archive. Defaults to the binary's Name (plus .exe on Windows). Ignored for raw downloads. |
Validation
Register rejects a Config whose values would be unsafe to use in file paths. Name, Version, DirName (if set) and every Binary.Name must each be a single valid path segment:
- not empty,
. or ..
- none of
/ \ : * ? " < > | or a NUL character
- no trailing dot or space (Windows strips these, so
6.1. and 6.1 would collide)
Binary names must also be unique within a tool, and each binary needs at least one Source. This keeps a typo from installing outside the base directory.
How Ensure works
- A newer call for the same tool supersedes one still waiting on the prompt or downloading (useful after a frontend hot reload).
- It looks for an existing install, as a complete set of binaries from a single place:
PATH first (unless SkipSystem), then the managed dir.
- If
ShouldInstall is set, it is called once per candidate. Returning true rejects that candidate and moves on. The first accepted candidate is used and no install happens.
- If nothing was accepted,
PromptUser mode emits <prefix>:missing and waits for Respond; AutoInstall mode goes straight to installing.
- Install checks that every binary has a source for the current platform with a URL and checksum, downloads each distinct asset once, verifies the SHA256, extracts the member, writes it atomically, and runs
VerifyArgs.
- It records the paths and emits
<prefix>:ready.
If a good install was already present, Ensure returns nil without emitting ready.
Version checks
cfg.ShouldInstall = func(ctx context.Context, c tooldl.Candidate) bool {
// c.Source is tooldl.SourceSystem or tooldl.SourceManaged
return majorVersion(ctx, c.Paths["ffmpeg"]) < 6
}
An old copy on PATH is rejected, the managed copy is considered next, and if neither passes, the tool is installed. ShouldInstall is not called when there are no candidates, and not called again after an install. Keep the check consistent with the version you pin, or you will reinstall on every launch.
Several binaries in one archive
Point multiple binaries at the same Source. Each distinct URL and checksum is downloaded once and then extracted per binary.
Different name inside the archive
tooldl.Source{URL: "https://.../whisper-bin-x64.zip", SHA256: "...", Member: "main.exe"}
Member is matched by base name anywhere in the archive. The file is installed under the binary's Name. If you set Member yourself, include the extension where needed.
Events
Event names use the tool's prefix (its Name unless EventPrefix is set).
| Event |
Payload |
<prefix>:missing |
none (PromptUser mode only) |
<prefix>:progress |
{tool, stage, step, steps, percent, indeterminate} |
<prefix>:error |
error string |
<prefix>:ready |
map of binary name to path (after a fresh install only) |
stage is "downloading" or "extracting". step and steps count binaries, not bytes. When indeterminate is true the server reported no size and percent is meaningless.
Errors
ErrDeclined: the user declined the install.
ErrNotStarted: Ensure was called before Start.
ErrUnknownTool: the name was never registered.
ErrNoBaseDir: neither BaseDir nor AppName was set.
Download, checksum and verification failures are returned as wrapped errors and also emitted as <prefix>:error, unless the call was superseded.
Behaviour worth knowing
- Checksums are mandatory. There is deliberately no option to skip verification. You hash each asset when you pin a version.
- Binaries are resolved as a set. If only some of a tool's binaries are on
PATH, that counts as no PATH candidate, so you never mix binaries from different installs.
PATH lookup uses the bare name. On Windows the OS applies PATHEXT itself. Shell aliases and functions are not on PATH and will not be detected.
Ensure is serialised per tool. Different tools can install concurrently.
Check never prompts, so it is safe for a settings page showing "installed / not installed".
Layout
tooldl.go Manager, Config, events, detection
install.go download, checksum, extract, install
wailsdl/ Wails v2 adapter (separate module)
License
Licensed under The Clear BSD License. See LICENSE.
The software is provided as is, with no warranty, and the license's disclaimer of liability applies.