A native BitTorrent client for macOS and iOS, built with SwiftUI over libtorrent using Swift/C++ interop.
cp Local.env.example Local.env # bundle id and team identifier; gitignored
make bootstrap # libtorrent submodule + Boost headers (~184 MB)
make openssl # OpenSSL xcframework (slow: five Configure+make runs)
make test # engine test suite
make run # build and launch the Mac appmake on its own lists everything. make config prints the bundle id and team
it resolved, which is the quickest way to check Local.env is being read.
Local.env is gitignored, so a team identifier never lands in the repository.
Precedence is environment, then Local.env, then the defaults in
Project.swift — so CI can override a developer's checkout without editing it.
The values reach the manifest as TUIST_-prefixed variables, and the prefix is
required rather than decorative: Tuist evaluates Project.swift in a sandbox
where ProcessInfo.environment is empty and reading Local.env from disk
fails, so that is the only channel available. One consequence is that plain
tuist generate does not read Local.env — use make generate, which
exports the variables first.
Everything else derives from the bundle id. The BGTaskScheduler identifiers and
the magnet URL type use $(PRODUCT_BUNDLE_IDENTIFIER) in the Info.plist, and
BackgroundCoordinator derives its constants from Bundle.main.bundleIdentifier,
so the two cannot drift — which matters because a mismatch there is not a build
error but a refused task registration at launch.
A team identifier is only needed to run on an iOS device. make app-macos
stays unsigned regardless, so configuring a team does not make local Mac builds
start demanding certificates; use make app-macos-signed when you want one.
| Path | What it is |
|---|---|
Sources/TorrentKit |
Swift API — actors, Sendable models, AsyncStream of events |
Sources/TorrentBridge |
C++17 facade over libtorrent; the only place C++ is touched |
Sources/TorrentFeeds |
RSS/Atom subscriptions and auto-download rules |
Vendor/libtorrent |
submodule pinned to v2.0.14 |
Vendor/boost |
Boost 1.92 headers, fetched by bootstrap.sh, not committed |
Vendor/openssl |
OpenSSL 3.5.8 xcframework, built by build-openssl.sh, not committed |
App/ |
SwiftUI app, shared plus per-platform sources |
App/Resources/Assets.xcassets |
App icon and accent colour, generated by make assets |
Project.swift |
Tuist manifest for the multiplatform app target |
The engine is a plain SwiftPM package at the repository root, so swift build and
swift test work with no Tuist generation step.
Swift cannot catch C++ exceptions. If one reaches Swift the process terminates
with a fatal error and no Swift traceback. Every function in TorrentBridge wraps
its body in try { ... } catch (...) { ... } and reports failure in its return
value. Tests/TorrentKitTests deliberately drives libtorrent into a throw to prove
this still holds.
C++ interop is viral. TorrentKit enables .interoperabilityMode(.Cxx), so
every dependent target must too — including the app, via
SWIFT_OBJC_INTEROP_MODE: objcxx in Project.swift.
- Engine — sessions, adding by
.torrentor magnet, pause/resume/recheck/remove, resume data persisted across launches, clean shutdown. - Management — per-file priorities, peers, trackers, piece availability, per-torrent rate limits, sequential download, queue position, renaming, and moving a torrent's files to another folder.
- Network and privacy — DHT, LSD, UPnP, NAT-PMP, peer exchange, protocol encryption, SOCKS4/5 and HTTP proxies, CIDR address blocking, activity caps.
- Apps — a Mac window with a filter sidebar, sortable table and inspector; an iOS list with swipe actions and a pushed detail view; settings and feeds on both.
- Streaming — a loopback HTTP server with byte-range support that drives libtorrent piece deadlines, so media plays while it downloads, with VLCKit as the player. Sequential download and first/last-piece priority are offered both when a torrent is added and afterwards in its options.
- RSS auto-download — feed subscriptions with include/exclude or regex rules, polled on a schedule, remembering what it has already added.
swift test runs 59 tests with no network. The one that matters most is
LoopbackTransferTests, which runs a seeder and a leecher in the same process,
moves two megabytes between them over loopback, and compares the result
byte-for-byte against the original — so piece transfer and hash verification are
genuinely exercised, not just compiled.
Tests against the public swarm are opt-in, because they depend on trackers and peers being reachable:
TORRENTKIT_LIVE_TESTS=1 swift testOpenSSL 3.5.8 is wired up, so HTTPS trackers and SSL torrents work.
Scripts/build-openssl.sh builds it from source for five architectures and
assembles an xcframework with headers at Headers/openssl, which is what
libtorrent's #include <openssl/ssl.h> needs. The popular prebuilt Swift
packages do not work here: krzyzanowskim/OpenSSL is a 1.3 GB repository and
ships framework-style headers that do not satisfy that include.
A note on the build flags: the script passes no-legacy, which would normally
break BitTorrent's message-stream encryption, since OpenSSL 3 moved RC4 into the
legacy provider. It is safe because libtorrent carries its own RC4 (from
libtomcrypt) and does the Diffie-Hellman exchange with Boost.Multiprecision — it
only needs OpenSSL for TLS, SSL torrents, and hashing.
The iOS app never goes on the App Store. Builds are uploaded to App Store
Connect only to be notarized for alternative distribution, which checks
security, privacy and that the app works, not content rules. Users then install
through AltStore PAL.
Scripts/appstoreconnect.swift does the API work; every step is safe to re-run,
and each *-plan target previews its step without sending anything.
This publishes a binary, so read Before distributing a build first.
- As Account Holder, accept the current Apple Developer Program License Agreement; its Attachment 14 covers alternative distribution.
- Create an App Store Connect API key with the Admin role, or App Manager
with access to Certificates, Identifiers & Profiles so it can register
bundle IDs, and fill in the
ASC_*,REVIEW_*andALTSTORE_*values inLocal.env, along withDEVELOPER_NAME,SUPPORT_URLandPRIVACY_POLICY_URL. Nothing personal is written into the scripts: names, URLs and identifiers all come fromLocal.env, or from what the repository already defines (VLCKit's pinned revision and the usage strings inProject.swift, the accent colour in the asset catalog). make setup-appregisters the app and share extension bundle IDs, then checks for the app record. App Store Connect has no API for creating apps, so if it is missing, the command opens App Store Connect, prints what to enter under Apps › + › New App, and waits until the app appears.make altstore-registerprints a marketplace token. Add it under Users and Access › Integrations › Marketplace, select Skein and turn on notifications. This step has no API.make listingfills in the metadata notarization still requires. Its text lives at the top of the script.make screenshotscaptures the listing screenshots on a 6.9" iPhone and a 13" iPad simulator, using a Debug-only screenshot mode with fixed sample torrents (App/Sources/Shared/ScreenshotMode.swift). CheckAppStore/screenshots/, commit it, thenmake screenshots-uploadreplaces the version's screenshots; unchanged sets are skipped.- Export compliance needs nothing filed. Skein uses only standard encryption
and is not declared for France, which needs no export documentation, so
every build answers
ITSAppUsesNonExemptEncryption = NO(Project.swift) andmake notarizeanswers older builds the same way. Distributing in France would need the French ANSSI declaration: setencryption.franceto true andENCRYPTION_FRANCE_DOCUMENTto its PDF, thenmake encryptionfiles it. Using non-OS encryption may also mean a year-end self-classification report to the U.S. Bureau of Industry and Security. - App Privacy is the one thing left by hand, because the API has no endpoint for it: App Store Connect › App Privacy › Get Started › No, we do not collect data from this app, then Publish.
- For the Release workflow, add repository secrets
ASC_ISSUER_ID,ASC_KEY_IDandASC_PRIVATE_KEY(the whole.p8file, including itsBEGIN/ENDlines), and repository variablesTUIST_BUNDLE_ID,TUIST_DEVELOPMENT_TEAM,DEVELOPER_NAMEandPRIVACY_POLICY_URLwith the same values as inLocal.env. The workflow pushes tomain, so ifmainis protected, allow GitHub Actions to bypass it.
From GitHub, with nothing to build locally: write the release notes in
AppStore/whats-new.txt (or type them into the workflow form), push, then run
Actions › Submit › Run workflow. Leave version empty to submit the next
patch version, or enter one such as 1.1. It archives, uploads and submits for
notarization; once Apple approves and AltStore has processed the package, the
Release workflow publishes it, Mac build included, within three hours.
The same steps locally:
make upload # archive with a timestamp build number and upload
make notarize # wait for processing, attach the build, submit for notarization
make status # until the version shows an ADPThen nothing else is needed: the Release workflow checks every 3 hours
and publishes once AltStore has processed the package (make release-check
shows the same decision locally). To publish straight away, run it by hand
(Actions › Release › Run workflow, or gh workflow run release.yml), which waits
up to 30 minutes for AltStore. make release does the same locally, but
leaves committing AltStore/source.json to you, and needs gh signed in.
Export compliance is answered by each build's Info.plist; see step 7 above. The answers live in encryption at the top of the script. They
are a legal declaration about the encryption Skein ships (OpenSSL, BitTorrent
protocol encryption), so change them only if that changes.
Each version becomes a GitHub release tagged v<version>-<build>, with the
package's files as assets, uploaded byte for byte because PAL checks each one
against manifest.json. AltStore/source.json lists every release, newest
first, with assetURLs pointing at those assets. Users add
https://raw.githubusercontent.com/<owner>/<repo>/main/AltStore/source.json
(make release prints it)
in AltStore PAL. Deleting a release breaks that version for anyone AltStore
would fall back to, so leave old releases in place.
Internal TestFlight testing needs no review and works outside the EU, for up to
100 people on the App Store Connect team. make beta creates an internal group
("Team") that gets every build, answers export compliance and adds "What to
Test" to the newest build; add yourself to the group once, then install from the
TestFlight app.
Builds expire after 90 days. The TestFlight workflow checks every Monday and,
when the newest build has BETA_RENEW_DAYS (default 14) or fewer days left,
archives and uploads a fresh one with a timestamp build number, then runs
make beta. It builds Project.swift's version, or the next patch version once
App Store Connect has closed that one. make beta-check shows the same decision
locally. It needs repository variables TUIST_DEVELOPMENT_TEAM and
TUIST_ENABLE_APP_GROUP (as in Local.env), and optionally BETA_RENEW_DAYS.
The Mac app isn't on any store. It is signed with Developer ID, notarized by
Apple (an automated malware scan that takes minutes, unrelated to the iOS
notarization review) and attached to the same GitHub release as the iOS build,
as Skein-<version>-<build>-macOS.zip. It is universal, for Apple silicon and
Intel Macs.
Signing uses a Developer ID Application certificate from the keychain, not Apple's cloud-managed one, which refuses App Store Connect API keys even with the Admin role (FB16835802). Create it once in Xcode › Settings › Accounts › Manage Certificates › + › Developer ID Application (Account Holder only).
The Release workflow does this after the iOS step, in its macos job, at the
same version and build number. It needs the repository variable
TUIST_DEVELOPMENT_TEAM and the certificate as two secrets: export it from
Keychain Access as a .p12 with a password, then add DEVELOPER_ID_P12
(base64 -i DeveloperID.p12 | pbcopy) and DEVELOPER_ID_P12_PASSWORD. A
release that already has a Mac zip is skipped. Locally:
make mac-release # build/mac/Skein-<version>-<build>-macOS.zip
make mac-release RELEASE_TAG=v1.0-<build> VERSION=1.0 BUILD_NUMBER=<build> # and attach itSkein is under a custom proprietary license: anyone may download official builds and use them for personal, noncommercial purposes, and read the source here. Copying, modifying, building from source and redistributing are not permitted. The build instructions above are for the author.
That covers Skein's own code and builds only. libtorrent, Boost, OpenSSL, VLCKit and FeedKit each carry their own licenses, none of which Skein's license overrides, and the license says so explicitly — see THIRD-PARTY-NOTICES.md. They are fetched at build time rather than vendored here, so publishing this repository distributes none of them.
Two things worth knowing:
Publishing a compiled build carries obligations. A binary embeds VLCKit, which is LGPL-2.1, so each release has to ship its license text and a way to get its source, and recipients keep the right to modify and relink it. The license carves those rights out rather than contradicting them; the notices file lists what each release has to include.
A public GitHub repository can be forked. GitHub's Terms of Service §D.5 say that by making a repository public you grant other users a licence to "use, display, perform and reproduce (by forking) Your Content", and your own licence cannot switch that off. If copies existing elsewhere on GitHub is unacceptable, a private repository is the only reliable answer.
Separately: Apple does not allow torrent clients on the App Store, so the iOS app is released only through EU alternative marketplaces (see Releasing). macOS distributes normally via Developer ID.