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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ Versions follow the `version+build` scheme from `pubspec.yaml`, bumped via
not just the checkbox, giving it a full-width tap target.
- Auth: the consent notice terms are now selectable, so users can copy the
text they're agreeing to.
- Logging: auth, room, and shared modules now log through `soliplex_logging`
instead of `debugPrint`, carrying error and stack-trace detail. The app
registers a console and a stdout sink at startup, holding release builds to
warnings and debug builds to info.

## [0.90.0+60] - 2026-06-16

Expand Down
5 changes: 5 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import 'package:flutter/widgets.dart';
import 'package:soliplex_logging/soliplex_logging.dart';

import 'package:soliplex_frontend/soliplex_frontend.dart';
import 'package:soliplex_frontend/flavors.dart';
import 'package:soliplex_frontend/src/core/log_sinks.dart';

Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// See installLogSinks for where records surface (DevTools Logging vs stdout),
// what can observe each, and the per-mode level floor.
installLogSinks(LogManager.instance);
final callbackParams = CallbackParamsCapture.captureNow();
clearCallbackUrl();
runSoliplexShell(await standard(callbackParams: callbackParams));
Expand Down
32 changes: 32 additions & 0 deletions lib/src/core/log_sinks.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import 'package:flutter/foundation.dart' show kReleaseMode;
import 'package:soliplex_logging/soliplex_logging.dart';

/// Registers the app's log sinks and sets the level floor.
///
/// Two sinks, each on its own transport:
///
/// - [ConsoleSink] goes through `dart:developer` to the VM-service logging
/// stream, so it reaches whatever client is attached — the DevTools
/// "Logging" view or an IDE debugger. On a native release build it is
/// effectively inert (AOT runs no VM service); on web it also writes the
/// browser console, which is present even in a release build.
/// - [StdoutSink] writes raw stdout via `dart:io`, which goes to whoever owns
/// the process: a terminal under `flutter run` or the launching tool under
/// `flutter run --machine`. A packaged GUI app has no attached terminal, so
/// its stdout is typically discarded (it is not Console.app / logcat — those
/// carry the `dart:developer`/OS-logging path, not raw process stdout), and
/// on web the sink is a no-op. For a view that doesn't depend on what's
/// attached, route to a file (a disk sink, or shell redirection of stdout).
///
/// Both register in every build mode; without a sink [LogManager] discards
/// every record. Release is held to [LogLevel.warning] so the on-device stream
/// stays quiet, while debug keeps [LogLevel.info]. Shipping logs *off* the
/// device (a backend sink) is a separate decision that needs redaction and
/// consent handling, since records can carry server URLs and error details.
/// Host apps that embed this package as a library configure their own sinks.
void installLogSinks(LogManager manager) {
manager.minimumLevel = kReleaseMode ? LogLevel.warning : LogLevel.info;
manager
..addSink(ConsoleSink())
..addSink(StdoutSink());
}
19 changes: 14 additions & 5 deletions lib/src/modules/auth/connect_flow.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:soliplex_agent/soliplex_agent.dart' hide AuthException;
import 'package:soliplex_agent/soliplex_agent.dart' as agent show AuthException;
import 'package:soliplex_logging/soliplex_logging.dart';

import 'auth_failure_description.dart';
import 'auth_tokens.dart';
Expand All @@ -14,6 +14,8 @@ import 'selected_server_storage.dart';
import 'server_entry.dart';
import 'server_manager.dart';

final Logger _logger = LogManager.instance.getLogger('soliplex.connect_flow');

/// State of the server connection flow.
sealed class ConnectState {
const ConnectState();
Expand Down Expand Up @@ -150,7 +152,7 @@ class ConnectFlow {
);
}
} catch (e, st) {
debugPrint('ConnectFlow.connect: $e\n$st');
_logger.error('ConnectFlow.connect failed', error: e, stackTrace: st);
if (!_isCancelled(gen)) {
state.value = UrlInput(
message: ConnectError('Unexpected error connecting to $url: $e'),
Expand Down Expand Up @@ -291,8 +293,11 @@ class ConnectFlow {
try {
await PreAuthStateStorage.clear();
} catch (e, st) {
debugPrint(
'ConnectFlow: post-login PreAuthState clear failed: $e\n$st');
_logger.warning(
'ConnectFlow: post-login PreAuthState clear failed',
error: e,
stackTrace: st,
);
}
// Only clear after a successful login. If the IdP challenge was
// cancelled or failed, the flag stays set so the next attempt
Expand Down Expand Up @@ -321,7 +326,11 @@ class ConnectFlow {
);
}
} on Exception catch (e, st) {
debugPrint('ConnectFlow._authenticate: $e\n$st');
_logger.error(
'ConnectFlow._authenticate failed',
error: e,
stackTrace: st,
);
await PreAuthStateStorage.clear();
if (!_isCancelled(gen)) {
state.value = UrlInput(
Expand Down
11 changes: 9 additions & 2 deletions lib/src/modules/auth/secure_server_storage.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:soliplex_logging/soliplex_logging.dart';

import 'server_storage.dart';

final Logger _logger =
LogManager.instance.getLogger('soliplex.secure_server_storage');

/// Persists server sessions using platform secure storage.
class SecureServerStorage implements ServerStorage {
SecureServerStorage({
Expand Down Expand Up @@ -53,7 +56,11 @@ Map<String, PersistedServer> deserializeStorageEntries(
final json = jsonDecode(entry.value) as Map<String, dynamic>;
result[serverId] = PersistedServer.fromJson(json);
} catch (e, st) {
debugPrint('Failed to load stored session ${entry.key}: $e\n$st');
_logger.warning(
'Failed to load stored session ${entry.key}',
error: e,
stackTrace: st,
);
}
}
return result;
Expand Down
26 changes: 20 additions & 6 deletions lib/src/modules/auth/server_manager.dart
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:soliplex_agent/soliplex_agent.dart';
import 'package:soliplex_logging/soliplex_logging.dart';

import '../../interfaces/auth_state.dart';
import 'auth_session.dart';
import 'auth_tokens.dart';
import 'server_entry.dart';
import 'server_storage.dart';

final Logger _logger = LogManager.instance.getLogger('soliplex.server_manager');

typedef HttpClientFactory = SoliplexHttpClient Function({
String? Function()? getToken,
TokenRefresher? tokenRefresher,
Expand Down Expand Up @@ -97,7 +99,7 @@ class ServerManager {
} else {
resolvedAlias = _uniqueAlias(serverUrl);
if (alias != null) {
debugPrint(
_logger.warning(
'Alias "$alias" for $serverId collides with an existing alias; '
'using "$resolvedAlias" instead',
);
Expand Down Expand Up @@ -156,7 +158,11 @@ class ServerManager {
_persistQueue[serverId] = (_persistQueue[serverId] ?? Future.value())
.then((_) => _storage.delete(serverId))
.catchError((Object e, StackTrace st) {
debugPrint('Failed to delete stored session for $serverId: $e\n$st');
_logger.error(
'Failed to delete stored session for $serverId',
error: e,
stackTrace: st,
);
});
}

Expand All @@ -166,7 +172,7 @@ class ServerManager {
try {
stored = await _storage.loadAll();
} catch (e, st) {
debugPrint('Failed to load stored servers: $e\n$st');
_logger.error('Failed to load stored servers', error: e, stackTrace: st);
return;
}
_restoring = true;
Expand All @@ -184,7 +190,11 @@ class ServerManager {
server.auth.login(provider: provider, tokens: tokens);
}
} catch (e, st) {
debugPrint('Failed to restore server ${entry.key}: $e\n$st');
_logger.warning(
'Failed to restore server ${entry.key}',
error: e,
stackTrace: st,
);
}
}
} finally {
Expand Down Expand Up @@ -234,7 +244,11 @@ class ServerManager {
_persistQueue[serverId] = (_persistQueue[serverId] ?? Future.value())
.then((_) => persist())
.catchError((Object e, StackTrace st) {
debugPrint('Failed to persist session for $serverId: $e\n$st');
_logger.error(
'Failed to persist session for $serverId',
error: e,
stackTrace: st,
);
});
}
}
8 changes: 6 additions & 2 deletions lib/src/modules/room/ui/async_action_dialog.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart';
import 'package:soliplex_design/soliplex_design.dart';
import 'package:soliplex_logging/soliplex_logging.dart';

final Logger _logger =
LogManager.instance.getLogger('soliplex_frontend.async_action_dialog');

/// Dialog that runs an async action with loading/error states.
///
Expand Down Expand Up @@ -46,8 +50,8 @@ class _AsyncActionDialogState extends State<AsyncActionDialog> {
try {
await widget.onAction();
if (mounted) Navigator.pop(context);
} on Exception catch (e) {
debugPrint('${widget.title} failed: $e');
} on Exception catch (e, st) {
_logger.warning('${widget.title} failed', error: e, stackTrace: st);
if (mounted) {
setState(() {
_busy = false;
Expand Down
10 changes: 5 additions & 5 deletions lib/src/modules/room/ui/room_info/features_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:soliplex_client/soliplex_client.dart' hide State;
import 'package:soliplex_logging/soliplex_logging.dart';

import 'room_info_widgets.dart';
import 'package:soliplex_design/soliplex_design.dart';

final Logger _logger =
LogManager.instance.getLogger('soliplex_frontend.features_card');

class FeaturesCard extends StatelessWidget {
const FeaturesCard({
super.key,
Expand Down Expand Up @@ -74,12 +78,8 @@ class _McpTokenRowState extends State<McpTokenRow> {
Future<void> _copyToken(String token) async {
try {
await Clipboard.setData(ClipboardData(text: token));
} on PlatformException catch (e, st) {
debugPrint('Clipboard.setData PlatformException: $e\n$st');
_showCopyFeedback(_TokenCopyState.error);
return;
} on Exception catch (e, st) {
debugPrint('Clipboard.setData failed: $e\n$st');
_logger.warning('Clipboard.setData failed', error: e, stackTrace: st);
_showCopyFeedback(_TokenCopyState.error);
return;
}
Expand Down
10 changes: 5 additions & 5 deletions lib/src/shared/copy_button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:soliplex_logging/soliplex_logging.dart';

import 'package:soliplex_design/soliplex_design.dart';

final Logger _logger =
LogManager.instance.getLogger('soliplex_frontend.copy_button');

class CopyButton extends StatefulWidget {
const CopyButton({
super.key,
Expand Down Expand Up @@ -38,12 +42,8 @@ class _CopyButtonState extends State<CopyButton> {
Future<void> _copy() async {
try {
await Clipboard.setData(ClipboardData(text: widget.text));
} on PlatformException catch (e, st) {
debugPrint('Clipboard.setData PlatformException: $e\n$st');
_showFeedback(_CopyFeedback.error);
return;
} on Exception catch (e, st) {
debugPrint('Clipboard.setData failed: $e\n$st');
_logger.warning('Clipboard.setData failed', error: e, stackTrace: st);
_showFeedback(_CopyFeedback.error);
return;
}
Expand Down
19 changes: 19 additions & 0 deletions test/core/log_sinks_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:soliplex_logging/soliplex_logging.dart';

import 'package:soliplex_frontend/src/core/log_sinks.dart';

void main() {
tearDown(LogManager.instance.reset);

test('installLogSinks registers a console sink and a stdout sink', () {
installLogSinks(LogManager.instance);

final sinks = LogManager.instance.sinks;
// Console sink → DevTools/IDE logging view (via dart:developer); stdout
// sink → terminal / platform console. Both, so logs are visible regardless
// of what's attached to the process.
expect(sinks.whereType<ConsoleSink>(), hasLength(1));
expect(sinks.whereType<StdoutSink>(), hasLength(1));
});
}
Loading