Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 bc91c0db52 fix: about page version unknown and link crash on Android (#213)
Bug 1 – "App Version: unknown": PackageInfo.fromPlatform() throws because
plugin registration is aborted mid-way by a Java Error (e.g.
NoClassDefFoundError) that catch (Exception e) does not catch.
Fix: change all 13 catch clauses in GeneratedPluginRegistrant.java to
catch (Throwable e) and commit the file so it is used by release builds.

Bug 2 – crash when tapping sharedinbox.de: url_launcher_android 6.3.25+
migrated to Pigeon 26, which throws a channel-error on some Android
devices (same root cause as path_provider_android, already pinned).
The onTapLink callback called launchUrl via unawaited() with no error
handling, so the PlatformException became an unhandled async error and
crashed the app.
Fix: pin url_launcher_android <6.3.25 in dependency_overrides, and add
a _launchUrl() helper with full try/catch that shows a SnackBar on error
instead of crashing. New widget test verifies the snackbar path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:15:19 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 839a3c63f9 feat: keep secrets in sync via age-encrypted master key (#208)
Store all production secrets encrypted in secrets.age (committed to the
repo) using age. Only one secret needs to be in CI: SECRETS_AGE_KEY.

When a secret changes locally, update secrets.env and re-run
scripts/secrets-encrypt.sh to commit a new secrets.age. CI picks up the
updated secrets automatically on the next push — no manual CI variable
updates required.

Changes:
- scripts/secrets-encrypt.sh: encrypt secrets.env → secrets.age
- scripts/secrets-decrypt.sh: decrypt secrets.age → GITHUB_ENV (CI) or
  eval-safe export block (local)
- scripts/test_secrets.sh: encrypt/decrypt round-trip tests
- secrets.env.example: template documenting all production secret keys
- ci/main.go: add CheckSecrets function (runs test_secrets.sh via Dagger),
  wire into Check(), update Graph(); add age to toolchain apt packages
- .forgejo/Dockerfile: add age package
- .forgejo/workflows/deploy.yml: replace per-secret CI references with a
  single "Decrypt production secrets" step using SECRETS_AGE_KEY
- flake.nix: add age to dev shell
- Taskfile.yml: add check-secrets task, include in check-fast
- .gitignore: ignore plaintext secrets.env
- DAGGER.md: document Option 5 (encrypted secrets file) as active approach

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:32:27 +02:00
17 changed files with 602 additions and 43 deletions
+1
View File
@@ -10,6 +10,7 @@ FROM ghcr.io/catthehacker/ubuntu:go-24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
stunnel4 \
netcat-openbsd \
age \
&& rm -rf /var/lib/apt/lists/*
# Dagger CLI — pinned to match the engine version on the runner host
+45 -29
View File
@@ -65,6 +65,7 @@ jobs:
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
@@ -75,11 +76,15 @@ jobs:
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Run Android Tests on Firebase Test Lab
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
- name: Decrypt production secrets
if: ${{ secrets.SECRETS_AGE_KEY != '' }}
env:
SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }}
run: scripts/secrets-decrypt.sh
- name: Run Android Tests on Firebase Test Lab
if: env.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != ''
env:
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
DAGGER_NO_NAG: "1"
run: task test-android-firebase
@@ -103,6 +108,7 @@ jobs:
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
@@ -113,12 +119,15 @@ jobs:
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Publish Android to Play Store
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
- name: Decrypt production secrets
if: ${{ secrets.SECRETS_AGE_KEY != '' }}
env:
SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }}
run: scripts/secrets-decrypt.sh
- name: Publish Android to Play Store
if: env.PLAY_STORE_CONFIG_JSON != ''
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
DAGGER_NO_NAG: "1"
run: task publish-android
@@ -142,6 +151,7 @@ jobs:
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
@@ -152,15 +162,15 @@ jobs:
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Build & Deploy APK to server
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
- name: Decrypt production secrets
if: ${{ secrets.SECRETS_AGE_KEY != '' }}
env:
SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }}
run: scripts/secrets-decrypt.sh
- name: Build & Deploy APK to server
if: env.SSH_PRIVATE_KEY != ''
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
DAGGER_NO_NAG: "1"
run: task deploy-apk
@@ -184,6 +194,7 @@ jobs:
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
@@ -194,13 +205,15 @@ jobs:
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Build & Deploy Linux to server
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
- name: Decrypt production secrets
if: ${{ secrets.SECRETS_AGE_KEY != '' }}
env:
SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }}
run: scripts/secrets-decrypt.sh
- name: Build & Deploy Linux to server
if: env.SSH_PRIVATE_KEY != ''
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
DAGGER_NO_NAG: "1"
run: task deploy-linux
@@ -226,6 +239,7 @@ jobs:
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
@@ -236,13 +250,15 @@ jobs:
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Generate build history and deploy website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
- name: Decrypt production secrets
if: ${{ secrets.SECRETS_AGE_KEY != '' }}
env:
SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }}
run: scripts/secrets-decrypt.sh
- name: Generate build history and deploy website
if: env.SSH_PRIVATE_KEY != ''
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
DAGGER_NO_NAG: "1"
run: task publish-website
+3 -1
View File
@@ -22,13 +22,15 @@ assets/changelog.txt
.env.local
.envrc
.direnv/
secrets.env # plaintext secrets — encrypted version (secrets.age) is committed
# --- Android ---
android/.gradle/
android/local.properties
android/app/google-services.json
android/key.properties
android/app/src/main/java/io/flutter/plugins/
# android/app/src/main/java/io/flutter/plugins/ intentionally tracked so that
# GeneratedPluginRegistrant.java (catch Throwable) is committed and used by CI.
.android/
Android/
.gradle/
+63 -3
View File
@@ -174,10 +174,70 @@ Run a secret manager co-located with the Dagger host. The CI job authenticates w
- Vault itself becomes a security-critical single point of failure.
- Operational overhead likely disproportionate for a small single-developer project.
### Option 5: Encrypted secrets file (age) — **implemented**
Store all production secrets in a file (`secrets.env`) that is encrypted with
[age](https://age-encryption.org/) into `secrets.age`. The encrypted file is
committed to the repository. Only the age private key — a single string — is
stored in Codeberg as `SECRETS_AGE_KEY`. Any CI job or developer with the key
can decrypt the file and obtain all secrets.
**How it works:**
1. Generate a key pair once:
```bash
age-keygen -o ~/.config/age/sharedinbox.key
age-keygen -y ~/.config/age/sharedinbox.key > .age-public-key
```
2. Copy `secrets.env.example` to `secrets.env`, fill in all values, then encrypt:
```bash
scripts/secrets-encrypt.sh # reads public key from .age-public-key
git add secrets.age && git commit -m "chore: update encrypted secrets"
```
3. Add the private key content as `SECRETS_AGE_KEY` in Codeberg repository secrets.
4. CI jobs call `scripts/secrets-decrypt.sh` (with `SECRETS_AGE_KEY` set) before
any step that needs production credentials. The script writes each variable
to `$GITHUB_ENV` so subsequent steps see them automatically.
**Keeping local and CI in sync:**
When you rotate a secret locally, update `secrets.env`, re-run
`scripts/secrets-encrypt.sh`, and commit the new `secrets.age`. CI will pick
up the fresh secrets on the next push — no manual CI variable updates needed.
Multi-line values (SSH keys, certificates) must be stored as a single line
with `\n` escape sequences inside double quotes. Example:
```
SSH_PRIVATE_KEY="<header>\n<base64 key body>\n<footer>"
```
**Pro:**
- One secret (`SECRETS_AGE_KEY`) in Codeberg instead of many.
- Encrypted secrets are version-controlled — rotating a secret is a git commit.
- Local dev environment and CI always use the same encrypted source of truth.
- `age` is a simple, audited tool with no server infrastructure.
- The private key never appears in workflow files or logs.
**Con:**
- `secrets.age` exposes the list of variable *names* (visible in the encrypted
file if the format leaks, though not the values).
- All credentials share a single key — compromising `SECRETS_AGE_KEY` exposes
everything at once.
- Key rotation requires re-encrypting `secrets.age` and updating the CI secret.
### Recommendation
**Option 1** (runner-level env vars) or **Option 2** (secret files) are the pragmatic starting point for a single self-hosted runner. They require no new infrastructure and move all production secrets off Codeberg immediately.
**Option 5** (encrypted secrets file) is now the active approach. It reduces
Codeberg secrets to exactly two categories:
- **Dagger access credentials** — `DAGGER_STUNNEL_URL`, `DAGGER_CA_CERT`,
`DAGGER_CLIENT_CERT`, `DAGGER_CLIENT_KEY`.
- **Master key** — `SECRETS_AGE_KEY`.
**Option 3** (Dagger host as orchestrator) is worth considering once the trigger SSH key replaces all other secrets in Codeberg — it offers the cleanest security boundary at the cost of reduced CI observability.
**Option 1** (runner-level env vars) or **Option 2** (secret files) remain
valid if you prefer not to commit an encrypted file to the repository.
**Option 4** (Vault) becomes worthwhile if the project grows to multiple runners or team members who each need audited access to deploy credentials.
**Option 3** (Dagger host as orchestrator) is worth considering once the
trigger SSH key replaces all other secrets in Codeberg — it offers the cleanest
security boundary at the cost of reduced CI observability.
**Option 4** (Vault) becomes worthwhile if the project grows to multiple
runners or team members who each need audited access to deploy credentials.
+6 -1
View File
@@ -655,7 +655,12 @@ tasks:
check-fast:
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks]
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks, check-secrets]
check-secrets:
desc: Test secrets encrypt/decrypt scripts (requires age)
cmds:
- bash scripts/test_secrets.sh
check-layers:
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
-1
View File
@@ -4,7 +4,6 @@ gradle-wrapper.jar
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
@@ -0,0 +1,84 @@
package io.flutter.plugins;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import io.flutter.Log;
import io.flutter.embedding.engine.FlutterEngine;
/**
* Generated file. Do not edit.
* This file is generated by the Flutter tool based on the
* plugins that support the Android platform.
*/
@Keep
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.steenbakker.mobile_scanner.MobileScannerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin mobile_scanner, dev.steenbakker.mobile_scanner.MobileScannerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.crazecoder.openfile.OpenFilePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin open_filex, com.crazecoder.openfile.OpenFilePlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.share.SharePlusPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin share_plus, dev.fluttercommunity.plus.share.SharePlusPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e);
}
}
}
+21 -1
View File
@@ -183,7 +183,7 @@ func (m *Ci) toolchain() *dagger.Container {
return dag.Container().
From("ghcr.io/cirruslabs/flutter:3.41.6").
WithExec([]string{"apt-get", "-qq", "update"}).
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld", "age"}).
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
WithExec([]string{"/bin/sh", "-c",
`flutter_dir=$(dirname $(dirname $(which flutter))); ` +
@@ -381,6 +381,21 @@ func (m *Ci) CheckHygiene(ctx context.Context) (string, error) {
Stdout(ctx)
}
// CheckSecrets verifies the secrets encrypt/decrypt scripts work correctly.
func (m *Ci) CheckSecrets(ctx context.Context) (string, error) {
scriptSrc := m.Source.Filter(dagger.DirectoryFilterOpts{
Include: []string{"scripts/secrets-encrypt.sh", "scripts/secrets-decrypt.sh", "scripts/test_secrets.sh"},
})
return dag.Container().
From("ghcr.io/cirruslabs/flutter:3.41.6").
WithExec([]string{"apt-get", "-qq", "update"}).
WithExec([]string{"apt-get", "install", "-y", "-qq", "age"}).
WithDirectory("/src", scriptSrc).
WithWorkdir("/src").
WithExec([]string{"bash", "scripts/test_secrets.sh"}).
Stdout(ctx)
}
// CheckLayers enforces that ui/ does not import data/.
func (m *Ci) CheckLayers(ctx context.Context) (string, error) {
return m.Base().
@@ -471,6 +486,9 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
if _, err := m.CheckLayers(ctx); err != nil {
return "Layer check failed", err
}
if _, err := m.CheckSecrets(ctx); err != nil {
return "Secrets script check failed", err
}
checkSetup := m.setup(m.checkSrc())
@@ -821,6 +839,7 @@ flowchart TD
pubGet --> hygiene["CheckHygiene"]
pubGet --> layers["CheckLayers"]
pubGet --> secrets["CheckSecrets\nage encrypt/decrypt"]
pubGet --> mocks["CheckMocks\n(own build_runner run)"]
codegen --> fmt["Format"]
@@ -834,6 +853,7 @@ flowchart TD
hygiene --> check{{"✓ Check"}}
layers --> check
secrets --> check
fmt --> check
analyze --> check
mocks --> check
+3
View File
@@ -87,6 +87,9 @@
# Website
hugo
# Secrets management (master-key encryption for CI sync)
age
# Utilities
git
curl
+25 -4
View File
@@ -95,6 +95,30 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
}
}
Future<void> _launchUrl(BuildContext context, Uri url) async {
try {
final launched =
await launchUrl(url, mode: LaunchMode.externalApplication);
if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Could not open browser.'),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text('Error: $e'),
),
);
}
}
}
Future<void> _createIssue(
BuildContext context,
int imapCount,
@@ -167,10 +191,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
onTapLink: (text, href, title) {
if (href != null) {
unawaited(
launchUrl(
Uri.parse(href),
mode: LaunchMode.externalApplication,
),
_launchUrl(context, Uri.parse(href)),
);
}
},
+3 -3
View File
@@ -1117,13 +1117,13 @@ packages:
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
dependency: "direct overridden"
description:
name: url_launcher_android
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
url: "https://pub.dev"
source: hosted
version: "6.3.30"
version: "6.3.24"
url_launcher_ios:
dependency: transitive
description:
+4
View File
@@ -89,3 +89,7 @@ dependency_overrides:
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
# stable Pigeon and is known to work reliably.
path_provider_android: ">=2.2.0 <2.2.21"
# url_launcher_android 6.3.25 updated to Pigeon 26, which causes a
# channel-error on launchUrl on some Android devices (same root cause as
# path_provider_android). Pin to <6.3.25 which uses stable Pigeon.
url_launcher_android: ">=6.3.0 <6.3.25"
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# Decrypts secrets.age and exports all KEY=VALUE pairs as environment variables.
#
# In CI (GITHUB_ENV set): writes to $GITHUB_ENV so subsequent job steps can
# read the variables. Multi-line values use the heredoc syntax required by
# Forgejo/GitHub Actions.
#
# Locally: prints an eval-safe export block to stdout. Source it with:
# eval "$(SECRETS_AGE_KEY=$(cat ~/.config/age/sharedinbox.key) scripts/secrets-decrypt.sh)"
# or pass a key file:
# eval "$(scripts/secrets-decrypt.sh ~/.config/age/sharedinbox.key)"
#
# Private key sources (first match wins):
# 1. Path to a key file passed as $1
# 2. SECRETS_AGE_KEY env var (the raw private key content — used in CI)
set -euo pipefail
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) \
|| REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd)
SECRETS_AGE="${SECRETS_AGE:-${REPO_ROOT}/secrets.age}"
if [ ! -f "$SECRETS_AGE" ]; then
echo "ERROR: secrets.age not found at $SECRETS_AGE" >&2
echo " Run: scripts/secrets-encrypt.sh to create it." >&2
exit 1
fi
TMP_KEY=""
cleanup() { [ -n "$TMP_KEY" ] && rm -f "$TMP_KEY"; }
trap cleanup EXIT
if [ -n "${1:-}" ]; then
KEY_FILE="$1"
elif [ -n "${SECRETS_AGE_KEY:-}" ]; then
TMP_KEY=$(mktemp)
chmod 600 "$TMP_KEY"
printf '%s\n' "$SECRETS_AGE_KEY" > "$TMP_KEY"
KEY_FILE="$TMP_KEY"
else
echo "ERROR: No age private key provided." >&2
echo " Pass a key file: scripts/secrets-decrypt.sh ~/.config/age/sharedinbox.key" >&2
echo " Or set SECRETS_AGE_KEY env var (CI: store as SECRETS_AGE_KEY secret)." >&2
exit 1
fi
DECRYPTED=$(age --decrypt -i "$KEY_FILE" "$SECRETS_AGE")
# Process each KEY=VALUE line.
# Double-quoted values have \n escape sequences converted to real newlines.
process_secrets() {
local line key raw_value value
while IFS= read -r line; do
[[ -z "$line" || "$line" == \#* ]] && continue
[[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || continue
key="${line%%=*}"
raw_value="${line#*=}"
# Double-quoted: strip quotes and expand \n → newline
if [[ "$raw_value" == '"'*'"' ]]; then
raw_value="${raw_value:1:${#raw_value}-2}"
value=$(printf '%b' "$raw_value")
# Single-quoted: strip quotes, no expansion
elif [[ "$raw_value" == "'"*"'" ]]; then
value="${raw_value:1:${#raw_value}-2}"
else
value="$raw_value"
fi
if [ -n "${GITHUB_ENV:-}" ]; then
# Heredoc syntax handles multi-line values safely
local delim="EOF_${key}_$$"
printf '%s<<%s\n%s\n%s\n' "$key" "$delim" "$value" "$delim" >> "$GITHUB_ENV"
else
# Print as export statements for eval
printf "export %s=%q\n" "$key" "$value"
fi
done <<< "$DECRYPTED"
}
process_secrets
if [ -n "${GITHUB_ENV:-}" ]; then
echo "Secrets written to \$GITHUB_ENV." >&2
fi
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Encrypts secrets.env → secrets.age using an age public key.
#
# Usage:
# scripts/secrets-encrypt.sh [AGE1...] public key as positional argument
# AGE_PUBLIC_KEY=AGE1... scripts/secrets-encrypt.sh
# scripts/secrets-encrypt.sh reads public key from .age-public-key
#
# The private key never touches this script. Only the public key is needed to
# encrypt. Store the private key in CI as SECRETS_AGE_KEY and keep a local
# copy at ~/.config/age/sharedinbox.key (or wherever you prefer).
set -euo pipefail
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) \
|| REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd)
SECRETS_ENV="${SECRETS_ENV:-${REPO_ROOT}/secrets.env}"
SECRETS_AGE="${SECRETS_AGE:-${REPO_ROOT}/secrets.age}"
KEY_FILE="${REPO_ROOT}/.age-public-key"
if [ -n "${1:-}" ]; then
PUBLIC_KEY="$1"
elif [ -n "${AGE_PUBLIC_KEY:-}" ]; then
PUBLIC_KEY="$AGE_PUBLIC_KEY"
elif [ -f "$KEY_FILE" ]; then
PUBLIC_KEY=$(cat "$KEY_FILE")
PUBLIC_KEY="${PUBLIC_KEY%%$'\n'*}" # take only the first line
else
echo "ERROR: No age public key provided." >&2
echo " Pass it as an argument: scripts/secrets-encrypt.sh AGE1..." >&2
echo " Or store it in .age-public-key: age-keygen -y ~/.config/age/sharedinbox.key > .age-public-key" >&2
exit 1
fi
if [ ! -f "$SECRETS_ENV" ]; then
echo "ERROR: secrets.env not found at $SECRETS_ENV" >&2
echo " Copy secrets.env.example to secrets.env and fill in values." >&2
exit 1
fi
age --encrypt --recipient "$PUBLIC_KEY" --output "$SECRETS_AGE" "$SECRETS_ENV"
echo "Encrypted $SECRETS_ENV$SECRETS_AGE"
echo "Commit secrets.age to keep CI in sync."
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# Tests for scripts/secrets-encrypt.sh and scripts/secrets-decrypt.sh.
# Run directly: bash scripts/test_secrets.sh
# Requires: age, age-keygen
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PASS=0
FAIL=0
_assert() {
local name="$1" expected="$2" actual="$3"
if [ "$actual" = "$expected" ]; then
PASS=$((PASS + 1))
else
echo "FAIL: $name"
echo " expected: $(printf '%s' "$expected" | head -c 80)"
echo " actual: $(printf '%s' "$actual" | head -c 80)"
FAIL=$((FAIL + 1))
fi
}
_assert_contains() {
local name="$1" needle="$2" haystack="$3"
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
PASS=$((PASS + 1))
else
echo "FAIL: $name"
echo " expected to contain: $needle"
echo " actual: $(printf '%s' "$haystack" | head -c 200)"
FAIL=$((FAIL + 1))
fi
}
if ! command -v age >/dev/null 2>&1 || ! command -v age-keygen >/dev/null 2>&1; then
echo "SKIP: age/age-keygen not found — install age to run secrets tests"
exit 0
fi
WORKDIR=$(mktemp -d)
cleanup() { rm -rf "$WORKDIR"; }
trap cleanup EXIT
KEY_FILE="$WORKDIR/test.key"
SECRETS_ENV="$WORKDIR/secrets.env"
SECRETS_AGE="$WORKDIR/secrets.age"
GITHUB_ENV_FILE="$WORKDIR/github.env"
# Generate a test age key pair
age-keygen -o "$KEY_FILE" 2>/dev/null
PUBLIC_KEY=$(age-keygen -y "$KEY_FILE")
PRIVATE_KEY=$(cat "$KEY_FILE")
# Helper: decrypt and eval, capturing specific variables
_decrypt_vars() {
local vars
vars=$(SECRETS_AGE_KEY="$PRIVATE_KEY" \
SECRETS_AGE="$SECRETS_AGE" \
bash "$SCRIPT_DIR/secrets-decrypt.sh")
eval "$vars"
}
# --- simple values ---
cat > "$SECRETS_ENV" << 'EOF'
SIMPLE_VAR=hello
QUOTED_DOUBLE="world"
QUOTED_SINGLE='literal'
EMPTY_VAR=
# comment line — should be ignored
NUMERIC=42
EOF
AGE_PUBLIC_KEY="$PUBLIC_KEY" \
SECRETS_ENV="$SECRETS_ENV" \
SECRETS_AGE="$SECRETS_AGE" \
bash "$SCRIPT_DIR/secrets-encrypt.sh"
_decrypt_vars
_assert "simple value" "hello" "${SIMPLE_VAR:-}"
_assert "double-quoted value" "world" "${QUOTED_DOUBLE:-}"
_assert "single-quoted value" "literal" "${QUOTED_SINGLE:-}"
_assert "empty value" "" "${EMPTY_VAR:-}"
_assert "numeric value" "42" "${NUMERIC:-}"
unset SIMPLE_VAR QUOTED_DOUBLE QUOTED_SINGLE EMPTY_VAR NUMERIC
# --- multi-line value with \n escape sequences ---
# Use a made-up key format to avoid triggering the detect-private-key pre-commit hook.
printf '%s\n' \
'SSH_KEY="FAKE-KEY-HEADER\nfakekey\nFAKE-KEY-FOOTER"' \
'SIDE=plain' \
> "$SECRETS_ENV"
rm -f "$SECRETS_AGE"
AGE_PUBLIC_KEY="$PUBLIC_KEY" \
SECRETS_ENV="$SECRETS_ENV" \
SECRETS_AGE="$SECRETS_AGE" \
bash "$SCRIPT_DIR/secrets-encrypt.sh"
_decrypt_vars
_assert_contains "multi-line: header present" "FAKE-KEY-HEADER" "${SSH_KEY:-}"
_assert_contains "multi-line: body present" "fakekey" "${SSH_KEY:-}"
_assert_contains "multi-line: footer present" "FAKE-KEY-FOOTER" "${SSH_KEY:-}"
_assert "variable alongside multi-line" "plain" "${SIDE:-}"
unset SSH_KEY SIDE
# --- GITHUB_ENV output uses heredoc syntax ---
printf '%s\n' 'CI_SECRET=supersecret' > "$SECRETS_ENV"
rm -f "$SECRETS_AGE" "$GITHUB_ENV_FILE"
AGE_PUBLIC_KEY="$PUBLIC_KEY" \
SECRETS_ENV="$SECRETS_ENV" \
SECRETS_AGE="$SECRETS_AGE" \
bash "$SCRIPT_DIR/secrets-encrypt.sh"
GITHUB_ENV="$GITHUB_ENV_FILE" \
SECRETS_AGE_KEY="$PRIVATE_KEY" \
SECRETS_AGE="$SECRETS_AGE" \
bash "$SCRIPT_DIR/secrets-decrypt.sh"
_assert_contains "GITHUB_ENV contains key" "CI_SECRET" "$(cat "$GITHUB_ENV_FILE")"
_assert_contains "GITHUB_ENV contains value" "supersecret" "$(cat "$GITHUB_ENV_FILE")"
# --- missing secrets.age exits non-zero with a helpful message ---
ERR=$(SECRETS_AGE="$WORKDIR/nonexistent.age" \
SECRETS_AGE_KEY="$PRIVATE_KEY" \
bash "$SCRIPT_DIR/secrets-decrypt.sh" 2>&1) && GOT=0 || GOT=$?
_assert "missing secrets.age: exits non-zero" "1" "$GOT"
_assert_contains "missing secrets.age: error mentions file" "secrets.age" "$ERR"
# --- missing key exits non-zero ---
ERR=$(SECRETS_AGE="$SECRETS_AGE" \
bash "$SCRIPT_DIR/secrets-decrypt.sh" 2>&1) && GOT=0 || GOT=$?
_assert "missing key: exits non-zero" "1" "$GOT"
# --- wrong key fails decryption ---
OTHER_KEY="$WORKDIR/other.key"
age-keygen -o "$OTHER_KEY" 2>/dev/null
ERR=$(SECRETS_AGE_KEY=$(cat "$OTHER_KEY") \
SECRETS_AGE="$SECRETS_AGE" \
bash "$SCRIPT_DIR/secrets-decrypt.sh" 2>&1) && GOT=0 || GOT=$?
_assert "wrong key: exits non-zero" "1" "$GOT"
# --- encrypt without secrets.env exits non-zero ---
ERR=$(AGE_PUBLIC_KEY="$PUBLIC_KEY" \
SECRETS_ENV="$WORKDIR/missing_secrets.env" \
SECRETS_AGE="$WORKDIR/out.age" \
bash "$SCRIPT_DIR/secrets-encrypt.sh" 2>&1) && GOT=0 || GOT=$?
_assert "encrypt without secrets.env: exits non-zero" "1" "$GOT"
_assert_contains "encrypt without secrets.env: error mentions file" "secrets.env" "$ERR"
echo ""
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1
+28
View File
@@ -0,0 +1,28 @@
# Copy this file to secrets.env and fill in real values.
# Then encrypt to secrets.age: scripts/secrets-encrypt.sh
#
# secrets.env — plaintext, git-ignored
# secrets.age — encrypted, committed to the repository
# .age-public-key — age public key, committed (not secret)
#
# Multi-line values (SSH keys, certificates) must be stored as a single line
# with literal \n for newlines, wrapped in double quotes. Example:
# SSH_PRIVATE_KEY="<header line>\n<base64 body lines>\n<footer line>"
#
# One-time setup:
# age-keygen -o ~/.config/age/sharedinbox.key
# age-keygen -y ~/.config/age/sharedinbox.key > .age-public-key
# # Store the private key content in CI as SECRETS_AGE_KEY secret.
ANDROID_KEYSTORE_BASE64=
ANDROID_KEYSTORE_PASSWORD=
PLAY_STORE_CONFIG_JSON=
SSH_PRIVATE_KEY=
SSH_KNOWN_HOSTS=
SSH_USER=
SSH_HOST=
ANDROID_APK_SCP_HOST=
ANDROID_APK_SCP_USER=
ANDROID_APK_SCP_PATH=
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY=
FIREBASE_PROJECT_ID=
+36
View File
@@ -27,6 +27,22 @@ class MockUrlLauncher extends Mock
}
}
class ThrowingUrlLauncher extends Mock
with MockPlatformInterfaceMixin
implements UrlLauncherPlatform {
@override
Future<bool> canLaunch(String? url) async => true;
@override
Future<bool> launchUrl(String? url, LaunchOptions? options) async {
throw PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: '
'"dev.flutter.pigeon.url_launcher_android.UrlLauncherApi.launchUrl".',
);
}
}
Widget _buildScreen({List<Account> accounts = const []}) {
return ProviderScope(
overrides: [
@@ -180,4 +196,24 @@ void main() {
);
expect(mock.launchedUrl, contains('1.2.3%2B99'));
});
testWidgets(
'AboutScreen link tap with failed url_launcher shows error snackbar',
(tester) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
UrlLauncherPlatform.instance = ThrowingUrlLauncher();
await tester.pumpWidget(_buildScreen());
await tester.pumpAndSettle();
await tester.tap(find.textContaining('sharedinbox.de').first);
await tester.pumpAndSettle();
expect(find.textContaining('Error:'), findsOneWidget);
},
);
}