delete CloudKit and added BalanceFlutter
Android CI / Build and Test (push) Failing after 21m18s
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

This commit is contained in:
wt committed 2026-10-28 15:37:32 +07:00
1 parent a715eca947
commit 2c278044bb
214 files changed
+13411 -2290

No files matched your search

+272
View File
@@ -0,0 +1,272 @@
// Run against a disposable BalanceServer instance:
// dart run tool/integration_sync.dart http://127.0.0.1:18080
// Creates two test accounts; never use a production database.
import 'dart:convert';
import 'dart:io';
import 'package:balance_flutter/domain/finance.dart';
import 'package:balance_flutter/services/sync_api.dart';
void check(bool condition, String message) {
if (!condition) throw StateError(message);
}
Future<void> unauthorized(
Future<Object?> Function() action,
String message,
) async {
try {
await action();
} on SyncApiException catch (error) {
check(error.statusCode == 401, '$message: expected HTTP 401');
return;
}
throw StateError('$message: unexpectedly accepted');
}
Map<String, dynamic> wire(
String entity,
String id,
DateTime updatedAt,
Map<String, dynamic>? payload,
) => {
'entity': entity,
'id': id,
'updatedAt': updatedAt.toUtc().toIso8601String(),
'deleted': payload == null,
'payload': ?payload,
};
Future<void> main(List<String> arguments) async {
final url = arguments.isEmpty ? 'http://127.0.0.1:18080' : arguments.single;
final uri = Uri.parse(url);
check(
['localhost', '127.0.0.1', '::1'].contains(uri.host),
'Integration accounts must use a local disposable server',
);
final primary = SyncApi(serverUrl: url, allowInsecureHttp: true);
final secondDevice = SyncApi(serverUrl: url, allowInsecureHttp: true);
final outsider = SyncApi(serverUrl: url, allowInsecureHttp: true);
final suffix = DateTime.now().microsecondsSinceEpoch;
final email = 'flutter-integration-$suffix@example.com';
const password = 'Balance integration password 2026!';
final ledger = <String, Map<String, dynamic>>{};
final pages = <int>[];
var cursor = 0;
try {
final registered = await primary.register(email, password);
final user = await primary.me(registered['accessToken'] as String);
check(user['email'] == email, 'register/me account mismatch');
var session = await secondDevice.login(email, password);
check(
(session['user'] as Map)['id'] == user['id'],
'login account mismatch',
);
final other = await outsider.register(
'other-$suffix@example.com',
password,
);
final timestamp = DateTime.now().toUtc().subtract(
const Duration(seconds: 5),
);
final transactions = List.generate(
1100,
(index) => TransactionRecord(
id: 'transaction-$index',
amountMinor: 10000 + index,
date: timestamp,
categoryName: 'Тестовая категория 🍏',
updatedAt: timestamp,
note: 'UTF-8 roundtrip $index',
kindRawValue: index.isEven ? 'expense' : 'income',
),
);
final category = CategoryRecord(
id: 'integration-category',
name: 'Тестовая категория 🍏',
icon: 'cart.fill',
colorName: 'green',
emoji: '🍏',
createdAt: timestamp,
updatedAt: timestamp,
);
final budget = BudgetRecord(
id: 'integration-budget',
categoryName: category.name,
limitMinor: 999999,
monthStart: DateTime.utc(timestamp.year, timestamp.month),
updatedAt: timestamp,
);
final changes = [
for (final record in transactions)
wire('transaction', record.id, record.updatedAt, record.payload()),
wire('category', category.id, category.updatedAt, category.payload()),
wire('budget', budget.id, budget.updatedAt, budget.payload()),
];
await primary.synchronize(
accessToken: () async => registered['accessToken'] as String,
cursor: 0,
deviceId: 'integration-device-one',
changes: changes,
applyPage: (_, _) async {},
);
cursor = await secondDevice.synchronize(
accessToken: () async => session['accessToken'] as String,
cursor: 0,
deviceId: 'integration-device-two',
changes: [],
applyPage: (page, nextCursor) async {
check(nextCursor > cursor, 'Non-advancing pagination cursor');
pages.add(page.length);
for (final change in page) {
ledger['${change['entity']}:${change['id']}'] = change;
}
cursor = nextCursor;
},
);
check(
jsonEncode(pages) == '[500,500,102]',
'Expected 3 pages [500,500,102], got $pages',
);
check(ledger.length == 1102, 'Missing records after pagination');
final roundtrip = ledger['transaction:transaction-99']!;
final decoded = TransactionRecord.fromPayload(
id: roundtrip['id'] as String,
updatedAt: DateTime.parse(roundtrip['updatedAt'] as String),
payload: Map<String, dynamic>.from(roundtrip['payload'] as Map),
);
check(
decoded.amountMinor == 10099 && decoded.categoryName == category.name,
'Money or UTF-8 payload changed',
);
final changedAt = DateTime.now().toUtc();
final edited = transactions.first.copyWith(
amountMinor: 1234567,
updatedAt: changedAt,
);
final updates = [
wire('transaction', edited.id, edited.updatedAt, edited.payload()),
wire('transaction', transactions[1].id, changedAt, null),
];
await primary.synchronize(
accessToken: () async => registered['accessToken'] as String,
cursor: cursor,
deviceId: 'integration-device-one',
changes: updates,
applyPage: (_, _) async {},
);
var updateCount = 0;
cursor = await secondDevice.synchronize(
accessToken: () async => session['accessToken'] as String,
cursor: cursor,
deviceId: 'integration-device-two',
changes: [],
applyPage: (page, nextCursor) async {
updateCount += page.length;
for (final change in page) {
ledger['${change['entity']}:${change['id']}'] = change;
}
cursor = nextCursor;
},
);
check(updateCount == 2, 'Expected update and tombstone');
check(
ledger['transaction:transaction-1']!['deleted'] == true,
'Deletion was not propagated',
);
final updatedPayload =
ledger['transaction:transaction-0']!['payload'] as Map;
check(
moneyToMinor(updatedPayload['amount'] as num) == 1234567,
'Update was not propagated',
);
final oldCursor = cursor;
// Replaying stale active rows must not overwrite the newer value/tombstone.
await primary.synchronize(
accessToken: () async => registered['accessToken'] as String,
cursor: cursor,
deviceId: 'integration-device-one',
changes: changes.take(2).toList(),
applyPage: (page, nextCursor) async {
check(
page.isEmpty && nextCursor == oldCursor,
'Stale replay changed LWW state',
);
},
);
// The second account shares record IDs but must see no first-account rows.
final isolated = await outsider.sync(
accessToken: other['accessToken'] as String,
cursor: 0,
deviceId: 'integration-outsider',
changes: [],
);
check(
(isolated['changes'] as List).isEmpty && isolated['cursor'] == 0,
'Cross-account data leak',
);
await outsider.sync(
accessToken: other['accessToken'] as String,
cursor: 0,
deviceId: 'integration-outsider',
pull: false,
changes: [
wire(
'transaction',
transactions.first.id,
changedAt,
transactions.first.copyWith(amountMinor: 1).payload(),
),
],
);
final unchanged = await primary.sync(
accessToken: registered['accessToken'] as String,
cursor: cursor,
deviceId: 'integration-device-one',
changes: [],
);
check(
(unchanged['changes'] as List).isEmpty,
'Other-account edit affected first account',
);
final oldRefresh = session['refreshToken'] as String;
session = await secondDevice.refresh(oldRefresh);
check(
session['refreshToken'] != oldRefresh,
'Refresh token was not rotated',
);
await unauthorized(
() => secondDevice.refresh(oldRefresh),
'Rotated refresh token',
);
check(
(await secondDevice.me(session['accessToken'] as String))['id'] ==
user['id'],
'Refreshed token invalid',
);
await secondDevice.logout(session['refreshToken'] as String);
await unauthorized(
() => secondDevice.refresh(session['refreshToken'] as String),
'Logged-out refresh token',
);
await primary.logout(registered['refreshToken'] as String);
await outsider.logout(other['refreshToken'] as String);
stdout.writeln(
jsonEncode({
'result': 'PASS',
'server': url,
'records': 1102,
'pullPages': pages,
'updateAndDelete': true,
'staleReplayLww': true,
'twoAccountIsolation': true,
'refreshRotation': true,
'logoutRevocation': true,
}),
);
} finally {
primary.close();
secondDevice.close();
outsider.close();
}
}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Disposable real-server integration run. No original server files are changed.
set -euo pipefail
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
server_source="${project_dir}/../BalanceServer"
GO_BIN="${GO_BIN:-$(command -v go || true)}"
DART_BIN="${DART_BIN:-$(command -v dart || true)}"
if [[ -z "$GO_BIN" && -x /tmp/go/bin/go ]]; then GO_BIN=/tmp/go/bin/go; fi
if [[ -z "$DART_BIN" && -x /tmp/flutter/bin/dart ]]; then DART_BIN=/tmp/flutter/bin/dart; fi
[[ -n "$GO_BIN" && -x "$GO_BIN" ]] || { echo 'Go 1.25+ required (set GO_BIN).' >&2; exit 1; }
[[ -n "$DART_BIN" && -x "$DART_BIN" ]] || { echo 'Dart required (set DART_BIN).' >&2; exit 1; }
go_version="$("$GO_BIN" version)"
if [[ ! "$go_version" =~ go([0-9]+)\.([0-9]+) ]] || (( BASH_REMATCH[1] < 1 || (BASH_REMATCH[1] == 1 && BASH_REMATCH[2] < 25) )); then
echo "Go 1.25+ required; found $go_version" >&2; exit 1
fi
scratch_dir="$(mktemp -d "${TMPDIR:-/tmp}/balance-integration.XXXXXX")"
server_pid=''
cleanup() {
if [[ -n "$server_pid" ]]; then kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true; fi
rm -rf -- "$scratch_dir"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
cp -R -- "$server_source" "$scratch_dir/server"
mkdir -p "$scratch_dir/server/cmd/flutter-integration"
cat > "$scratch_dir/server/cmd/flutter-integration/main.go" <<'GO'
package main
import (
"balance-server/internal/config"
"balance-server/internal/database"
"balance-server/internal/httpapi"
"log"
"log/slog"
"net"
"net/http"
"os"
"time"
)
func main() {
cfg, err := config.Load(); if err != nil { log.Fatal(err) }
db, err := database.Open(cfg.DatabasePath); if err != nil { log.Fatal(err) }; defer db.Close()
listener, err := net.Listen("tcp", "127.0.0.1:0"); if err != nil { log.Fatal(err) }
if err = os.WriteFile(os.Getenv("BALANCE_READY_FILE"), []byte("http://"+listener.Addr().String()), 0600); err != nil { log.Fatal(err) }
server := &http.Server{Handler: httpapi.New(cfg, db, slog.Default()), ReadHeaderTimeout: 5*time.Second}
log.Fatal(server.Serve(listener))
}
GO
(cd "$scratch_dir/server" && "$GO_BIN" build -o "$scratch_dir/server-bin" ./cmd/flutter-integration)
BALANCE_DB_PATH="$scratch_dir/balance.db" \
BALANCE_JWT_SECRET='integration-only-disposable-secret-2026-balance' \
BALANCE_ALLOW_REGISTRATION=true BALANCE_READY_FILE="$scratch_dir/ready" \
"$scratch_dir/server-bin" >"$scratch_dir/server.log" 2>&1 &
server_pid=$!
for ((attempt=0; attempt<100; attempt++)); do
[[ -s "$scratch_dir/ready" ]] && break
if ! kill -0 "$server_pid" 2>/dev/null; then cat "$scratch_dir/server.log" >&2; exit 1; fi
sleep 0.1
done
[[ -s "$scratch_dir/ready" ]] || { cat "$scratch_dir/server.log" >&2; echo 'Server startup timed out.' >&2; exit 1; }
server_url="$(cat "$scratch_dir/ready")"
cd "$project_dir"
"$DART_BIN" run tool/integration_sync.dart "$server_url"
+23
View File
@@ -0,0 +1,23 @@
"""Resize the original Balance icon into platform assets (requires Pillow)."""
import json
from pathlib import Path
from PIL import Image
root = Path(__file__).resolve().parents[1]
icon = Image.open(root / 'assets/app_icon.png').convert('RGB')
def save(path, size):
icon.resize((size,size), Image.Resampling.LANCZOS).save(path)
for target in ['ios','macos']:
folder = root / target / 'Runner/Assets.xcassets/AppIcon.appiconset'
for asset in json.loads((folder/'Contents.json').read_text())['images']:
if 'filename' in asset:
size = round(float(asset['size'].split('x')[0]) * float(asset['scale'].rstrip('x')))
save(folder/asset['filename'],size)
for density,size in [('mdpi',48),('hdpi',72),('xhdpi',96),('xxhdpi',144),('xxxhdpi',192)]:
save(root/f'android/app/src/main/res/mipmap-{density}/ic_launcher.png',size)
for name,size in [('Icon-192',192),('Icon-512',512),('Icon-maskable-192',192),('Icon-maskable-512',512)]:
save(root/f'web/icons/{name}.png',size)
save(root/'web/favicon.png',32)
icon.save(root/'windows/runner/resources/app_icon.ico',sizes=[(16,16),(32,32),(48,48),(64,64),(128,128),(256,256)])
manifest=root/'web/manifest.json'
data=json.loads(manifest.read_text());data.update(background_color='#f4faf6',theme_color='#356859',orientation='any')
manifest.write_text(json.dumps(data,ensure_ascii=False,indent=2)+'\n')