From 5e53b1b24d8060121921a0df9f8557839307a618 Mon Sep 17 00:00:00 2001 From: Goran Ninkovic Date: Mon, 10 Aug 2026 12:11:21 +0200 Subject: [PATCH 1/3] docs: add the MIT LICENSE file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other repo in the org carries one; this one never did. Without it the code is technically unlicensed, so nobody can legally use it — worth fixing before the repo gets any attention. MIT, matching gstack and forge. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LAsieST4zZGygM4WwsEuSH --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ffbb86f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Goran Ninkovic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 524a457e6aa8745c256543109c36960142dc5939 Mon Sep 17 00:00:00 2001 From: Goran Ninkovic Date: Mon, 10 Aug 2026 12:11:33 +0200 Subject: [PATCH 2/3] feat(flutter): make the Android receiver releasable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The receiver had no publishable build path: Flutter's template signs release builds with the debug keystore, which is generated per machine and expires after a year. Android refuses an update signed with a different key, so an APK published that way would strand everyone who installed it. Release signing is now opt-in through a gitignored android/key.properties. When it is absent — a fresh clone, or CI — the build falls back to the debug keystore as before, so `flutter build apk --release` still works for anyone building for themselves. Only a published APK needs the key. Adds `mise run flutter-apk` (universal) and `flutter-apk-split` (per-ABI, roughly a third the size each), and docs/RELEASING-ANDROID.md covering keystore setup, verifying which key actually signed an APK, and the Gatekeeper-equivalent wording for the release notes. This makes Android the receiver a stranger can install without any Apple Developer relationship — macOS notarization stays a separate concern. Verified: arm64 25.2 MB, armeabi-v7a 21.0 MB, x86_64 27.7 MB, universal 68.6 MB. apksigner confirms the fallback path still yields CN=Android Debug, as documented. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LAsieST4zZGygM4WwsEuSH --- README.md | 6 ++ docs/RELEASING-ANDROID.md | 103 +++++++++++++++++++++++++++ flutter/android/app/build.gradle.kts | 37 ++++++++-- mise.toml | 13 ++++ 4 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 docs/RELEASING-ANDROID.md diff --git a/README.md b/README.md index ea1d2cf..34fea5f 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,14 @@ mise run rust-build # cargo build --release mise run rust-test # cargo test mise run rust-check # cargo clippy + cargo fmt --check mise run flutter-build # flutter build macos --release +mise run flutter-apk # flutter build apk --release (universal) +mise run flutter-apk-split # one APK per ABI, ~1/3 the size ``` +Publishing the Android app — keystore, self-signing, and what to put in the +release notes — is covered in +[`docs/RELEASING-ANDROID.md`](docs/RELEASING-ANDROID.md). + See [CONTRIBUTING.md](CONTRIBUTING.md) for prerequisites and conventions, and [`docs/adr/`](docs/adr/) for the architectural decisions behind the receiver's worker isolate, the fountain codec, and the sender's Rust move. diff --git a/docs/RELEASING-ANDROID.md b/docs/RELEASING-ANDROID.md new file mode 100644 index 0000000..372edc3 --- /dev/null +++ b/docs/RELEASING-ANDROID.md @@ -0,0 +1,103 @@ +# Releasing the Android receiver + +Porter's receiver ships as a **self-signed APK on GitHub Releases**. There is +no Play Store listing and no Apple-style notarization step — the only thing +required is a keystore you generate yourself and keep. + +Anyone can build the app without any of this: `flutter build apk --release` +falls back to the debug keystore when `android/key.properties` is absent. That +fallback is fine for building for yourself and **not** fine for publishing — +see [Why not the debug key](#why-not-the-debug-key). + +## Prerequisite: JDK 17 + +Gradle 8.12 cannot parse a Java 25 version string and fails with a bare +`IllegalArgumentException: 25.0.2`, which Flutter surfaces as a build error +reading only `25.0.2`. If Flutter picks up Android Studio's bundled JDK 25, +point it at a 17 instead: + +```sh +flutter config --jdk-dir="$(/usr/libexec/java_home -v 17)" +``` + +This is per-machine config, not a repo setting. `flutter doctor -v` shows +which JDK is in use. + +## One-time: create the keystore + +```sh +keytool -genkey -v \ + -keystore ~/porter-upload.jks \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -alias porter +``` + +Then create `flutter/android/key.properties` (gitignored, never commit it): + +```properties +storePassword= +keyPassword= +keyAlias=porter +storeFile=/absolute/path/to/porter-upload.jks +``` + + + +> **Back up `porter-upload.jks` and its passwords.** Losing the keystore means +> no existing install can ever be upgraded — every user has to uninstall and +> reinstall, losing app data. There is no recovery path. + +## Build + +```sh +mise run flutter-apk # universal APK → build/app/outputs/flutter-apk/ +mise run flutter-apk-split # per-ABI APKs, ~1/3 the size each +``` + +Split APKs are what you attach to a release: a universal APK carries arm64, +armeabi and x86_64 native code, and virtually every real phone is +`arm64-v8a`. Attach the universal one too, as the no-guesswork fallback. + +Verify what you built: + +```sh +# Confirm it is NOT the debug key. Should print your CN, not "Android Debug". +keytool -printcert -jarfile build/app/outputs/flutter-apk/app-arm64-v8a-release.apk +``` + +## Publish + +Attach the APKs to the GitHub Release. In the release notes, tell people what +Android will show them: + +> Android will warn that this APK is from an unknown developer, because it is +> signed with our own key rather than distributed through the Play Store. You +> will need to allow installation from your browser or file manager. The +> SHA-256 fingerprint of the signing key is `` — it will +> not change between releases. + +Publishing the fingerprint is what makes self-signing trustworthy: it lets +anyone confirm a later APK came from the same key. + +## Why not the debug key + +Flutter's template signs release builds with the debug keystore. That build +runs, which is why it is a usable fallback, but it must never be published: + +- The debug keystore is generated per machine, so builds from two machines are + mutually un-upgradable. +- It expires after 365 days. +- Android refuses to install an update signed with a different key than the + installed version. + +An APK published with a debug key strands every person who installs it. + +## Why no Play Store + +Play requires a Google Play Developer account ($25), and since August 2023 new +personal developer accounts must complete a 14-day closed test with 12 testers +before production access. For a tool aimed at people transferring files across +an air gap, a directly downloadable APK is the better distribution channel +anyway. F-Droid is a reasonable future option — it needs a reproducible build +and a metadata submission, and it is not a blocker for launch. diff --git a/flutter/android/app/build.gradle.kts b/flutter/android/app/build.gradle.kts index 28d6f97..00cdaa9 100644 --- a/flutter/android/app/build.gradle.kts +++ b/flutter/android/app/build.gradle.kts @@ -1,9 +1,22 @@ +import java.util.Properties + plugins { id("com.android.application") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } +// Release signing is opt-in: `android/key.properties` is gitignored and absent +// on a fresh clone and in CI. When it is missing the release build falls back +// to the debug keystore, so `flutter build apk --release` still works for +// anyone building for themselves. Only the published APK needs the real key — +// see docs/RELEASING-ANDROID.md. +val keystoreProperties = Properties().apply { + val f = rootProject.file("key.properties") + if (f.exists()) f.inputStream().use { load(it) } +} +val hasReleaseKeystore = keystoreProperties.getProperty("storeFile") != null + android { namespace = "com.goranninkovic.porterrx" compileSdk = flutter.compileSdkVersion @@ -15,7 +28,6 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.goranninkovic.porterrx" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. @@ -25,11 +37,28 @@ android { versionName = flutter.versionName } + signingConfigs { + if (hasReleaseKeystore) { + create("release") { + storeFile = rootProject.file(keystoreProperties.getProperty("storeFile")) + storePassword = keystoreProperties.getProperty("storePassword") + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // The debug fallback is deliberate, but it is NOT publishable: the + // debug keystore is generated per machine and expires after a year, + // and Android refuses an update signed with a different key. A + // published APK signed this way could never be upgraded. + signingConfig = if (hasReleaseKeystore) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } } diff --git a/mise.toml b/mise.toml index aeae109..5ff7c6a 100644 --- a/mise.toml +++ b/mise.toml @@ -20,6 +20,19 @@ ARCHS=arm64 \ sh macos/strip_x86_64.sh """ +[tasks.flutter-apk] +dir = "flutter" +# Universal APK — every ABI in one file. Bigger, but the safe download when +# you don't know the target device. Falls back to the debug keystore unless +# android/key.properties exists; see docs/RELEASING-ANDROID.md. +run = "flutter build apk --release" + +[tasks.flutter-apk-split] +dir = "flutter" +# One APK per ABI, roughly a third the size each. These are what to attach to +# a GitHub Release; virtually every real phone wants arm64-v8a. +run = "flutter build apk --release --split-per-abi" + [tasks.flutter-test] dir = "flutter" run = "flutter analyze && flutter test" From 21a4140a41f1450f680a6975f2e74d212320aacc Mon Sep 17 00:00:00 2001 From: Goran Ninkovic Date: Mon, 10 Aug 2026 12:28:08 +0200 Subject: [PATCH 3/3] ci: build sender binaries on release and publish the Homebrew formula MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README calls the sender "a single static binary", but the repo shipped none — the only install path was `cargo build --release`, which needs a Rust toolchain on the very machine most likely to be locked down. On a published release this cross-compiles porter-sender for macOS (arm64/x86_64) and Linux (arm64/x86_64), attaches the tarballs, then regenerates Formula/porter-sender.rb in TerrorSquad/homebrew-tap with the real checksums. Mirrors what goreleaser already does for forge, hand-rolled because goreleaser is Go-only. The formula's test drives `join` rather than `--version`: the sender is a ratatui TUI that panics without a real terminal, and there is no --version flag (it exits 1). `join` is plain file I/O, so it works in Homebrew's sandbox. Requires the HOMEBREW_TAP_GITHUB_TOKEN secret on this repo — GITHUB_TOKEN cannot write to another repository. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LAsieST4zZGygM4WwsEuSH --- .github/workflows/release.yml | 148 ++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..494e166 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,148 @@ +name: release + +# release-please cuts the tag; this builds the sender for each platform, +# attaches the tarballs to the release, and points the Homebrew tap at them. +on: + release: + types: [published] + # Lets a failed or re-run release be rebuilt without cutting a new tag. + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to build (e.g. v2.9.0)" + required: true + +permissions: + contents: write + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-apple-darwin + os: macos-latest + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + - target: aarch64-unknown-linux-gnu + os: ubuntu-24.04-arm + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: rust-sender + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + + - name: Install the Rust toolchain + run: rustup target add ${{ matrix.target }} + + - run: cargo build --release --locked --target ${{ matrix.target }} + + - name: Package the binary + id: package + run: | + TAG="${{ inputs.tag || github.event.release.tag_name }}" + VERSION="${TAG#v}" + NAME="porter-sender_${VERSION}_${{ matrix.target }}.tar.gz" + tar -czf "$NAME" -C "target/${{ matrix.target }}/release" porter-sender + echo "name=$NAME" >> "$GITHUB_OUTPUT" + # Homebrew verifies this; the formula job reads it back from here. + shasum -a 256 "$NAME" | cut -d" " -f1 > "$NAME.sha256" + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.target }} + path: | + rust-sender/${{ steps.package.outputs.name }} + rust-sender/${{ steps.package.outputs.name }}.sha256 + + - name: Attach to the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ inputs.tag || github.event.release.tag_name }}" \ + "${{ steps.package.outputs.name }}" --clobber + + formula: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + + - name: Write the formula + run: | + TAG="${{ inputs.tag || github.event.release.tag_name }}" + VERSION="${TAG#v}" + BASE="https://github.com/TerrorSquad/porter/releases/download/${TAG}" + + sha() { cat "dist/$1/porter-sender_${VERSION}_$1.tar.gz.sha256"; } + + cat > porter-sender.rb <