diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml
index 10a719c..f70fbfd 100644
--- a/.github/workflows/auto-assign.yml
+++ b/.github/workflows/auto-assign.yml
@@ -2,8 +2,6 @@ name: Auto Assign
on:
issues:
types: [opened]
- pull_request:
- types: [opened]
jobs:
run:
runs-on: ubuntu-latest
@@ -16,4 +14,3 @@ jobs:
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
assignees: Eoic
- numOfAssignee: 1
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 389204e..22f8596 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,12 +4,12 @@ on:
push:
branches: [master, main]
paths:
- - 'client/**'
+ - 'app/**'
- '.github/workflows/**'
pull_request:
branches: [master, main]
paths:
- - 'client/**'
+ - 'app/**'
- '.github/workflows/**'
workflow_dispatch:
@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
defaults:
run:
- working-directory: client
+ working-directory: app
steps:
- name: Checkout repository
@@ -45,7 +45,7 @@ jobs:
run: dart format --set-exit-if-changed .
- name: Analyze code
- run: dart analyze
+ run: flutter analyze --no-fatal-warnings --no-fatal-infos
- name: Run tests with coverage
run: flutter test --coverage
@@ -53,6 +53,6 @@ jobs:
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
- files: client/coverage/lcov.info
+ files: app/coverage/lcov.info
flags: client
token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.gitignore b/.gitignore
index c5d2d58..8df6c94 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,5 @@ firebase-debug.log
.worktrees/
landing/node_modules/
landing/css/
-test/data/
\ No newline at end of file
+test/data/
+.idea/
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 6e70df3..b8421a6 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -1,22 +1,29 @@
{
"version": "2.0.0",
+ "options": {
+ "cwd": "${workspaceFolder}/app/"
+ },
"tasks": [
{
"label": "Papyrus (web)",
"type": "shell",
- "command": "flutter run",
+ "command": "flutter",
"args": [
+ "run",
"-d",
"chrome",
"--dart-define-from-file",
- ".dart_defines"
+ ".dart_defines",
+ "--web-port",
+ "3000"
]
},
{
"label": "Papyrus (android)",
"type": "shell",
- "command": "flutter run",
+ "command": "flutter",
"args": [
+ "run",
"-d",
"android",
"--dart-define-from-file",
@@ -26,8 +33,9 @@
{
"label": "Papyrus (iOS)",
"type": "shell",
- "command": "flutter run",
+ "command": "flutter",
"args": [
+ "run",
"-d",
"ios",
"--dart-define-from-file",
@@ -35,12 +43,13 @@
]
},
{
- "label": "Papyrus (desktop)",
+ "label": "Papyrus (Linux)",
"type": "shell",
- "command": "flutter run",
+ "command": "flutter",
"args": [
+ "run",
"-d",
- "desktop",
+ "linux",
"--dart-define-from-file",
".dart_defines"
]
diff --git a/README.md b/README.md
index fa62da7..62a734f 100644
--- a/README.md
+++ b/README.md
@@ -70,7 +70,7 @@ Many reading applications offer partial solutions but fall short on essential fe
2. **Install dependencies**
```bash
- cd client
+ cd app
flutter pub get
```
@@ -87,6 +87,34 @@ Many reading applications offer partial solutions but fall short on essential fe
flutter run -d windows # or: macos, linux
```
+### Running with the back-end
+
+When the user signs in, the Flutter client communicates with the Papyrus server for auth and asks the server for PowerSync credentials.
+
+Run the server and PowerSync locally, then start Flutter with:
+
+```bash
+cd ../server
+./scripts/bootstrap_local.sh
+cd ../client/app
+flutter run -d chrome --web-hostname papyrus.localhost --web-port 3000 --dart-define-from-file=.dart_defines
+```
+
+Authenticated books use `papyrus-account.db` and synchronize through
+PowerSync. Guest mode uses the separate local-only `papyrus-guest.db`; guest
+books remain on that device and are not merged into an account.
+
+For local web auth links, add these entries to `/etc/hosts`:
+
+```text
+127.0.0.1 papyrus.localhost
+::1 papyrus.localhost
+```
+
+See
+[`server/docs/flutter-auth-integration.md`](../server/docs/flutter-auth-integration.md)
+for the full integration guide.
+
## Documentation
See [PapyrusReader/docs](https://github.com/PapyrusReader/docs).
diff --git a/app/.dart_defines.example b/app/.dart_defines.example
index f946c57..e9d0735 100644
--- a/app/.dart_defines.example
+++ b/app/.dart_defines.example
@@ -1,4 +1,4 @@
{
- "SUPABASE_URL": "https://your-project-ref.supabase.co",
- "SUPABASE_ANON_KEY": "your-anon-key"
+ "PAPYRUS_API_BASE_URL": "http://papyrus.localhost:8080",
+ "POWERSYNC_SERVICE_URL": "http://localhost:8081"
}
diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml
index f16eead..77e6c8f 100644
--- a/app/analysis_options.yaml
+++ b/app/analysis_options.yaml
@@ -1,4 +1,7 @@
include: package:flutter_lints/flutter.yaml
linter:
- rules:
\ No newline at end of file
+ rules:
+ lines_longer_than_80_chars: false
+formatter:
+ page_width: 120
\ No newline at end of file
diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml
index 5f22883..0c05a42 100644
--- a/app/android/app/src/main/AndroidManifest.xml
+++ b/app/android/app/src/main/AndroidManifest.xml
@@ -26,6 +26,16 @@
+
+
+
+
+
+
+
+
-
- 856619753967-uks4vdv502s0kqd8017e7d6b4pvou8jo.apps.googleusercontent.com
diff --git a/app/integration_test/powersync_books_integration_test.dart b/app/integration_test/powersync_books_integration_test.dart
new file mode 100644
index 0000000..29f791a
--- /dev/null
+++ b/app/integration_test/powersync_books_integration_test.dart
@@ -0,0 +1,121 @@
+import 'dart:io';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+import 'package:papyrus/auth/auth_api_client.dart';
+import 'package:papyrus/auth/auth_repository.dart';
+import 'package:papyrus/auth/papyrus_api_config.dart';
+import 'package:papyrus/auth/token_store.dart';
+import 'package:papyrus/models/book.dart';
+import 'package:papyrus/powersync/papyrus_powersync_connector.dart';
+import 'package:papyrus/powersync/powersync_service.dart';
+import 'package:path/path.dart' as path;
+import 'package:uuid/uuid.dart';
+
+const runIntegration = bool.fromEnvironment('RUN_POWERSYNC_INTEGRATION');
+
+class MemoryRefreshTokenStorage implements RefreshTokenStorage {
+ String? value;
+
+ @override
+ Future delete() async => value = null;
+
+ @override
+ Future read() async => value;
+
+ @override
+ Future write(String refreshToken) async => value = refreshToken;
+}
+
+Future waitForBook(PapyrusPowerSyncService service, String id, {bool present = true, String? title}) async {
+ final deadline = DateTime.now().add(const Duration(seconds: 20));
+ while (DateTime.now().isBefore(deadline)) {
+ final book = await service.getById(id);
+ if ((book != null) == present && (title == null || book?.title == title)) {
+ return;
+ }
+ await Future.delayed(const Duration(milliseconds: 200));
+ }
+ fail('Book $id did not reach expected presence=$present');
+}
+
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ testWidgets('two clients sync books, reconnect offline writes, and isolate users', (tester) async {
+ final config = PapyrusApiConfig.fromEnvironment();
+ final email = 'powersync-${DateTime.now().microsecondsSinceEpoch}@example.com';
+ const password = 'SecureP@ss123';
+ final root = await Directory.systemTemp.createTemp('papyrus-e2e-');
+
+ AuthRepository repository(String label) {
+ return AuthRepository(
+ apiClient: AuthApiClient(config: config),
+ tokenStore: TokenStore(MemoryRefreshTokenStorage()),
+ );
+ }
+
+ PapyrusPowerSyncService service(AuthRepository repository, String label) {
+ return PapyrusPowerSyncService(
+ connectorFactory: () => PapyrusPowerSyncConnector(authRepository: repository, config: config),
+ pathResolver: (mode) async => path.join(root.path, '$label-${mode.name}.db'),
+ );
+ }
+
+ final firstAuth = repository('first');
+ final firstTokens = await firstAuth.register(
+ email: email,
+ password: password,
+ displayName: 'PowerSync Test',
+ clientType: 'desktop',
+ );
+ final secondAuth = repository('second');
+ await secondAuth.login(email: email, password: password, clientType: 'desktop');
+ final otherAuth = repository('other');
+ final otherTokens = await otherAuth.register(
+ email: 'other-$email',
+ password: password,
+ displayName: 'Other User',
+ clientType: 'desktop',
+ );
+
+ final first = service(firstAuth, 'first');
+ final second = service(secondAuth, 'second');
+ final other = service(otherAuth, 'other');
+
+ try {
+ await Future.wait([
+ first.activateAuthenticated(firstTokens.user.userId),
+ second.activateAuthenticated(firstTokens.user.userId),
+ other.activateAuthenticated(otherTokens.user.userId),
+ ]);
+ await Future.wait([
+ first.syncStates.firstWhere((state) => state.connected),
+ second.syncStates.firstWhere((state) => state.connected),
+ other.syncStates.firstWhere((state) => state.connected),
+ ]).timeout(const Duration(seconds: 20));
+
+ final book = Book(
+ id: const Uuid().v4(),
+ title: 'Realtime book',
+ author: 'Papyrus',
+ addedAt: DateTime.now().toUtc(),
+ );
+ await first.upsert(book);
+ await waitForBook(second, book.id, title: book.title);
+ expect(await other.getById(book.id), isNull);
+
+ await first.setOnline(false);
+ await first.upsert(book.copyWith(title: 'Offline edit'));
+ await first.setOnline(true);
+ await waitForBook(second, book.id, title: 'Offline edit');
+ expect((await second.getById(book.id))?.title, 'Offline edit');
+
+ await second.delete(book.id);
+ await waitForBook(first, book.id, present: false);
+ } finally {
+ await Future.wait([first.close(), second.close(), other.close()]);
+ await root.delete(recursive: true);
+ }
+ }, skip: !runIntegration);
+}
diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist
index 0ed24e5..b653a1b 100644
--- a/app/ios/Runner/Info.plist
+++ b/app/ios/Runner/Info.plist
@@ -54,7 +54,7 @@
Editor
CFBundleURLSchemes
- com.googleusercontent.apps.856619753967-c7gsj5tujnukf8ku4a3kk8p8ttdt7qk3
+ papyrus
diff --git a/app/lib/auth/auth_api_client.dart b/app/lib/auth/auth_api_client.dart
new file mode 100644
index 0000000..83b0fa4
--- /dev/null
+++ b/app/lib/auth/auth_api_client.dart
@@ -0,0 +1,207 @@
+import 'dart:convert';
+
+import 'package:http/http.dart' as http;
+import 'package:papyrus/auth/auth_models.dart';
+import 'package:papyrus/auth/papyrus_api_config.dart';
+
+class AuthApiException implements Exception {
+ final int statusCode;
+ final String message;
+ final String? code;
+
+ const AuthApiException({required this.statusCode, required this.message, this.code});
+
+ @override
+ String toString() => message;
+}
+
+class AuthApiClient {
+ static const serverUnavailableMessage = 'Unable to connect. Please try again.';
+
+ final PapyrusApiConfig config;
+ final http.Client _httpClient;
+
+ AuthApiClient({required this.config, http.Client? httpClient}) : _httpClient = httpClient ?? http.Client();
+
+ Future ensureServerReachable() async {
+ try {
+ final response = await _httpClient.get(config.serverBaseUri.resolve('/health'));
+
+ if (response.statusCode >= 200 && response.statusCode < 300) {
+ return;
+ }
+ } catch (_) {
+ throw const AuthApiException(statusCode: 0, message: serverUnavailableMessage);
+ }
+
+ throw const AuthApiException(statusCode: 0, message: serverUnavailableMessage);
+ }
+
+ Uri googleOAuthStartUri(String redirectUri) {
+ return config.endpoint('/auth/oauth/google/start', {'redirect_uri': redirectUri});
+ }
+
+ Future register({
+ required String email,
+ required String password,
+ required String displayName,
+ String clientType = 'mobile',
+ String? deviceLabel,
+ }) async {
+ final json = await _postJson(
+ config.endpoint('/auth/register'),
+ body: {
+ 'email': email,
+ 'password': password,
+ 'display_name': displayName,
+ 'client_type': clientType,
+ 'device_label': deviceLabel,
+ },
+ );
+
+ return AuthTokens.fromJson(json);
+ }
+
+ Future login({
+ required String email,
+ required String password,
+ String clientType = 'mobile',
+ String? deviceLabel,
+ }) async {
+ final json = await _postJson(
+ config.endpoint('/auth/login'),
+ body: {'email': email, 'password': password, 'client_type': clientType, 'device_label': deviceLabel},
+ );
+
+ return AuthTokens.fromJson(json);
+ }
+
+ Future refresh(String refreshToken) async {
+ final json = await _postJson(config.endpoint('/auth/refresh'), body: {'refresh_token': refreshToken});
+
+ return AuthTokens.fromJson(json);
+ }
+
+ Future exchangeCode({required String code, String clientType = 'mobile', String? deviceLabel}) async {
+ final json = await _postJson(
+ config.endpoint('/auth/exchange-code'),
+ body: {'code': code, 'client_type': clientType, 'device_label': deviceLabel},
+ );
+
+ return AuthTokens.fromJson(json);
+ }
+
+ Future logout(String accessToken) async {
+ await _postJson(config.endpoint('/auth/logout'), accessToken: accessToken);
+ }
+
+ Future currentUser(String accessToken) async {
+ final json = await _getJson(config.endpoint('/users/me'), accessToken: accessToken);
+
+ return PapyrusUser.fromJson(json);
+ }
+
+ Future updateCurrentUser({required String accessToken, String? displayName, String? avatarUrl}) async {
+ final body = {};
+
+ if (displayName != null) {
+ body['display_name'] = displayName;
+ }
+
+ if (avatarUrl != null) {
+ body['avatar_url'] = avatarUrl;
+ }
+
+ final json = await _patchJson(config.endpoint('/users/me'), accessToken: accessToken, body: body);
+
+ return PapyrusUser.fromJson(json);
+ }
+
+ Future forgotPassword(String email) async {
+ final json = await _postJson(config.endpoint('/auth/forgot-password'), body: {'email': email});
+
+ return json['message'] as String? ?? 'If the email is registered, a reset link has been sent';
+ }
+
+ Future resetPassword({required String token, required String password}) async {
+ final json = await _postJson(config.endpoint('/auth/reset-password'), body: {'token': token, 'password': password});
+
+ return json['message'] as String? ?? 'Password has been reset successfully';
+ }
+
+ Future verifyEmail(String token) async {
+ final json = await _postJson(config.endpoint('/auth/verify-email'), body: {'token': token});
+
+ return json['message'] as String? ?? 'Email verified successfully';
+ }
+
+ Future resendVerification(String email) async {
+ final json = await _postJson(config.endpoint('/auth/resend-verification'), body: {'email': email});
+
+ return json['message'] as String? ?? 'If the email is registered, a verification link has been sent';
+ }
+
+ Future powerSyncToken(String accessToken) async {
+ final json = await _postJson(config.endpoint('/auth/powersync-token'), accessToken: accessToken);
+
+ return PowerSyncToken.fromJson(json);
+ }
+
+ Future uploadPowerSyncBatch(String accessToken, List