initial multi user feature

This commit is contained in:
Fabian Freund
2025-11-27 07:51:16 +01:00
parent 6d3c2757c3
commit 00d0599dde
49 changed files with 1341 additions and 258 deletions
+175
View File
@@ -0,0 +1,175 @@
import 'dart:async';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart' as path_provider;
import 'package:sqlite3/sqlite3.dart';
import 'package:uuid/uuid.dart';
import 'package:weblibre/domain/entities/profile.dart';
import 'package:weblibre/utils/filesystem.dart' as fs;
final filesystem = _Filesystem();
class _Filesystem {
late final Directory dataDir;
late final Directory profilesDir;
late final UuidValue selectedProfile;
late final Directory selectedProfileDir;
late final Directory profileDatabasesDir;
late final String relativeProfilePath;
Future<List<Directory>> getAvailableProfileDirectories() {
return profilesDir.list().transform(fs.profileTransformer).toList();
}
Future<Profile?> readProfileMetadata(Directory profileDir) {
return fs.readProfileMetadata(profileDir);
}
Directory getProfileDir(UuidValue uuid) {
return fs.getProfileDir(profilesDir, uuid);
}
Future<bool> createNewProfile(Profile profile) {
return fs.createNewProfile(profilesDir, profile);
}
Future<void> updateProfileMetadata(Profile profile) {
return fs.writeProfileMetadata(getProfileDir(profile.uuidValue), profile);
}
Future<void> setStartupProfile(UuidValue profile) {
return fs.writeStartupProfile(profilesDir, profile, flush: true);
}
Future<void> _linkMozillaDir(Directory filesDir) async {
final mozillaDir = Directory(p.join(selectedProfileDir.path, 'mozilla'));
await mozillaDir.create();
final mozillaLink = Link(p.join(filesDir.path, 'mozilla'));
if (await mozillaLink.exists()) {
await mozillaLink.delete();
}
await mozillaLink.create(mozillaDir.path);
}
Future<void> _setupSqliteCache() async {
// Make sqlite3 pick a more suitable location for temporary files - the
// one from the system may be inaccessible due to sandboxing.
final cachebase = (await path_provider.getTemporaryDirectory()).path;
// We can't access /tmp on Android, which sqlite3 would try by default.
// Explicitly tell it about the correct temporary directory.
sqlite3.tempDirectory = cachebase;
}
Future<void> _copyDirectory(
Directory source,
Directory destination,
bool Function(FileSystemEntity e) filter,
) async {
// Create destination directory
await destination.create(recursive: true);
// List all contents
await for (final entity in source.list().where(filter)) {
final newPath = p.join(destination.path, p.basename(entity.path));
if (entity is Directory) {
// Recursively copy subdirectory
await _copyDirectory(entity, Directory(newPath), filter);
} else if (entity is File) {
// Copy file
await entity.copy(newPath);
} else if (entity is Link) {
// Copy link
await Link(newPath).create(await entity.target());
}
}
}
Future<void> init() async {
final filesDir = await path_provider.getApplicationSupportDirectory();
dataDir = filesDir.parent;
profilesDir = Directory(p.join(filesDir.path, fs.profilesDirName));
await profilesDir.create();
final selectedProfile = await fs.selectStartupProfile(profilesDir);
if (selectedProfile == null) {
final defaultProfile = Profile.create(name: 'Default');
if (!await fs.createNewProfile(profilesDir, defaultProfile)) {
throw Exception('Unable to create default profile');
}
this.selectedProfile = defaultProfile.uuidValue;
await fs.writeStartupProfile(profilesDir, defaultProfile.uuidValue);
final mozillaDir = Directory(p.join(filesDir.path, 'mozilla'));
if (await mozillaDir.exists()) {
final type = await FileSystemEntity.type(mozillaDir.path);
if (type != FileSystemEntityType.link) {
await _migrate(defaultProfile, mozillaDir, filesDir);
}
}
} else {
this.selectedProfile = selectedProfile;
}
relativeProfilePath = p.join(
fs.profilesDirName,
'${fs.profileDirPrefix}${this.selectedProfile.uuid}',
);
selectedProfileDir = Directory(p.join(filesDir.path, relativeProfilePath));
await selectedProfileDir.create();
profileDatabasesDir = Directory(
p.join(selectedProfileDir.path, 'databases'),
);
await profileDatabasesDir.create();
await _linkMozillaDir(filesDir);
await _setupSqliteCache();
}
Future<void> _migrate(
Profile defaultProfile,
Directory mozillaDir,
Directory filesDir,
) async {
final profileDir = getProfileDir(defaultProfile.uuidValue);
final newMozillaDir = Directory(p.join(profileDir.path, 'mozilla'));
await newMozillaDir.create();
await mozillaDir.rename(newMozillaDir.path);
await _copyDirectory(
filesDir,
Directory(p.join(profileDir.path, 'files')),
(e) => e is! Directory || p.basename(e.path) != fs.profilesDirName,
);
final profileDatabasesDir = Directory(p.join(profileDir.path, 'databases'));
await _copyDirectory(
Directory(p.join(dataDir.path, 'databases')),
profileDatabasesDir,
(e) => true,
);
final dbFolder = await path_provider.getApplicationDocumentsDirectory();
final bangDb = File(p.join(dbFolder.path, 'bang3.db'));
await bangDb.copy(p.join(profileDatabasesDir.path, 'bang.db'));
final feedDb = File(p.join(dbFolder.path, 'feed.db'));
await feedDb.copy(p.join(profileDatabasesDir.path, 'feed.db'));
final tabDb = File(p.join(dbFolder.path, 'tab2.db'));
await tabDb.copy(p.join(profileDatabasesDir.path, 'tab.db'));
final userDb = File(p.join(dbFolder.path, 'user.db'));
await userDb.copy(p.join(profileDatabasesDir.path, 'user.db'));
}
}
+2 -1
View File
@@ -17,9 +17,10 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:io';
import 'package:exceptions/exceptions.dart';
import 'package:http/http.dart';
import 'package:universal_io/io.dart';
ErrorMessage handleHttpError(Exception exception, StackTrace stackTrace) {
return switch (exception) {
+1 -1
View File
@@ -49,6 +49,6 @@ Future<GoRouter> router(Ref ref) async {
return GoRouter(
debugLogDiagnostics: true,
routes: $appRoutes,
initialLocation: initialLocation ?? BrowserRoute().location,
initialLocation: initialLocation ?? const BrowserRoute().location,
);
}
+1 -1
View File
@@ -41,4 +41,4 @@ final class RouterProvider
}
}
String _$routerHash() => r'ab1d1e2ea27dd41fe78d7430bfeba87051d88d36';
String _$routerHash() => r'cbaa7e982114942303574f9573f4a75b79955583';
+66 -1
View File
@@ -64,11 +64,28 @@ part of 'routes.dart';
name: 'OpenSharedContentRoute',
path: 'open_content',
),
TypedGoRoute<SelectProfileRoute>(
name: 'SelectProfileRoute',
path: 'profile',
),
TypedGoRoute<ProfileListRoute>(
name: 'ProfileListRoute',
path: 'profiles',
routes: [
TypedGoRoute<EditProfileRoute>(name: 'ProfileEditScreen', path: 'edit'),
TypedGoRoute<CreateProfileRoute>(
name: 'CreateProfileRoute',
path: 'create',
),
],
),
],
)
class BrowserRoute extends GoRouteData with $BrowserRoute {
static const name = 'BrowserRoute';
const BrowserRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const BrowserScreen();
@@ -106,6 +123,8 @@ class SearchRoute extends GoRouteData with $SearchRoute {
}
class TorProxyRoute extends GoRouteData with $TorProxyRoute {
const TorProxyRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const TorProxyScreen();
@@ -113,6 +132,8 @@ class TorProxyRoute extends GoRouteData with $TorProxyRoute {
}
class ContainerDraftRoute extends GoRouteData with $ContainerDraftRoute {
const ContainerDraftRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const ContainerDraftSuggestionsScreen();
@@ -120,6 +141,8 @@ class ContainerDraftRoute extends GoRouteData with $ContainerDraftRoute {
}
class ContainerListRoute extends GoRouteData with $ContainerListRoute {
const ContainerListRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const ContainerListScreen();
@@ -128,6 +151,8 @@ class ContainerListRoute extends GoRouteData with $ContainerListRoute {
class ContainerSelectionRoute extends GoRouteData
with $ContainerSelectionRoute {
const ContainerSelectionRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const ContainerSelectionScreen();
@@ -137,7 +162,7 @@ class ContainerSelectionRoute extends GoRouteData
class ContainerEditRoute extends GoRouteData with $ContainerEditRoute {
final String containerData;
ContainerEditRoute({required this.containerData});
const ContainerEditRoute({required this.containerData});
@override
Widget build(BuildContext context, GoRouterState state) {
@@ -205,6 +230,8 @@ class OpenSharedContentRoute extends GoRouteData with $OpenSharedContentRoute {
}
class HistoryRoute extends GoRouteData with $HistoryRoute {
const HistoryRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const HistoryScreen();
@@ -212,8 +239,46 @@ class HistoryRoute extends GoRouteData with $HistoryRoute {
}
class TabViewRoute extends GoRouteData with $TabViewRoute {
const TabViewRoute();
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return DialogPage(builder: (_) => const TabViewScreen());
}
}
class SelectProfileRoute extends GoRouteData with $SelectProfileRoute {
const SelectProfileRoute();
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return DialogPage(builder: (_) => SelectProfileDialog());
}
}
class ProfileListRoute extends GoRouteData with $ProfileListRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const ProfileListScreen();
}
}
class CreateProfileRoute extends GoRouteData with $CreateProfileRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const ProfileEditScreen(profile: null);
}
}
class EditProfileRoute extends GoRouteData with $EditProfileRoute {
final String profile;
const EditProfileRoute({required this.profile});
@override
Widget build(BuildContext context, GoRouterState state) {
return ProfileEditScreen(
profile: Profile.fromJson(jsonDecode(profile) as Map<String, dynamic>),
);
}
}
+4
View File
@@ -23,6 +23,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/routing/widgets/dialog_page.dart';
import 'package:weblibre/domain/entities/profile.dart';
import 'package:weblibre/features/about/presentation/screens/about.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
import 'package:weblibre/features/bangs/presentation/screens/categories.dart';
@@ -57,6 +58,9 @@ import 'package:weblibre/features/settings/presentation/screens/web_engine_harde
import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening_group.dart';
import 'package:weblibre/features/settings/presentation/screens/web_engine_settings.dart';
import 'package:weblibre/features/tor/presentation/screens/tor_proxy.dart';
import 'package:weblibre/features/user/domain/presentation/dialogs/select_profile.dart';
import 'package:weblibre/features/user/domain/presentation/screens/profile_edit.dart';
import 'package:weblibre/features/user/domain/presentation/screens/profile_list.dart';
import 'package:weblibre/features/web_feed/presentation/add_feed_dialog.dart';
import 'package:weblibre/features/web_feed/presentation/screens/feed_article.dart';
import 'package:weblibre/features/web_feed/presentation/screens/feed_article_list.dart';
+117 -7
View File
@@ -442,11 +442,33 @@ RouteBase get $browserRoute => GoRouteData.$route(
name: 'OpenSharedContentRoute',
factory: $OpenSharedContentRoute._fromState,
),
GoRouteData.$route(
path: 'profile',
name: 'SelectProfileRoute',
factory: $SelectProfileRoute._fromState,
),
GoRouteData.$route(
path: 'profiles',
name: 'ProfileListRoute',
factory: $ProfileListRoute._fromState,
routes: [
GoRouteData.$route(
path: 'edit',
name: 'ProfileEditScreen',
factory: $EditProfileRoute._fromState,
),
GoRouteData.$route(
path: 'create',
name: 'CreateProfileRoute',
factory: $CreateProfileRoute._fromState,
),
],
),
],
);
mixin $BrowserRoute on GoRouteData {
static BrowserRoute _fromState(GoRouterState state) => BrowserRoute();
static BrowserRoute _fromState(GoRouterState state) => const BrowserRoute();
@override
String get location => GoRouteData.$location('/browser');
@@ -511,7 +533,7 @@ const _$TabTypeEnumMap = {
};
mixin $TorProxyRoute on GoRouteData {
static TorProxyRoute _fromState(GoRouterState state) => TorProxyRoute();
static TorProxyRoute _fromState(GoRouterState state) => const TorProxyRoute();
@override
String get location => GoRouteData.$location('/browser/tor_proxy');
@@ -531,7 +553,7 @@ mixin $TorProxyRoute on GoRouteData {
}
mixin $HistoryRoute on GoRouteData {
static HistoryRoute _fromState(GoRouterState state) => HistoryRoute();
static HistoryRoute _fromState(GoRouterState state) => const HistoryRoute();
@override
String get location => GoRouteData.$location('/browser/history');
@@ -551,7 +573,7 @@ mixin $HistoryRoute on GoRouteData {
}
mixin $TabViewRoute on GoRouteData {
static TabViewRoute _fromState(GoRouterState state) => TabViewRoute();
static TabViewRoute _fromState(GoRouterState state) => const TabViewRoute();
@override
String get location => GoRouteData.$location('/browser/tab_view');
@@ -598,7 +620,7 @@ mixin $ContextMenuRoute on GoRouteData {
mixin $ContainerDraftRoute on GoRouteData {
static ContainerDraftRoute _fromState(GoRouterState state) =>
ContainerDraftRoute();
const ContainerDraftRoute();
@override
String get location => GoRouteData.$location('/browser/container_draft');
@@ -619,7 +641,7 @@ mixin $ContainerDraftRoute on GoRouteData {
mixin $ContainerListRoute on GoRouteData {
static ContainerListRoute _fromState(GoRouterState state) =>
ContainerListRoute();
const ContainerListRoute();
@override
String get location => GoRouteData.$location('/browser/containers');
@@ -692,7 +714,7 @@ mixin $ContainerEditRoute on GoRouteData {
mixin $ContainerSelectionRoute on GoRouteData {
static ContainerSelectionRoute _fromState(GoRouterState state) =>
ContainerSelectionRoute();
const ContainerSelectionRoute();
@override
String get location => GoRouteData.$location('/browser/select_container');
@@ -766,6 +788,94 @@ mixin $OpenSharedContentRoute on GoRouteData {
void replace(BuildContext context) => context.replace(location);
}
mixin $SelectProfileRoute on GoRouteData {
static SelectProfileRoute _fromState(GoRouterState state) =>
const SelectProfileRoute();
@override
String get location => GoRouteData.$location('/browser/profile');
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
mixin $ProfileListRoute on GoRouteData {
static ProfileListRoute _fromState(GoRouterState state) => ProfileListRoute();
@override
String get location => GoRouteData.$location('/browser/profiles');
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
mixin $EditProfileRoute on GoRouteData {
static EditProfileRoute _fromState(GoRouterState state) =>
EditProfileRoute(profile: state.uri.queryParameters['profile']!);
EditProfileRoute get _self => this as EditProfileRoute;
@override
String get location => GoRouteData.$location(
'/browser/profiles/edit',
queryParams: {'profile': _self.profile},
);
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
mixin $CreateProfileRoute on GoRouteData {
static CreateProfileRoute _fromState(GoRouterState state) =>
CreateProfileRoute();
@override
String get location => GoRouteData.$location('/browser/profiles/create');
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
T? _$convertMapValue<T>(
String key,
Map<String, String> map,