Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 <<EOF
# Generated by .github/workflows/release.yml. DO NOT EDIT.
class PorterSender < Formula
desc "Air-gapped file transfer: sends a file as a QR slideshow in your terminal"
homepage "https://porter.goranninkovic.com"
version "${VERSION}"
license "MIT"

on_macos do
if Hardware::CPU.arm?
url "${BASE}/porter-sender_${VERSION}_aarch64-apple-darwin.tar.gz"
sha256 "$(sha aarch64-apple-darwin)"
end
if Hardware::CPU.intel?
url "${BASE}/porter-sender_${VERSION}_x86_64-apple-darwin.tar.gz"
sha256 "$(sha x86_64-apple-darwin)"
end
end

on_linux do
if Hardware::CPU.arm? && Hardware::CPU.is_64_bit?
url "${BASE}/porter-sender_${VERSION}_aarch64-unknown-linux-gnu.tar.gz"
sha256 "$(sha aarch64-unknown-linux-gnu)"
end
if Hardware::CPU.intel? && Hardware::CPU.is_64_bit?
url "${BASE}/porter-sender_${VERSION}_x86_64-unknown-linux-gnu.tar.gz"
sha256 "$(sha x86_64-unknown-linux-gnu)"
end
end

def install
bin.install "porter-sender"
end

test do
# The sender itself is a TUI and needs a real terminal, so the
# smoke test drives \`join\`, which is plain file I/O. It takes
# the transfer *directory* — the base name comes from that.
(testpath/"demo").mkpath
(testpath/"demo/demo.partaa").write "hello "
(testpath/"demo/demo.partab").write "world"
system "#{bin}/porter-sender", "join", "demo", "--output", "out.txt"
assert_equal "hello world", (testpath/"out.txt").read
end
end
EOF

- name: Push it to the tap
env:
# Same secret forge's goreleaser uses — a PAT with write access to
# homebrew-tap. GITHUB_TOKEN cannot reach another repository.
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
run: |
TAG="${{ inputs.tag || github.event.release.tag_name }}"
gh repo clone TerrorSquad/homebrew-tap tap -- --depth=1
cp porter-sender.rb tap/Formula/porter-sender.rb
cd tap
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Formula/porter-sender.rb
git diff --staged --quiet || git commit -m "porter-sender ${TAG}"
git push
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
103 changes: 103 additions & 0 deletions docs/RELEASING-ANDROID.md
Original file line number Diff line number Diff line change
@@ -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=<the store password>
keyPassword=<the key password>
keyAlias=porter
storeFile=/absolute/path/to/porter-upload.jks
```

<!-- ponytail: file-based key config, the standard Flutter pattern. Move to
env vars only if this ever builds on CI. -->

> **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 `<paste from keytool>` — 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.
37 changes: 33 additions & 4 deletions flutter/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -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")
}
}
}
}
Expand Down
13 changes: 13 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading