Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
902b9bfbce | ||
|
|
357ed9af31 | ||
|
|
96b1660b59 | ||
|
|
e7ff9243c9 | ||
|
|
d51e67ddcc | ||
|
|
43068509d2 | ||
|
|
d9b8748631 | ||
|
|
50ae7df8a3 | ||
|
|
7dd5800064 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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/
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-3
@@ -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))); ` +
|
||||
@@ -195,7 +195,8 @@ func (m *Ci) toolchain() *dagger.Container {
|
||||
WithUser("ci").
|
||||
WithExec([]string{"/bin/sh", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`})
|
||||
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`}).
|
||||
WithExec([]string{"flutter", "precache", "--linux", "--no-android", "--no-ios"})
|
||||
}
|
||||
|
||||
// Base is the Flutter toolchain container with mutable cache mounts attached.
|
||||
@@ -380,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().
|
||||
@@ -470,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())
|
||||
|
||||
@@ -810,7 +829,7 @@ func (m *Ci) Graph() string {
|
||||
` + "```" + `mermaid
|
||||
flowchart TD
|
||||
subgraph dagger ["Dagger · Check pipeline"]
|
||||
toolchain["toolchain\nflutter:3.41.6 + NDK + apt"]
|
||||
toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"]
|
||||
pubGet["pubGetLayer\nflutter pub get"]
|
||||
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
||||
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
||||
@@ -820,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"]
|
||||
@@ -833,6 +853,7 @@ flowchart TD
|
||||
|
||||
hygiene --> check{{"✓ Check"}}
|
||||
layers --> check
|
||||
secrets --> check
|
||||
fmt --> check
|
||||
analyze --> check
|
||||
mocks --> check
|
||||
|
||||
@@ -87,6 +87,9 @@
|
||||
# Website
|
||||
hugo
|
||||
|
||||
# Secrets management (master-key encryption for CI sync)
|
||||
age
|
||||
|
||||
# Utilities
|
||||
git
|
||||
curl
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
|
||||
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
_Step _step = _Step.generatingKey;
|
||||
ShareKeyMaterial? _keyMaterial;
|
||||
DateTime? _keyExpiresAt;
|
||||
String? _pubKeyQr;
|
||||
String? _errorMessage;
|
||||
bool _scannerActive = false;
|
||||
@@ -64,6 +65,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
);
|
||||
setState(() {
|
||||
_keyMaterial = material;
|
||||
_keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
|
||||
_pubKeyQr = qr;
|
||||
_step = _Step.showingPubKey;
|
||||
});
|
||||
@@ -85,22 +87,24 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight: start + stop the scanner to verify the plugin is available.
|
||||
// Falls back to text entry on any exception (including MissingPluginException).
|
||||
// Pre-flight: probe the scanner's permission-state method to verify the
|
||||
// plugin is registered. MissingPluginException is thrown on Android builds
|
||||
// where the plugin is not linked (issue #204). All other exceptions mean
|
||||
// the plugin exists but something else failed — the MobileScanner widget
|
||||
// will surface those via its own error builder.
|
||||
Future<void> _initScanner() async {
|
||||
MobileScannerController? ctrl;
|
||||
bool available = false;
|
||||
try {
|
||||
ctrl = MobileScannerController();
|
||||
await ctrl.start();
|
||||
await ctrl.stop();
|
||||
await const MethodChannel(
|
||||
'dev.steenbakker.mobile_scanner/scanner/method',
|
||||
).invokeMethod<int>('state');
|
||||
available = true;
|
||||
} on MissingPluginException {
|
||||
// Plugin not registered on this device; text fallback will be shown.
|
||||
} catch (_) {
|
||||
// Plugin not available on this device; text fallback will be shown.
|
||||
} finally {
|
||||
try {
|
||||
await ctrl?.dispose();
|
||||
} catch (_) {}
|
||||
// Plugin registered but state check failed; let the scanner widget
|
||||
// handle it via its errorBuilder.
|
||||
available = true;
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (available) {
|
||||
@@ -274,7 +278,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const _ExpiryHint(),
|
||||
_ExpiryHint(expiresAt: _keyExpiresAt!),
|
||||
const SizedBox(height: 32),
|
||||
if (_errorMessage != null) ...[
|
||||
Text(
|
||||
@@ -404,8 +408,37 @@ bool _cameraScanSupported() =>
|
||||
Platform.isMacOS ||
|
||||
Platform.isWindows;
|
||||
|
||||
class _ExpiryHint extends StatelessWidget {
|
||||
const _ExpiryHint();
|
||||
class _ExpiryHint extends StatefulWidget {
|
||||
const _ExpiryHint({required this.expiresAt});
|
||||
|
||||
final DateTime expiresAt;
|
||||
|
||||
@override
|
||||
State<_ExpiryHint> createState() => _ExpiryHintState();
|
||||
}
|
||||
|
||||
class _ExpiryHintState extends State<_ExpiryHint> {
|
||||
late Timer _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _formatRemaining() {
|
||||
final remaining = widget.expiresAt.difference(DateTime.now().toUtc());
|
||||
if (remaining.isNegative) return 'expired';
|
||||
final minutes = remaining.inMinutes;
|
||||
final seconds = remaining.inSeconds % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -415,7 +448,7 @@ class _ExpiryHint extends StatelessWidget {
|
||||
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'This key expires in 20 minutes',
|
||||
'This key expires in ${_formatRemaining()}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -57,22 +57,24 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight: start + stop the scanner to verify the plugin is available.
|
||||
// Falls back to text entry on any exception (including MissingPluginException).
|
||||
// Pre-flight: probe the scanner's permission-state method to verify the
|
||||
// plugin is registered. MissingPluginException is thrown on Android builds
|
||||
// where the plugin is not linked (issue #204). All other exceptions mean
|
||||
// the plugin exists but something else failed — the MobileScanner widget
|
||||
// will surface those via its own error builder.
|
||||
Future<void> _initScanner() async {
|
||||
MobileScannerController? ctrl;
|
||||
bool available = false;
|
||||
try {
|
||||
ctrl = MobileScannerController();
|
||||
await ctrl.start();
|
||||
await ctrl.stop();
|
||||
await const MethodChannel(
|
||||
'dev.steenbakker.mobile_scanner/scanner/method',
|
||||
).invokeMethod<int>('state');
|
||||
available = true;
|
||||
} on MissingPluginException {
|
||||
// Plugin not registered on this device; text fallback will be shown.
|
||||
} catch (_) {
|
||||
// Plugin not available on this device; text fallback will be shown.
|
||||
} finally {
|
||||
try {
|
||||
await ctrl?.dispose();
|
||||
} catch (_) {}
|
||||
// Plugin registered but state check failed; let the scanner widget
|
||||
// handle it via its errorBuilder.
|
||||
available = true;
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (available) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
@@ -13,7 +12,8 @@ class ChangeLogScreen extends StatelessWidget {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('ChangeLog')),
|
||||
body: FutureBuilder<String>(
|
||||
future: rootBundle.loadString('assets/changelog.txt'),
|
||||
future:
|
||||
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
@@ -17,20 +18,35 @@ class CrashScreen extends StatelessWidget {
|
||||
final StackTrace? stackTrace;
|
||||
final String gitHash;
|
||||
|
||||
Future<String> _buildReport() async {
|
||||
String version = 'unknown';
|
||||
String get _buildMode {
|
||||
if (kDebugMode) return 'debug';
|
||||
if (kProfileMode) return 'profile';
|
||||
return 'release';
|
||||
}
|
||||
|
||||
Future<String> _fetchVersion() async {
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
version = '${info.version}+${info.buildNumber}';
|
||||
} catch (_) {}
|
||||
return '${info.version}+${info.buildNumber}';
|
||||
} catch (_) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _buildReport() async {
|
||||
final version = await _fetchVersion();
|
||||
final platform =
|
||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||
final gitLine = gitHash.isNotEmpty
|
||||
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
|
||||
: '';
|
||||
final timestamp = DateTime.now().toUtc().toIso8601String();
|
||||
return 'App Version: $version\n'
|
||||
'Build Mode: $_buildMode\n'
|
||||
'$gitLine'
|
||||
'Platform: $platform\n\n'
|
||||
'Platform: $platform\n'
|
||||
'Dart: ${Platform.version}\n'
|
||||
'Timestamp: $timestamp\n\n'
|
||||
'Error:\n```\n$exception\n```\n\n'
|
||||
'Stack Trace:\n```\n$stackTrace\n```';
|
||||
}
|
||||
@@ -56,6 +72,18 @@ class CrashScreen extends StatelessWidget {
|
||||
style: Theme.of(ctx).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
FutureBuilder<String>(
|
||||
future: _fetchVersion(),
|
||||
builder: (context, snapshot) => Text(
|
||||
'v${snapshot.data ?? '…'} • $_buildMode • '
|
||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (gitHash.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
|
||||
+3
-3
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -146,16 +146,18 @@ def _ready_issues() -> list[dict]:
|
||||
|
||||
|
||||
def _latest_main_ci_run() -> dict | None:
|
||||
"""Return the latest CI run on the main branch (excludes PR and schedule runs).
|
||||
"""Return the latest ci.yml run on the main branch.
|
||||
|
||||
Using the global latest run (limit=1) is wrong: a passing or failing run
|
||||
on a PR branch could mask the true state of main. We filter to push
|
||||
events on the 'main' prettyref so section-3 logic only reacts to main.
|
||||
Forgejo reports scheduled/dispatch workflows (e.g. deploy.yml) with
|
||||
event=push and prettyref=main, so filtering by event alone is not enough.
|
||||
We also require workflow_id == "ci.yml".
|
||||
"""
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
|
||||
runs = (data or {}).get("workflow_runs", [])
|
||||
for run in runs:
|
||||
if run.get("event") == "push" and run.get("prettyref") == "main":
|
||||
if (run.get("event") == "push"
|
||||
and run.get("prettyref") == "main"
|
||||
and run.get("workflow_id") == "ci.yml"):
|
||||
return run
|
||||
return None
|
||||
|
||||
|
||||
Executable
+85
@@ -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
|
||||
Executable
+42
@@ -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."
|
||||
+24
-12
@@ -505,28 +505,40 @@ class TestOutputFormat(unittest.TestCase):
|
||||
|
||||
|
||||
class TestLatestMainCiRun(unittest.TestCase):
|
||||
"""_latest_main_ci_run() must return only push-to-main runs, ignoring schedule/deploy workflows."""
|
||||
"""_latest_main_ci_run() must return only ci.yml push-to-main runs."""
|
||||
|
||||
def test_skips_schedule_runs_returns_push_to_main(self):
|
||||
runs = [
|
||||
{"event": "schedule", "prettyref": "main", "status": "success", "id": 1},
|
||||
{"event": "push", "prettyref": "main", "status": "success", "id": 2},
|
||||
]
|
||||
def _ci_run(self, run_id, status="success"):
|
||||
return {"event": "push", "prettyref": "main", "workflow_id": "ci.yml",
|
||||
"status": status, "id": run_id}
|
||||
|
||||
def _deploy_run(self, run_id, status="success"):
|
||||
return {"event": "push", "prettyref": "main", "workflow_id": "deploy.yml",
|
||||
"status": status, "id": run_id}
|
||||
|
||||
def test_skips_deploy_run_returns_ci_run(self):
|
||||
# Forgejo reports deploy.yml schedule runs as event=push/prettyref=main;
|
||||
# must be excluded by workflow_id filter.
|
||||
runs = [self._deploy_run(1), self._ci_run(2)]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["id"], 2)
|
||||
|
||||
def test_returns_none_when_only_schedule_runs_exist(self):
|
||||
runs = [
|
||||
{"event": "schedule", "prettyref": "main", "status": "success", "id": 1},
|
||||
]
|
||||
def test_returns_none_when_only_deploy_runs_exist(self):
|
||||
runs = [self._deploy_run(1)]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_push_to_main_run(self):
|
||||
runs = [{"event": "push", "prettyref": "main", "status": "running", "id": 42}]
|
||||
def test_returns_none_when_only_schedule_runs_exist(self):
|
||||
runs = [{"event": "schedule", "prettyref": "main", "workflow_id": "deploy.yml",
|
||||
"status": "success", "id": 1}]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_ci_push_to_main_run(self):
|
||||
runs = [self._ci_run(42, status="running")]
|
||||
with patch("agent_loop._tea_get", return_value={"workflow_runs": runs}):
|
||||
result = agent_loop._latest_main_ci_run()
|
||||
self.assertIsNotNone(result)
|
||||
|
||||
Executable
+153
@@ -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
|
||||
@@ -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=
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ void main() {
|
||||
expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows 20-minute expiry hint', (tester) async {
|
||||
testWidgets('shows expiry countdown hint', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/receive',
|
||||
@@ -32,8 +32,106 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('20 minutes'), findsOneWidget);
|
||||
expect(find.textContaining('expires in'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'step 2 button shows text-input fallback on platforms without camera',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/receive',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// On Linux (desktop, no camera) the text fallback field must appear.
|
||||
expect(find.byKey(const Key('encryptedCodeField')), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'step 2 — valid encrypted QR imports account via text fallback',
|
||||
(tester) async {
|
||||
// Pre-generate a key pair so we can encrypt a QR code with the same
|
||||
// material the screen will use for decryption.
|
||||
final material = await ShareEncryptionService.generateKeyPair();
|
||||
final repo = FakeShareKeyRepository(material: material);
|
||||
|
||||
const account = Account(
|
||||
id: 'src-1',
|
||||
displayName: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
imapHost: 'imap.example.com',
|
||||
smtpHost: 'smtp.example.com',
|
||||
);
|
||||
|
||||
final encryptedQr = await ShareEncryptionService.encryptAccounts(
|
||||
recipientKeyId: material.keyId,
|
||||
recipientPublicKeyBytes: material.publicKeyBytes,
|
||||
accounts: [
|
||||
AccountPayload(
|
||||
accountJson: account.toJson(),
|
||||
password: 'secret',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/receive',
|
||||
overrides: baseOverrides(shareKeyRepository: repo),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(); // key generation completes
|
||||
|
||||
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('encryptedCodeField')),
|
||||
encryptedQr,
|
||||
);
|
||||
await tester.tap(find.text('Import'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Imported 1 account successfully.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'step 2 — invalid encrypted QR shows error and returns to pub-key step',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/receive',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('scanEncryptedButton')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('encryptedCodeField')),
|
||||
'not-a-valid-qr-code',
|
||||
);
|
||||
await tester.tap(find.text('Import'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Screen returns to the pub-key step with an error message visible.
|
||||
expect(find.byKey(const Key('pubKeyQrCode')), findsOneWidget);
|
||||
expect(find.textContaining('Import failed:'), findsWidgets);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('AccountSendScreen', () {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
||||
|
||||
class _FakeAssetBundle extends CachingAssetBundle {
|
||||
final Map<String, String> _assets;
|
||||
_FakeAssetBundle(this._assets);
|
||||
|
||||
@override
|
||||
Future<ByteData> load(String key) async {
|
||||
if (_assets.containsKey(key)) {
|
||||
final encoded = utf8.encode(_assets[key]!);
|
||||
return ByteData.view(Uint8List.fromList(encoded).buffer);
|
||||
}
|
||||
throw FlutterError('Asset not found: "$key"');
|
||||
}
|
||||
}
|
||||
|
||||
const _fakeChangelog =
|
||||
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
|
||||
|
||||
void main() {
|
||||
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
DefaultAssetBundle(
|
||||
bundle: _FakeAssetBundle({'assets/changelog.txt': _fakeChangelog}),
|
||||
child: const MaterialApp(home: ChangeLogScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('ChangeLog'), findsOneWidget);
|
||||
expect(find.textContaining('initial release'), findsOneWidget);
|
||||
expect(find.textContaining('resolve crash'), findsOneWidget);
|
||||
expect(find.textContaining('Error loading changelog'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('ChangeLogScreen shows error when asset is missing', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
DefaultAssetBundle(
|
||||
bundle: _FakeAssetBundle({}),
|
||||
child: const MaterialApp(home: ChangeLogScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Error loading changelog'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -116,7 +116,10 @@ void main() {
|
||||
|
||||
expect(clipboardText, isNotNull);
|
||||
expect(clipboardText, contains('App Version: 1.0.0+42'));
|
||||
expect(clipboardText, contains('Build Mode:'));
|
||||
expect(clipboardText, contains('Platform:'));
|
||||
expect(clipboardText, contains('Dart:'));
|
||||
expect(clipboardText, contains('Timestamp:'));
|
||||
expect(clipboardText, contains('TestException: clipboard test'));
|
||||
// GIT_HASH is empty in test builds — no Git Commit line expected
|
||||
expect(clipboardText, isNot(contains('Git Commit:')));
|
||||
@@ -167,6 +170,35 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen shows version, build mode, and platform in the UI',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
const exception = 'TestException: info row test';
|
||||
final stackTrace = StackTrace.current;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: CrashScreen(exception: exception, stackTrace: stackTrace),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Info row shows app version (from mock), build mode, and platform OS.
|
||||
expect(find.textContaining('1.0.0+42'), findsWidgets);
|
||||
// In test builds kDebugMode is true.
|
||||
expect(find.textContaining('debug'), findsOneWidget);
|
||||
// Platform OS is always present (linux in CI, android/ios on device).
|
||||
expect(
|
||||
find.textContaining(RegExp(r'linux|android|ios|windows|macos')),
|
||||
findsWidgets,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash',
|
||||
(tester) async {
|
||||
|
||||
@@ -79,11 +79,13 @@ class FakeAccountRepository implements AccountRepository {
|
||||
}
|
||||
|
||||
class FakeShareKeyRepository implements ShareKeyRepository {
|
||||
FakeShareKeyRepository({ShareKeyMaterial? material}) : _material = material;
|
||||
|
||||
ShareKeyMaterial? _material;
|
||||
|
||||
@override
|
||||
Future<ShareKeyMaterial> createKeyPair() async {
|
||||
_material = await ShareEncryptionService.generateKeyPair();
|
||||
_material ??= await ShareEncryptionService.generateKeyPair();
|
||||
return _material!;
|
||||
}
|
||||
|
||||
@@ -511,6 +513,7 @@ List<Override> baseOverrides({
|
||||
List<Mailbox>? mailboxes,
|
||||
DiscoveryResult? discovery,
|
||||
Exception? connectionError,
|
||||
ShareKeyRepository? shareKeyRepository,
|
||||
}) =>
|
||||
[
|
||||
accountRepositoryProvider
|
||||
@@ -525,7 +528,9 @@ List<Override> baseOverrides({
|
||||
connectionTestServiceProvider.overrideWithValue(
|
||||
FakeConnectionTestService(error: connectionError),
|
||||
),
|
||||
shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()),
|
||||
shareKeyRepositoryProvider.overrideWithValue(
|
||||
shareKeyRepository ?? FakeShareKeyRepository(),
|
||||
),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user