The problem
Every game launcher does the same job: find a title, hand it to a download backend, track progress, launch when it's done. What they don't do is treat backends as swappable. Exhibition takes either a magnet link for a local torrent session or the same magnet for Real-Debrid's unrestricted service, and the rest of the app doesn't care which was used.
Why these choices
- Go. The work here is concurrency and long-lived network clients, which is exactly where Go's runtime and static binary win. One cross-compiled file per platform, no runtime to install.
- Wails v3 over Electron. The frontend is a list of downloads with some settings. A webview is enough; shipping a whole browser per app is not.
- rain for BitTorrent. A pure-Go implementation, so the same binary does both backends with no cgo. It exposes DHT, PEX, and port ranges directly, which is what let the settings be user-facing.
- JSON provider manifests on disk rather than a compiled-in list. Adding a source is dropping a file into
provider/, no rebuild.
Normalising before matching
Titles arrive as "Some Game (v1.0.0) [Multi]" from providers and as "some game" from the user. Fuzzy search fails on punctuation and case mismatches, so both sides get the same normalisation pass before comparison.
func (fuzzyManager *FuzzyManager) IndexFuzzy(games map[int]json_models.Game) {
fuzzyManager.GamesMap = make(map[string]int)
for id, game := range games {
name := strings.ToLower(game.Name)
// Remove weird characters
name = strings.Map(func(r rune) rune {
if strings.ContainsRune(",!?.$#@%^&*()-+=[]/';:<>", r) {
return -1
}
return r
}, name)
fuzzyManager.GamesMap[name] = id
}
}
Returning -1 from a strings.Map callback
deletes the rune. That's the idiomatic Go filter, and it's why no
second pass is needed.
Two downloads, one queue
The queue stores a UUID and a type discriminator. Adding an item means pushing a tagged struct onto a slice; the runner type-switches on it later. Adding a third backend doesn't touch the queue at all.
func (q *Queue) AddRealDebridDownloadToQueue(magnetLink string) {
d := Download{
UUID: uuid.New().String(),
Type: RealDebridType,
MagnetLink: magnetLink,
}
q.AddDownloadToQueue(d)
}
Real-Debrid's 503 means "not cached"
/unrestrict/check answers 503 when a host has no cached copy
ready. That's not an error condition, it's a routing decision: the link
goes to the torrent backend instead. Treating it as a failure would send
every uncached download down a dead end.
func (client *RealDebridClient) UnrestrictCheck(link string) (bool, error) {
params := url.Values{}
params.Add("link", link)
req, err := client.newRequest(http.MethodPost, "/unrestrict/check", nil, params)
if err != nil {
return false, fmt.Errorf("could not post link: %w", err)
}
resp, err := client.client.Do(req)
if resp.StatusCode == 503 {
return false, nil
}
return true, nil
}
BitTorrent settings are just struct fields
rain exposes DHT, PEX, and the port range directly, so making them
user settings took copying them onto the config. ResumeOnStartup
is deliberately off: the launcher owns the session lifecycle, and a
half-finished torrent resuming behind the user's back is a bug
generator.
conf := torrent.DefaultConfig
conf.DataDir = path
conf.DataDirIncludesTorrentID = false
conf.Debug = false
conf.DHTEnabled = bittorrentSettings.UseDHT
conf.PEXEnabled = bittorrentSettings.UsePEX
conf.ResumeOnStartup = false
conf.Database = filepath.Join(path, "bittorrent.db")
conf.PortBegin = bittorrentSettings.StartPort
conf.PortEnd = bittorrentSettings.EndPort
session, err := torrent.NewSession(conf)
Current limitations
- Provider refresh isn't incremental. Providers are re-verified and reloaded wholesale on startup. With ETags stored in the JSON there's a hint of caching, but no conditional requests are made.
- Fuzzy search is per-process. The index is rebuilt from whatever is in memory at startup. No persistence means the first query after a launch is over a partial catalogue.
- Path handling is duplicated per platform.
disk_unix.goanddisk_windows.gocarry the same logic in two places, waiting for someone to remember to change both. - The proxy client is optional and half-wired. Present, configurable, and not used on every code path that hits the network.