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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Many reading applications offer partial solutions but fall short on essential fe
| **Goals** | Reading goals (books, pages, time) with streak tracking |
| **Sync** | Cross-device synchronization via self-hostable server |
| **Storage** | Multiple backends: self-hosted, Google Drive, WebDAV, S3 |
| **Catalogs** | OPDS 1.2 and 2.0 browsing, keyword search, and automatic imports through the backend relay |
| **Accessibility** | E-ink optimization, dark/light themes, customizable fonts |

## Supported platforms
Expand Down Expand Up @@ -101,6 +102,17 @@ cd ../client/app
flutter run -d chrome --web-hostname papyrus.localhost --web-port 3000 --dart-define-from-file=.dart_defines
```

Web hot restart (`R` in the Flutter terminal) performs a full browser refresh at
the current URL. This releases the previous Dart runtime's database listeners
and workers before starting the app again. Browser storage is retained; ordinary
hot reload (`r`) still uses Flutter's normal behavior. After changing
`web/flutter_bootstrap.js`, refresh the browser once to load the new bootstrap.

The bootstrap uses Flutter's documented
[`onEntrypointLoaded` hook](https://docs.flutter.dev/platform-integration/web/initialization#the-onentrypointloaded-callback),
which runs on cold startup and hot restart. Its regression checks run with
`node --test test/web/flutter_bootstrap_test.cjs` from `app/`.

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.
Expand All @@ -120,6 +132,14 @@ for the full integration guide.

See [PapyrusReader/docs](https://github.com/PapyrusReader/docs).

### OPDS catalogs

Open **Library → Catalogs → Add catalog**, enter its URL, and optionally enter HTTP Basic credentials. Browse sections, search, open a publication, and download a supported format into your library. Web imports support EPUB; native imports support EPUB, PDF, MOBI, AZW3, TXT, CBZ, and CBR.

Saved catalogs and secure-storage credentials are local to each server/account, with a separate guest collection. Catalog requests and downloads use the selected Papyrus backend relay, so catalogs do not need browser CORS support. A reachable backend is required, but a Papyrus account is not. Guest imports stay local. The relay accepts public network destinations and sends catalog credentials only to their origin. OAuth, lending, purchases, DRM, offline catalog caching, and download resumption after closing the app are deferred.

See [OPDS setup, compatibility, and verification](docs/opds-support.md) for details and local smoke-test commands.

## Technology stack

| Layer | Technology |
Expand Down
30 changes: 29 additions & 1 deletion app/lib/config/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import 'package:papyrus/pages/dashboard_page.dart';
import 'package:papyrus/pages/developer_options_page.dart';
import 'package:papyrus/pages/goals_page.dart';
import 'package:papyrus/pages/library_page.dart';
import 'package:papyrus/pages/catalogs_page.dart';
import 'package:papyrus/pages/catalog_book_page.dart';
import 'package:papyrus/pages/forgot_password_page.dart';
import 'package:papyrus/pages/login_page.dart';
import 'package:papyrus/pages/edit_profile_page.dart';
Expand Down Expand Up @@ -43,7 +45,11 @@ class AppRouter {
required this.preferencesProvider,
required this.syncSettingsProvider,
required this.acquisitionAvailabilityProvider,
});
}) {
// Catalog browsing pushes visited pages to preserve their state on Back.
// Reflect those destinations in web URLs for refresh, sharing and history.
GoRouter.optionURLReflectsImperativeAPIs = true;
}

late final GoRouter router = GoRouter(
debugLogDiagnostics: true,
Expand Down Expand Up @@ -121,6 +127,28 @@ class AppRouter {
path: 'books',
pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const LibraryPage()),
),
GoRoute(
name: 'CATALOGS',
path: 'catalogs',
pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const CatalogsPage()),
routes: [
GoRoute(
name: 'CATALOG',
path: ':catalogId',
routes: [catalogBookRoute()],
pageBuilder: (context, state) => NoTransitionPage(
key: state.pageKey,
child: CatalogsPage(
catalogId: state.pathParameters['catalogId'],
feedUri: Uri.tryParse(state.uri.queryParameters['feed'] ?? '')?.hasScheme == true
? Uri.tryParse(state.uri.queryParameters['feed']!)
: null,
query: state.uri.queryParameters['q'] ?? '',
),
),
),
],
),
GoRoute(
name: 'SHELVES',
path: 'shelves',
Expand Down
79 changes: 74 additions & 5 deletions app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ import 'package:papyrus/media/media_cache_service.dart';
import 'package:papyrus/media/media_models.dart';
import 'package:papyrus/media/media_storage_scope.dart';
import 'package:papyrus/media/media_upload_queue.dart';
import 'package:papyrus/opds/opds_catalog_store.dart';
import 'package:papyrus/opds/opds_catalogs.dart';
import 'package:papyrus/opds/opds_downloads.dart';
import 'package:papyrus/opds/opds_http_client.dart';
import 'package:papyrus/opds/opds_library.dart';
import 'package:papyrus/opds/opds_resource_cache.dart';
import 'package:papyrus/services/book_import_session.dart';
import 'package:papyrus/platform/book_import_drop_registration.dart';
import 'package:papyrus/platform/hot_restart_cleanup.dart';
import 'package:papyrus/powersync/powersync_service.dart';
import 'package:papyrus/powersync/papyrus_powersync_connector.dart';
import 'package:papyrus/powersync/sync_profile_switch_queue.dart';
Expand All @@ -38,7 +44,6 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'config/app_router.dart';

Future main() async {
await runPreviousHotRestartCleanup();
WidgetsFlutterBinding.ensureInitialized();
ensureBookImportDropPluginRegistered();
usePathUrlStrategy();
Expand Down Expand Up @@ -163,6 +168,10 @@ class _PapyrusState extends State<Papyrus> {
late final SyncSettingsProvider _syncSettingsProvider;
late final MediaUploadQueue _mediaUploadQueue;
late final BookImportService _bookImportService;
late final OpdsCatalogs _opdsCatalogs;
late final OpdsDownloads _opdsDownloads;
late final OpdsHttpClient _opdsHttpClient;
late final OpdsLibrary _opdsLibrary;
late final PapyrusPowerSyncService _powerSyncService;
late final BookStorageStatusController _bookStorageStatusController;
late final PapyrusApiConfig _officialApiConfig;
Expand Down Expand Up @@ -218,7 +227,22 @@ class _PapyrusState extends State<Papyrus> {
mediaUploadQueue: _mediaUploadQueue,
hasBookFile: _bookImportService.hasBookFile,
);
registerHotRestartCleanup(_disposeDataServices);
_opdsLibrary = OpdsLibrary(widget.prefs, dataStore: _dataStore);
final opdsCache = OpdsResourceCache(widget.prefs);
_opdsCatalogs = OpdsCatalogs(OpdsCatalogStore(widget.prefs), library: _opdsLibrary, cache: opdsCache);
_opdsHttpClient = OpdsHttpClient(apiConfig: () => _syncSettingsProvider.activeApiConfig, cache: opdsCache);
_opdsDownloads = OpdsDownloads(
library: _opdsLibrary,
httpClient: _opdsHttpClient,
captureImport: () => BookImportSession.capture(
dataStore: _dataStore,
queue: _mediaUploadQueue,
importService: _bookImportService,
authProvider: _authProvider,
powerSyncService: _powerSyncService,
),
);
_opdsCatalogs.addListener(_opdsDownloads.reset);
unawaited(_dataStore.attachBookRepository(_powerSyncService));
_appRouter = AppRouter(
authProvider: _authProvider,
Expand All @@ -237,6 +261,11 @@ class _PapyrusState extends State<Papyrus> {
_syncSettingsProvider.removeListener(_handleSyncSettingsChanged);
unawaited(_disposeDataServices());
_bookStorageStatusController.dispose();
_opdsCatalogs.removeListener(_opdsDownloads.reset);
_opdsDownloads.dispose();
_opdsLibrary.dispose();
_opdsCatalogs.dispose();
_opdsHttpClient.cache?.dispose();
_bookImportService.dispose();
_acquisitionDownloadsComposition.dispose();
_acquisitionAvailabilityProvider.dispose();
Expand All @@ -255,11 +284,13 @@ class _PapyrusState extends State<Papyrus> {
}

Future<void> _disposeDataServices() async {
_opdsDownloads.reset();
await _dataStore.disposeBookRepository();
await _powerSyncService.close();
}

void _syncPowerSyncAuthState() {
_updateOpdsScope();
if (_authStateOperation != null) {
_authStateUpdateQueued = true;
return;
Expand Down Expand Up @@ -287,10 +318,20 @@ class _PapyrusState extends State<Papyrus> {

Future<void> _applyPowerSyncAuthState() async {
final user = _authProvider.user;
final profileKey = _activeProfileKey;
if (user != null && !_authProvider.isOfflineMode) {
final userId = user.userId;
await _mediaUploadQueue.activateScope(MediaStorageScope(profileKey: _activeProfileKey, userId: userId));
await _powerSyncService.activateAuthenticated(userId, profileKey: _activeProfileKey);
final scope = MediaStorageScope(profileKey: profileKey, userId: userId);
await _mediaUploadQueue.activateScope(scope);
await _powerSyncService.activateAuthenticated(userId, profileKey: profileKey);
if (!_switchingSyncProfile &&
_activeProfileKey == profileKey &&
_syncSettingsProvider.activeProfileKey == profileKey &&
_authProvider.isSignedIn &&
_authProvider.user?.userId == userId &&
!_authProvider.isOfflineMode) {
_opdsCatalogs.setScope(scope.persistenceKey);
}
await _refreshMediaUsage();
await _processMediaUploads();
return;
Expand All @@ -299,6 +340,12 @@ class _PapyrusState extends State<Papyrus> {
if (_authProvider.isOfflineMode) {
await _mediaUploadQueue.activateScope(null);
await _powerSyncService.activateGuest();
if (_authProvider.isOfflineMode &&
!_switchingSyncProfile &&
_activeProfileKey == profileKey &&
_syncSettingsProvider.activeProfileKey == profileKey) {
_opdsCatalogs.setScope(MediaStorageScope.localGuest.persistenceKey);
}
return;
}

Expand All @@ -321,6 +368,7 @@ class _PapyrusState extends State<Papyrus> {
_acquisitionDownloadsComposition.handleServerChanged();
final nextProfileKey = _syncSettingsProvider.activeProfileKey;
final nextConfig = _syncSettingsProvider.activeApiConfig;
if (nextProfileKey != _activeProfileKey) _opdsCatalogs.setScope(null);
_profileSwitchQueue.request(nextProfileKey, () => _switchActiveSyncProfile(nextProfileKey, nextConfig));
}

Expand All @@ -340,6 +388,24 @@ class _PapyrusState extends State<Papyrus> {
}
}

void _updateOpdsScope() {
if (_switchingSyncProfile ||
_activeProfileKey != _syncSettingsProvider.activeProfileKey ||
_authProvider.isBootstrapping) {
_opdsCatalogs.setScope(null);
} else if (_authProvider.isOfflineMode) {
if (_opdsCatalogs.scope != MediaStorageScope.localGuest.persistenceKey) _opdsCatalogs.setScope(null);
} else if (_authProvider.isSignedIn && _authProvider.user != null) {
final expected = MediaStorageScope(
profileKey: _activeProfileKey,
userId: _authProvider.user!.userId,
).persistenceKey;
if (_opdsCatalogs.scope != expected) _opdsCatalogs.setScope(null);
} else {
_opdsCatalogs.setScope(null);
}
}

Future<void> _refreshMediaUsage() async {
if (!_authProvider.isSignedIn || _authProvider.isOfflineMode) return;
final repository = _authRepository;
Expand Down Expand Up @@ -404,6 +470,9 @@ class _PapyrusState extends State<Papyrus> {
ChangeNotifierProvider.value(value: _bookStorageStatusController),
Provider.value(value: _powerSyncService),
Provider.value(value: _bookImportService),
ChangeNotifierProvider.value(value: _opdsCatalogs),
ChangeNotifierProvider.value(value: _opdsDownloads),
Provider.value(value: _opdsHttpClient),
Provider(create: _createBookDownloadService),
Provider(create: _createMediaCacheService),
StreamProvider<SyncState>.value(value: _powerSyncService.syncStates, initialData: _powerSyncService.syncState),
Expand Down
Loading
Loading