Husi is a recreational proxy tool for Android and Desktop (Linux/macOS/Windows). The Kotlin/Compose
Multiplatform UI in composeApp/ calls into a Go core (libcore/) via JNI; protocol plugins under
plugin/ are separately built from Go/Rust submodules and packaged as auxiliary Android APKs.
All build orchestration goes through the root Makefile. Toolchain: JDK, Android NDK, **Go
**, Zig (used for the desktop launcher and Darwin cross-builds) — pinned versions live in
buildScript/init/. ./run <subcmd> is a generic dispatcher that resolves to
buildScript/<subcmd>.sh.
First-time setup needs git submodules for plugins and library pins:
./run lib source # = git submodule update --init --recursive
make assets # downloads geoip/geosite into composeApp resources
Common targets:
| Target | Purpose |
|---|---|
make assets |
Download geoip/geosite into composeApp resources (run once before first build) |
make libcore_android |
Build Go core into composeApp/libs/libcore.aar (required for Android builds) |
make libcore / make libcore_desktop DESKTOP_TARGETS=... |
Build Go core into composeApp/libs/libcore-desktop-<os>-<arch>.jar |
make apk / make apk_debug |
Assemble androidApp:assembleFossRelease / Debug |
make desktop / make desktop_release |
Run Compose desktop app via gradlew :composeApp:run[Release] |
make desktop_uberjar |
Single fat jar in composeApp/build/compose/jars/ |
make desktop_package[_linux/_macos/_windows] |
Native packages under composeApp/build/compose/packages/ |
make launcher |
Build the Zig native launcher (used by Linux/macOS/Windows installers) |
make plugin PLUGIN=<name> |
Assemble plugin APK; valid names: hysteria2 juicity naive mieru shadowquic |
make aboutlibraries |
Regenerate OSS license metadata (run before release) |
make generate_option |
Regenerate sing-box option mappings (boxoption); output piped through $CLIP |
The BUILD_PLUGIN env var (read in settings.gradle.kts) controls which plugin modules are
included; BUILD_PLUGIN=none excludes all plugins to speed up app-only builds (this is what the
Makefile does).
Cross-compiling desktop libcore: pass JNI_INCLUDE=/path/to/jni when JNI headers aren't
auto-detected; for Darwin targets on non-Darwin hosts also pass DARWIN_SDK=/path/to/MacOSX.sdk (
uses zig cc). Linux desktop builds pull in cronet-go for naive outbound — set CRONET_GO_ROOT
if it's not in ../../cronet-go or $HOME/cronet-go.
In restricted environments without a cronet-go checkout (or without network access to fetch
one), pass NO_NAIVE=1 to make libcore / make libcore_desktop (or --no-naive to
libcore/build.sh directly) to drop the with_naive_outbound build tag and skip the cronet-go
toolchain setup entirely. The resulting build omits the naive outbound protocol.
Desktop Gradle picks the libcore jar from os.name/os.arch; override with
./gradlew -p composeApp run -PdesktopTarget=linux/amd64. Missing jars fail the build immediately
rather than silently falling back.
make test # = test_gradle + test_go + test_launcher
make test_gradle # ./gradlew :composeApp:allTests (JUnit5)
make test_go # cd libcore && go test -v -count=1 ./...
make test_launcher # cd launcher && zig build test
make lint_go # GOOS=android golangci-lint run ./...
make fmt_go # gofumpt + gofmt + gci (run before committing Go)
Run a single Gradle test class: ./gradlew :composeApp:desktopTest --tests fr.husi.SomeTest. Run a
single Go test: cd libcore && go test -run TestName ./pkg/.... Install Go tooling once with
make fmt_go_install lint_go_install.
libcore/— Go module exposed to JVM. Built two ways:- Android via
gomobile→composeApp/libs/libcore.aar(entry:libcore.go, with sibling Go files for tun, dns, ping, ruleset, plugin glue, etc.). - Desktop via
anja(JNI bindings) → per-platformlibcore-desktop-<os>-<arch>.jarchecked intocomposeApp/libs/for IDE consumption. libcore/cmd/holdsboxoption(option codegen),boxversion,licencecollect,ruleset_generate.libcore/plugin/houses Go-side plugin support: outbound adapters (http,juicity,trusttunnel,vless), plusmieruproto(Mieru traffic-pattern protobuf),raybridge(*ray-compatible API shim),plugindns(plugin DNS conn plumbing), andpluginoption(option types/constants for hooked protocols).
- Android via
composeApp/— Kotlin Multiplatform shared module (Android library + JVMdesktoptarget). Source sets:commonMain,androidMain,desktopMain, plusandroidDebugand tests. Package root isfr.husi.androidApp/— thin Androidapplicationthat depends on:composeApp. Defines theAndroidManifest.xml, ABI splits, signing, andfoss/playflavors.library/— vendoredDragDropSwipeLazyColumnsubmodule pluslibcore-stubfor IDE indexing.launcher/— Zig executable embedded into desktop installers (launcher/src/main.zig); on Linux it getssetcapfor ambient capabilities before exec'ing the JVM, on Windows it embeds an admin-elevating manifest.plugin/api/— shared Android library that plugin APKs link against.plugin/{hysteria2,juicity,mieru,naive,shadowquic}/each wrap a Go/Rust submodule built into JNI libs bybuildScript/plugin/<name>.sh. The matching Gradle module'ssetupPlugin(...)registers anexternalBuildExectask that hooks intomergeJniLibFolders.buildSrc/src/main/kotlin/Helpers.kt— Gradle conventions:setupApp,setupAppCommon,setupKotlinCommon,setupPlugin,requireMetadata(readshusi.properties),requireLocalProperties, APK renamer, andwritePlatformInfo(used to emitexpect/actualPlatformInfoper desktop target).buildScript/— bash scripts invoked via./run:lib/{core,assets,source}.sh,plugin/<name>.sh,init/{env,env_ndk,version}.sh, plusrename.shfor forking under a new package name.release/{linux,macos,windows}/package.sh— invoked bymake desktop_package_*after the uber jar exists. Linux packaging usesnfpm.
- DI via Koin. Boot at
fr.husi.di.initHusiKoin(repository)(called fromApplication.onCreateon Android,DesktopMain.mainon desktop). Koin modules:commonUiModule(),commonNavigationModule, plusexpectplatformKoinModules()/platformRepositoryModule(). Repository(fr.husi.repository.Repository) is the central platform-abstraction interface ( filesystem dirs, string resources, service start/stop,boxService).SagerRepositoryis the Android impl (subclassAndroidRepository);DesktopRepositoryis the desktop impl. Always go throughRepositoryinstead of touchingContextdirectly incommonMain.database/SagerDatabase— Room database with explicitAutoMigrationchain plus customMigrationspecs indatabase/Migrations.kt. Add a new entry every time you bump the schema or KSP will fail. Schemas land incomposeApp/schemas/.bg/— service plumbing (subscription/route asset auto-update,BackendState,GuardedProcessPool,DeepLinkDispatcher). On Android this powersProxyService/VpnService/TileServicerunning in the:bgprocess; on desktop it's wired throughDesktopTaskScheduler/DesktopTaskRegistry.libcore/BoxServiceFactory.ktis theexpect/actualbridge that hands the platform a configuredServicefrom the Go core.ui/is the Compose tree. Sub-packages mirror feature areas (profile,dashboard,configuration,tools); ViewModels follow Jetpack Lifecycle. Navigation uses Composenavigation3.- Resources: shared in
composeApp/src/commonMain/composeResources/, accessed via the generatedfr.husi.resources.Res(packageOfResClass = "fr.husi.resources").
The Android app runs in two processes: the main UI process and :bg (proxy service, tile,
BootReceiver, Tasker receiver, Room invalidation service). Application.kt branches on
isMainProcess/isBgProcess and only isBgProcess calls
Libcore.initCore(shouldOperateFiles=true, ...) and boxService.start(). Code added under bg/
should respect this split.
DesktopMain.kt is a Clikt CLI: -d/--dir, -l/--log-level, -m/--many (allow multiple
instances), -b/--background, plus deep-link arguments. Uses Compose
application { Window(...) Tray(...) }. Single-instance enforcement and tray live here. Data dir
defaults to the platform config directory: Linux uses $XDG_CONFIG_HOME/husi when set, otherwise
$HOME/.config/husi; macOS uses $HOME/Library/Application Support/husi; Windows uses
%APPDATA%\husi or %USERPROFILE%\AppData\Roaming\husi. Override with -d for a custom data dir.
Canonical conventions live in CONTRIBUTING.md (English-only comments, path handling via
File.resolve and fr.husi.ktx.invariantPathString, no fully-qualified Kotlin imports, forEach
only at chain ends, also over apply when this is ambiguous, make fmt_go + make test_go
before committing Go). Read it before editing Kotlin or Go.
- Version metadata lives in
husi.properties(PACKAGE_NAME,VERSION_NAME,VERSION_CODE, plus<PLUGIN>_VERSION_*per plugin).requireMetadata()reads it from Gradle. - Signing: drop
KEYSTORE_PASS/ALIAS_NAME/ALIAS_PASSintolocal.properties(or env), and replacerelease.keystore. AFossReleasebuild with no keystore aborts viaexitProcess(0)insetupAppCommon— that's intentional, not a bug. composeApp/executableSo/is added as a JNI libs source dir for the Android app (used to bundle plugin executables alongside the host APK).- The Go module uses
golangci-lintwithGOOS=android; many files have//go:buildconstraints for android vs desktop vs darwin/linux variants — when adding platform-specific code, follow the existing*_android.go/*_darwin.go/*_linux.go/*_stub.gosplit. .gitignoreexcludes.claude/,.gemini/,.codex/, generatedcomposeApp/libs/, and submodule trees underexternal/. Build outputs (build/,*.aar,*.jarin libs,*.tar.zstassets) are also ignored.