[client] WIP: primary instance takeover

This commit is contained in:
Abhishek Shroff
2025-05-18 08:10:46 +05:30
parent f13e30a1c3
commit dcfe978016
16 changed files with 225 additions and 202 deletions

View File

@@ -97,7 +97,7 @@ class PhylumAccount extends Account<PhylumAccount> {
if (errorResponse is PhylumApiErrorResponse) {
if (errorResponse.code == "credentials_invalid") {
logger.i('Invalid Credentials - Logging out');
logOut();
close(clearData: true);
l.call();
}
}
@@ -108,9 +108,11 @@ class PhylumAccount extends Account<PhylumAccount> {
Future<void> transaction(Future<void> Function() fn) => db.transaction(fn);
@override
Future<void> cleanup() async {
await super.cleanup();
await db.dropDatabase();
Future<void> cleanup(bool clearData) async {
await super.cleanup(clearData);
if (clearData) {
await db.dropDatabase();
}
}
static Future<PhylumAccount> createFromLoginResponse(

View File

@@ -3,31 +3,22 @@ import 'dart:io';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_state_notifier/flutter_state_notifier.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'package:hive_ce/hive.dart';
import 'package:logger/logger.dart';
import 'package:offtheline/offtheline.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:phylum/ui/app/app.dart';
import 'package:phylum/integrations/directories.dart';
import 'package:phylum/libphylum/actions/deserializers.dart';
import 'package:phylum/libphylum/db/db.dart';
import 'package:phylum/libphylum/phylum_account.dart';
import 'package:phylum/ui/login/login_app.dart';
import 'package:phylum/util/logging.dart';
import 'package:provider/provider.dart';
import 'presence_stub.dart' if (dart.library.js_interop) 'presence_web.dart';
import 'ui/account/presence_stub.dart' if (dart.library.js_interop) 'ui/account/presence_web.dart';
const storageDir = String.fromEnvironment("STORAGE_DIR");
void main() async {
if (isAnother()) {
runApp(const AnotherRunningMessage());
return;
}
usePathUrlStrategy();
WidgetsFlutterBinding.ensureInitialized();
@@ -62,57 +53,5 @@ void main() async {
}
Hive.registerAdapter(OfflineActionAdapter(actionDeserializers));
final accountManager = await AccountManager.restore((id) async {
final account = PhylumAccount.restore(id: id);
final err = await account.initialized;
if (err != null) {
logger.e("Unable to restore account: $err");
return null;
}
return account;
});
runApp(AccountSelector(accountManager: accountManager));
}
class AnotherRunningMessage extends StatelessWidget {
const AnotherRunningMessage({super.key});
@override
Widget build(BuildContext context) {
return const Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text('Phylum is running in another tab or window.'),
),
),
),
);
}
}
class AccountSelector extends StatelessWidget {
final AccountManager<PhylumAccount> accountManager;
const AccountSelector({
super.key,
required this.accountManager,
});
@override
Widget build(BuildContext context) =>
StateNotifierProvider<AccountManager<PhylumAccount>, AccountManagerState<PhylumAccount>>.value(
value: accountManager,
builder: (context, child) {
final account =
context.select<AccountManagerState<PhylumAccount>, PhylumAccount?>((state) => state.selectedAccount);
if (account == null) {
return const LoginApp(key: ValueKey('login'));
}
return PhylumApp.create(account);
});
runApp(buildApp());
}

View File

@@ -1 +0,0 @@
bool isAnother() => false;

View File

@@ -1,4 +0,0 @@
import 'dart:js_interop';
@JS()
external bool isAnother();

View File

@@ -0,0 +1,62 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_state_notifier/flutter_state_notifier.dart';
import 'package:offtheline/offtheline.dart';
import 'package:phylum/libphylum/phylum_account.dart';
import 'package:phylum/ui/account/login_page.dart';
import 'package:phylum/ui/app/app.dart';
import 'package:phylum/ui/account/account_selector_app.dart';
import 'package:provider/provider.dart';
class AccountSelector extends StatefulWidget {
const AccountSelector({super.key});
@override
State<AccountSelector> createState() => AccountSelectorState();
}
class AccountSelectorState extends State<AccountSelector> {
AccountManager<PhylumAccount>? _accountManager;
bool _errorsDismissed = false;
@override
void initState() {
AccountManager.restore((id) async {
final account = PhylumAccount.restore(id: id);
final err = await account.initialized;
if (err != null) {
throw err;
}
return account;
}).then((accountManager) {
setState(() {
_accountManager = accountManager;
});
});
super.initState();
}
@override
Widget build(BuildContext context) {
final accountManager = _accountManager;
if (accountManager == null) {
return Placeholder();
}
return StateNotifierProvider<AccountManager<PhylumAccount>, AccountManagerState<PhylumAccount>>.value(
value: accountManager,
builder: (context, child) {
if (!_errorsDismissed) {}
final account =
context.select<AccountManagerState<PhylumAccount>, PhylumAccount?>((state) => state.selectedAccount);
if (account == null) {
return const AccountSelectorApp(key: ValueKey('login'), child: LoginPage());
}
return PhylumApp.create(account);
});
}
Future<void> closeAccountManager() => _accountManager?.close() ?? SynchronousFuture(null);
}

View File

@@ -2,17 +2,16 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:uri/uri.dart';
import 'login_page.dart';
class LoginApp extends StatefulWidget {
const LoginApp({super.key});
class AccountSelectorApp extends StatefulWidget {
final Widget child;
const AccountSelectorApp({super.key, required this.child});
@override
State<LoginApp> createState() => _LoginAppState();
State<AccountSelectorApp> createState() => _AccountSelectorAppState();
}
class _LoginAppState extends State<LoginApp> {
final delegate = LoginRouterDelegate();
class _AccountSelectorAppState extends State<AccountSelectorApp> {
late final delegate = AccountSelectorRouterDelegate(child: widget.child);
@override
Widget build(BuildContext context) {
@@ -27,7 +26,7 @@ class _LoginAppState extends State<LoginApp> {
brightness: Brightness.dark,
);
return MaterialApp.router(
title: 'Login | Phylum',
title: 'Phylum',
debugShowCheckedModeBanner: false,
theme: theme,
darkTheme: darkTheme,
@@ -37,28 +36,28 @@ class _LoginAppState extends State<LoginApp> {
}
}
class LoginRouteConfiguration {
class AccountSelectorRouteConfiguration {
final String? next;
LoginRouteConfiguration(this.next);
AccountSelectorRouteConfiguration(this.next);
}
class LoginRouteInformationParser extends RouteInformationParser<LoginRouteConfiguration> {
class LoginRouteInformationParser extends RouteInformationParser<AccountSelectorRouteConfiguration> {
const LoginRouteInformationParser();
@override
Future<LoginRouteConfiguration> parseRouteInformation(RouteInformation routeInformation) {
Future<AccountSelectorRouteConfiguration> parseRouteInformation(RouteInformation routeInformation) {
final uri = routeInformation.uri;
final segments = uri.pathSegments;
final next = (segments.isEmpty || (segments.length == 1 && (segments[0] == 'home' || segments[0] == 'login')))
? null
: uri.path;
return SynchronousFuture(LoginRouteConfiguration(next));
return SynchronousFuture(AccountSelectorRouteConfiguration(next));
}
@override
RouteInformation? restoreRouteInformation(LoginRouteConfiguration configuration) {
RouteInformation? restoreRouteInformation(AccountSelectorRouteConfiguration configuration) {
final b = UriBuilder()..path = '/login';
if (configuration.next != null) {
b.queryParameters['next'] = configuration.next!;
@@ -68,27 +67,28 @@ class LoginRouteInformationParser extends RouteInformationParser<LoginRouteConfi
}
}
class LoginRouterDelegate extends RouterDelegate<LoginRouteConfiguration>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<LoginRouteConfiguration> {
class AccountSelectorRouterDelegate extends RouterDelegate<AccountSelectorRouteConfiguration>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<AccountSelectorRouteConfiguration> {
final Widget child;
@override
final navigatorKey = GlobalKey<NavigatorState>();
LoginRouterDelegate();
AccountSelectorRouterDelegate({required this.child});
LoginRouteConfiguration? _currentConfiguration;
AccountSelectorRouteConfiguration? _currentConfiguration;
@override
LoginRouteConfiguration? get currentConfiguration => _currentConfiguration;
AccountSelectorRouteConfiguration? get currentConfiguration => _currentConfiguration;
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
onGenerateRoute: (settings) => MaterialPageRoute(builder: (context) => const LoginPage()),
onGenerateRoute: (settings) => MaterialPageRoute(builder: (context) => child),
);
}
@override
Future<void> setNewRoutePath(LoginRouteConfiguration configuration) {
Future<void> setNewRoutePath(AccountSelectorRouteConfiguration configuration) {
_currentConfiguration = configuration;
notifyListeners();
return SynchronousFuture(null);

View File

@@ -17,7 +17,7 @@ class LogoutPage extends StatelessWidget {
child: Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: state == AccountState.loggedOut ? buildLoggedOutView(context) : buildLoggingOutView(),
child: state == AccountState.closed ? buildLoggedOutView(context) : buildLoggingOutView(),
),
),
),

View File

@@ -0,0 +1,4 @@
import 'package:flutter/material.dart';
import 'package:phylum/ui/account/account_selector.dart';
Widget buildApp() => AccountSelector();

View File

@@ -0,0 +1,93 @@
import 'dart:js_interop';
import 'package:flutter/material.dart';
import 'package:phylum/ui/account/account_selector.dart';
import 'package:web/web.dart' as web;
@JS()
external bool isPrimary();
@JS()
external void setPrimary(JSBoolean value);
Widget buildApp() => const PrimaryNegotiator();
class PrimaryNegotiator extends StatefulWidget {
const PrimaryNegotiator({super.key});
@override
State<PrimaryNegotiator> createState() => _PrimaryNegotiatorState();
}
class _PrimaryNegotiatorState extends State<PrimaryNegotiator> {
final key = GlobalKey<AccountSelectorState>();
final channel = web.BroadcastChannel('takover');
bool primary = isPrimary();
@override
void initState() {
super.initState();
channel.onmessage = processMessage.toJS;
}
@override
Widget build(BuildContext context) {
if (primary) {
return AccountSelector(key: key);
}
return Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Phylum is running in another tab or window.'),
Align(
alignment: Alignment.bottomRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
child: Text('Use here'),
onPressed: () {
channel.postMessage('close'.toJS);
},
)
],
),
)
],
),
),
),
),
);
}
void processMessage(web.MessageEvent event) {
final msg = event.data.toString();
if (msg == 'close') {
final state = key.currentState;
if (state != null) {
channel.postMessage('closing'.toJS);
state.closeAccountManager().then((v) {
channel.postMessage('closed'.toJS);
setPrimary(false.toJS);
setState(() {
primary = false;
});
});
}
}
if (msg == 'closed') {
setPrimary(true.toJS);
setState(() {
primary = true;
});
}
}
}

View File

@@ -3,12 +3,13 @@ import 'package:flutter/material.dart';
import 'package:flutter_state_notifier/flutter_state_notifier.dart';
import 'package:offtheline/offtheline.dart';
import 'package:phylum/integrations/download_manager.dart';
import 'package:phylum/ui/account/account_selector_app.dart';
import 'package:phylum/ui/account/logout_page.dart';
import 'package:phylum/ui/app/app_shortcuts.dart';
import 'package:phylum/ui/app/router.dart';
import 'package:phylum/libphylum/phylum_account.dart';
import 'package:phylum/libphylum/phylum_api_types.dart';
import 'package:phylum/ui/app/action_queue_status_notifier.dart';
import 'package:phylum/ui/logout/logout_app.dart';
import 'package:provider/provider.dart';
class PhylumApp extends StatefulWidget {
@@ -55,7 +56,6 @@ class _PhylumAppState extends State<PhylumApp> {
@override
Widget build(BuildContext context) {
final ready = context.select<AccountState, bool>((state) => state == AccountState.ready);
final theme = ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Color(0xff769e57),
@@ -66,18 +66,21 @@ class _PhylumAppState extends State<PhylumApp> {
colorScheme: theme.colorScheme.copyWith(brightness: Brightness.dark),
brightness: Brightness.dark,
);
if (ready) {
return MaterialApp.router(
key: ValueKey('ready'),
title: 'Phylum',
debugShowCheckedModeBanner: false,
theme: theme,
darkTheme: darkTheme,
routeInformationParser: _routeInformationParser,
routerDelegate: _routerDelegate,
shortcuts: appShortcuts,
);
final ready = context.select<AccountState, bool>((state) => state == AccountState.ready);
if (!ready) {
return const AccountSelectorApp(key: ValueKey('logout'), child: LogoutPage());
}
return const LogoutApp(key: ValueKey('logout'));
return MaterialApp.router(
key: ValueKey('ready'),
title: 'Phylum',
debugShowCheckedModeBanner: false,
theme: theme,
darkTheme: darkTheme,
routeInformationParser: _routeInformationParser,
routerDelegate: _routerDelegate,
shortcuts: appShortcuts,
);
}
}

View File

@@ -174,7 +174,7 @@ class NavList extends StatelessWidget {
leading: const Icon(Icons.logout),
title: const Text('Log Out'),
onTap: () {
context.read<PhylumAccount>().logOut();
context.read<PhylumAccount>().close(clearData: true);
},
),
],

View File

@@ -1,81 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:phylum/ui/logout/logout_page.dart';
class LogoutApp extends StatefulWidget {
const LogoutApp({super.key});
@override
State<LogoutApp> createState() => _LoginAppState();
}
class _LoginAppState extends State<LogoutApp> {
final delegate = LogoutRouterDelegate();
@override
Widget build(BuildContext context) {
final theme = ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Color(0xff769e57),
dynamicSchemeVariant: DynamicSchemeVariant.neutral,
),
);
final darkTheme = ThemeData(
colorScheme: theme.colorScheme.copyWith(brightness: Brightness.dark),
brightness: Brightness.dark,
);
return MaterialApp.router(
title: 'Login | Phylum',
debugShowCheckedModeBanner: false,
theme: theme,
darkTheme: darkTheme,
routeInformationParser: const LogoutRouteInformationParser(),
routerDelegate: delegate,
);
}
}
class LogoutRouteConfiguration {
const LogoutRouteConfiguration();
}
class LogoutRouteInformationParser extends RouteInformationParser<LogoutRouteConfiguration> {
const LogoutRouteInformationParser();
@override
Future<LogoutRouteConfiguration> parseRouteInformation(RouteInformation routeInformation) {
return SynchronousFuture(const LogoutRouteConfiguration());
}
@override
RouteInformation? restoreRouteInformation(LogoutRouteConfiguration configuration) {
return RouteInformation(uri: Uri(path: '/logout'));
}
}
class LogoutRouterDelegate extends RouterDelegate<LogoutRouteConfiguration>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<LogoutRouteConfiguration> {
@override
final navigatorKey = GlobalKey<NavigatorState>();
LogoutRouterDelegate();
LogoutRouteConfiguration? _currentConfiguration;
@override
LogoutRouteConfiguration? get currentConfiguration => _currentConfiguration;
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
onGenerateRoute: (settings) => MaterialPageRoute(builder: (context) => const LogoutPage()),
);
}
@override
Future<void> setNewRoutePath(LogoutRouteConfiguration configuration) {
_currentConfiguration = configuration;
notifyListeners();
return SynchronousFuture(null);
}
}

View File

@@ -478,10 +478,10 @@ packages:
dependency: "direct main"
description:
name: hive_ce
sha256: fdc19336f03ecd01dbc1d1afe69d87ed9336bdf996c5374a25f9c21ef5f2989e
sha256: "192b7334299e3672efa1f85d544fef0091c5c592be5caada61417e37723addc6"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
version: "2.11.2"
http:
dependency: "direct main"
description:
@@ -662,8 +662,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "2a01d7ce8e8c0964a133c74b2b019d66fd611a69"
resolved-ref: "2a01d7ce8e8c0964a133c74b2b019d66fd611a69"
ref: "2365704ff865d530169124600b4753f51656e815"
resolved-ref: "2365704ff865d530169124600b4753f51656e815"
url: "https://codeberg.org/shroff/offtheline.git"
source: git
version: "0.16.0"

View File

@@ -4,7 +4,7 @@ publish_to: 'none'
version: 0.3.0+30
environment:
sdk: ^3.5.0
sdk: ^3.6.0
dependencies:
flutter:
@@ -27,7 +27,7 @@ dependencies:
offtheline:
git:
url: https://codeberg.org/shroff/offtheline.git
ref: 2a01d7ce8e8c0964a133c74b2b019d66fd611a69
ref: 2365704ff865d530169124600b4753f51656e815
open_file:
package_info_plus:
path:
@@ -68,4 +68,4 @@ dependency_overrides:
git:
url: https://github.com/moshe5745/flutter_keyboard_visibility.git
ref: b8393c50c483fa6fd16148d43dff454e6e845201
path: flutter_keyboard_visibility_web
path: flutter_keyboard_visibility_web

View File

@@ -36,18 +36,24 @@
<body oncontextmenu="return false;">
<script>
const channel = new BroadcastChannel("the_one");
var another = false;
var primary = true;
channel.onmessage = (event) => {
if (event.data == 'ping') {
if (event.data == 'ping' && primary) {
channel.postMessage('pong');
}
if (event.data == 'pong') {
another = true;
primary = false;
}
};
channel.postMessage("ping");
function isAnother() {
return another;
function setPrimary(value) {
primary = value;
}
function isPrimary() {
return primary;
}
</script>
<script src="flutter_bootstrap.js" async></script>