Files
mudblock/lib/core/store.dart

718 lines
21 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:awesome_notifications/awesome_notifications.dart';
import 'package:ctelnet/ctelnet.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:mudblock/core/features/keyboard_shortcuts.dart';
import 'package:path/path.dart' as path;
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../core/features/settings.dart';
import '../core/profile_presets.dart';
import '../core/storage.dart';
import 'background_service.dart';
import 'consts.dart';
import 'features/action.dart';
import 'features/alias.dart';
import 'features/builtin_command.dart';
import 'features/profile.dart';
import 'features/trigger.dart';
import 'number_utils.dart';
import 'platform_utils.dart';
import 'routes.dart';
const maxLines = 2000;
class GameStore extends ChangeNotifier {
final List<String> _lines = [];
CTelnetClient get _client => _clientRef!;
CTelnetClient? _clientRef;
final ScrollController scrollController = ScrollController();
final TextEditingController input = TextEditingController();
final FocusNode inputFocus = FocusNode();
bool isCompressed = false;
final ZLibDecoder decoder = ZLibDecoder();
final incomingMsgSplitPattern = RegExp("($cr$lf)|($lf$cr)|$cr|$lf");
final ZLibCodec _decoder = ZLibCodec();
final StreamController<List<int>> _rawStreamController = StreamController();
late Stream<List<int>> _decodedStream;
late StreamSubscription<List<int>> _decodedSub;
late StreamSubscription<Message> _subscription;
late final List<MUDProfile> profiles = [];
MUDProfile? _currentProfile;
bool _clientReady = false;
final storage = ProfileStorage('');
late GlobalSettings globalSettings;
final List<String> _commandHistory = [];
int historyIndex = -1;
String get commandSeparator => currentProfile.settings.commandSeparator.value;
/// accepts csp but NOT double csp
RegExp get outgoingMsgSplitPattern =>
_outgoingMsgSplitPattern(commandSeparator);
RegExp _outgoingMsgSplitPattern(String csp) =>
RegExp("(?<!$csp)$csp(?!$csp)");
MUDProfile get currentProfile => _currentProfile!;
List<Alias> get aliases => builtInAliases + (_currentProfile?.aliases ?? []);
List<Trigger> get triggers =>
builtInTriggers + (_currentProfile?.triggers ?? []);
get connected => _clientReady && _client.connected;
Future<GameStore> init() async {
debugPrint('GameStore.init');
await storage.init();
await loadGlobalSettings();
debugPrint('storage.init $storage');
loadAllProfiles();
return this;
}
void appStart(BuildContext context) async {
echoSystem(BuiltinCommand.motd());
}
void selectProfileAndConnect(BuildContext context) async {
final profile = await Navigator.pushNamed(context, Paths.selectProfile);
if (profile == null) {
return;
}
_currentProfile?.removeListener(onProfileUpdate);
_currentProfile = profile as MUDProfile;
currentProfile.addListener(onProfileUpdate);
await _clientRef?.disconnect();
_clientRef = CTelnetClient(
host: currentProfile.host,
port: currentProfile.port,
onConnect: _onConnect,
onDisconnect: onDisconnect,
onError: onError,
);
await currentProfile.load();
echoSystem('Profile loaded. Connecting...');
final stream = await _client.connect();
if (stream == null) {
echoError('Failed to connect');
return;
}
await startBackgroundService();
showGameNotification();
_subscription = stream.listen(onData);
notifyListeners();
}
Future<void> _onConnect() async {
_clientReady = true;
echoSystem('Connected');
if (currentProfile.authMethod != AuthMethod.none &&
currentProfile.username.isNotEmpty &&
currentProfile.password.isNotEmpty) {
debugPrint('Sending username and password');
switch (currentProfile.authMethod) {
case AuthMethod.diku:
await Future.delayed(const Duration(milliseconds: 100));
send(currentProfile.username);
await Future.delayed(const Duration(milliseconds: 100));
send(currentProfile.password);
if (currentProfile.authPostSend) {
await Future.delayed(const Duration(milliseconds: 100));
send('');
}
break;
case AuthMethod.none:
break;
}
}
if (PlatformUtils.isMobile) {
WakelockPlus.toggle(enable: globalSettings.keepAwake.value);
}
}
Future<void> disconnect() {
return _client.disconnect();
}
void onDisconnect() {
WakelockPlus.disable();
_subscription.cancel();
echoSystem('Disconnected');
}
void onRawData(List<int> bytes) {
debugPrint('Received Raw Data: ${bytes.length} bytes');
try {
final data = Message(bytes);
handleSpecialMessages(data);
if (data.text.isEmpty) {
return;
}
processIncoming(data.text);
} catch (e, stack) {
debugPrint('error: $e$newline$stack');
echoError('Error: $e');
echoError('The original line was:');
echoError(String.fromCharCodes(bytes));
}
}
void onData(Message data) {
try {
if (currentProfile.mccpEnabled && isCompressed) {
_rawStreamController.add(data.bytes);
return;
}
debugPrint('Received data: ${data.bytes.length} bytes');
handleSpecialMessages(data);
if (isCompressed) {
return;
}
if (data.text.isEmpty) {
return;
}
processIncoming(data.text);
} catch (e, stack) {
debugPrint('error: $e$newline$stack');
echo(data.text);
echo('Error: $e');
}
}
void processIncoming(String text, {int? colorize}) {
final lines = text.split(incomingMsgSplitPattern);
// ignore: unused_local_variable
for (var (i, line) in lines.indexed) {
if (colorize != null && !line.startsWith('$esc[')) {
line = '$esc[${colorize}m$line$esc[0m';
}
onIncomingLine(
line,
// newLine: i != lines.length - 1);
newLine: true,
);
}
}
void processOutgoing(String text, {int? colorize}) {
final lines = text.split(incomingMsgSplitPattern);
// ignore: unused_local_variable
for (var (i, line) in lines.indexed) {
if (colorize != null && !line.startsWith('$esc[')) {
line = '$esc[${colorize}m$line$esc[0m';
}
onOutgoingLine(
line,
// newLine: i != lines.length - 1);
newLine: true,
);
}
}
void handleSpecialMessages(Message data) {
final terminalSub = data.subnegotiation(Symbols.terminalType);
if (data.isCommand) {
debugPrint('Received command: ${data.commands}');
}
final builder = MessageBuilder();
if (data.doo(Symbols.terminalType)) {
debugPrint('Received terminal type DO request');
debugPrint('Sending terminal type WILL response');
builder.addWill(Symbols.terminalType);
}
if (terminalSub != null &&
terminalSub.isNotEmpty &&
terminalSub.single == 1) {
debugPrint('Received terminal type SEND request');
List<int> ttBytes = _clientNamesAsBytes();
builder.addSubnegotiation(Symbols.terminalType, ttBytes);
debugPrint('Sending terminal type response: $ttBytes');
}
// MCCP
if (currentProfile.mccpEnabled) {
if (!isCompressed) {
if (data.sb(Symbols.compression2)) {
debugPrint('Received compression start');
if (builder.isNotEmpty) {
builder.send(_client);
}
enableCompression();
// send the rest of the data to the compression stream
if (data.bytes.indexOf(Symbols.compression2) + 3 <
data.bytes.length) {
_rawStreamController.add(data.bytes
.sublist(data.bytes.indexOf(Symbols.compression2) + 3));
}
echoSystem('Compression started');
debugPrint("Done handling command (early)");
return;
} else if (data.will(Symbols.compression2)) {
debugPrint('Received compression request');
builder.addDoo(Symbols.compression2);
debugPrint('Sending compression request');
}
} else {
// isCompressed
// if (data.se()) {
// final seIndex = data.bytes.indexOf(Symbols.se);
// if (data.bytes[seIndex - 1] == Symbols.iac) {
// debugPrint('Received compression end');
// disableCompression();
// }
// }
}
}
if (builder.isNotEmpty) {
builder.send(_client);
debugPrint("Done handling command");
}
}
List<int> _clientNamesAsBytes() {
final tt = const AsciiEncoder().convert('Mublock');
final ttBytes = [0, ...tt];
return ttBytes;
}
void disableCompression() {
isCompressed = false;
_decodedSub.cancel();
echoSystem('Compression disabled');
}
void enableCompression() {
isCompressed = true;
_decodedStream = _decoder.decoder.bind(_rawStreamController.stream);
_decodedSub = _decodedStream.listen(onRawData);
echoSystem('Compression enabled');
}
void onError(dynamic error) {
echo('Error: $error');
}
void onOutgoingLine(String line, {bool newLine = false}) {
final result = Alias.processLine(this, aliases, line);
debugPrint('Processed line: $line');
if (!result.lineRemoved) {
echo(line, newLine: newLine);
}
}
void onIncomingLine(String line, {bool newLine = false}) {
final result = Trigger.processLine(this, triggers, line);
debugPrint('Processed line: $line');
if (!result.lineRemoved) {
echo(line, newLine: newLine);
}
}
List<String> get lines =>
_lines.sublist(max(0, _lines.length - maxLines), _lines.length);
/// echo - echo to screen, DOES NOT split by msgSplitPattern, is not send to server
void echo(String line, {bool newLine = true}) {
if (_currentProfile != null &&
currentProfile.settings.showTimestamps.value) {
line = '[${DateTime.now().toIso8601String()}] $line';
}
_lines.add('$line${newLine ? '\n' : ''}');
notifyListeners();
scrollToEnd();
}
/// echoOwn - same as echo, but with predefined color
void echoOwn(String line) {
processIncoming(line, colorize: 93);
}
/// echoSystem - same as echo, but with predefined color
void echoSystem(String line) {
processIncoming(line, colorize: 96);
}
/// echoError - same as echo, but with predefined color
void echoError(String line) {
echo('$esc[31;1m$line');
}
/// sendBytes - raw send bytes - DOES NOT split by outgoingMsgSplitPattern, no processing
void sendBytes(List<int> bytes) {
if (!_clientReady || !_client.connected) {
return;
}
debugPrint('Sending bytes: $bytes');
_client.sendBytes(bytes);
}
/// sendString - send string - DOES NOT split by outgoingMsgSplitPattern, no processing
void sendString(String line) {
if (!_clientReady || !_client.connected) {
return;
}
debugPrint('Sending string: $line');
// debugPrint(StackTrace.current.toString());
_client.send(line + newline);
}
/// send - raw send string - no processing, DOES split by outgoingMsgSplitPattern
void send(String text) {
if (!_clientReady || !_client.connected) {
return;
}
for (var line in _splitCsp(text)) {
if (isCompressed) {
debugPrint('Sending compressed bytes: $line');
sendBytes(line.codeUnits + newline.codeUnits);
} else {
debugPrint('Sending string: $line');
sendString(line);
}
}
}
/// execute - process aliases and triggers, then send, also split by outgoingMsgSplitPattern
void execute(String text) {
for (var line in _splitCsp(text)) {
line = MUDAction.doVariableReplacements(this, line);
debugPrint('processing aliases for: $line');
var result = Alias.processLine(this, aliases, line);
if (!result.lineRemoved &&
(_currentProfile == null ||
currentProfile.settings.echoCommands.value)) {
echoOwn(line);
}
if (!result.processed) {
sendString(line);
}
}
}
List<String> _splitCsp(String line) {
final pattern = _currentProfile != null
? outgoingMsgSplitPattern
: _outgoingMsgSplitPattern(';');
final csp = commandSeparatorSetting
.fromValue(_currentProfile?.settings.commandSeparator.value);
return line
.split(pattern)
.map((l) => l.replaceAll('$csp$csp', csp.value))
.toList();
}
/// submitInput - echo input, process aliases and triggers, then send, scroll to end, select input
void submitAsInput(String text) {
// if (!_clientReady || !_client.connected) {
// return;
// }
addToCommandHistory(text);
historyIndex = -1;
execute(text);
scrollToEnd();
selectInput();
}
void addToCommandHistory(String text) {
_commandHistory.add(text);
}
void scrollToEnd() {
Future.delayed(const Duration(milliseconds: 50), () {
scrollController.animateTo(
scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 50),
curve: Curves.easeIn,
);
});
}
void unselectInput() {
input.selection = const TextSelection.collapsed(offset: -1);
}
void selectInput() {
// if (inputFocus.hasFocus || inputFocus.hasPrimaryFocus) {
// return;
// }
input.selection = TextSelection(
baseOffset: 0,
extentOffset: input.text.length,
);
inputFocus.previousFocus();
inputFocus.requestFocus();
}
void setInput(String content) {
input.text = content;
selectInput();
}
void setInputSelection(int start, int end) {
if (start < 0) {
start = input.text.length + start + 1;
}
if (end < 0) {
end = input.text.length + end + 1;
}
input.selection = TextSelection(
baseOffset: start,
extentOffset: end,
);
}
static consumer({
Widget? child,
required Widget Function(
BuildContext context, GameStore value, Widget? child)
builder,
}) {
return Consumer<GameStore>(
builder: builder,
child: child,
);
}
static provider({
Widget? child,
Widget Function(BuildContext context, Widget? child)? builder,
}) {
return ChangeNotifierProvider<GameStore>.value(
value: gameStore,
builder: builder,
child: child,
);
}
void loadSavedProfiles() async {
final list = await storage.readDirectory('.');
debugPrint('Loading profiles: $list');
profiles.clear();
for (final name in list) {
if (path.basename(name).startsWith('.')) {
continue;
}
final profile = await storage.readFile('./$name/$name');
if (profile == null) {
continue;
}
debugPrint('Adding profile: $profile');
profiles.add(MUDProfile.fromJson(profile));
}
debugPrint('Profiles Loaded: ${profiles.map((e) => e.name)}');
notifyListeners();
}
Future<void> loadGlobalSettings() async {
final settings = await storage.readFile('settings');
if (settings != null) {
globalSettings = GlobalSettings.fromJson(settings);
} else {
globalSettings = GlobalSettings.empty();
}
}
void loadAllProfiles() async {
final list = await storage.readDirectory('.');
debugPrint('Existing profiles: $list');
if (list.isEmpty) {
final futures = <Future>[];
debugPrint('Filling stock profiles');
for (final profile in profilePresets) {
futures.add(profile.save());
}
await Future.wait(futures);
}
loadSavedProfiles();
}
final auxillaryIntents = {
const KeyboardIntent(LogicalKeyboardKey.arrowUp),
const KeyboardIntent(LogicalKeyboardKey.arrowDown),
};
void onShortcut(BuildContext context, LogicalKeyboardKey key,
[LogicalKeyboardKey? modifier]) {
final action = currentProfile.keyboardShortcuts.getAction(modifier, key);
if (action.isNotEmpty) {
submitAsInput(action);
selectInput();
return;
}
switch (key) {
case LogicalKeyboardKey.arrowUp:
if (_commandHistory.isNotEmpty &&
historyIndex < _commandHistory.length - 1) {
historyIndex++;
input.text = _commandHistory.reversed.elementAt(historyIndex);
}
break;
case LogicalKeyboardKey.arrowDown:
if (_commandHistory.isNotEmpty && historyIndex > 0) {
historyIndex--;
input.text = _commandHistory.reversed.elementAt(historyIndex);
}
default:
// selectInput();
// final label = key.keyLabel.replaceAll('Numpad ', '');
// if (label.length == 1) {
// setInput(
// input.text + label,
// );
// }
break;
}
}
// TODO move to [Settings]
void echoSettingsChanged(Settings old, Settings updated) {
echoSystem('Settings updated:');
const defaultTpl = "[Settings] {key} set to {value}";
final tplOverrides = {
'commandSeparator': "[Settings] Command Separator set to '{value}'. "
"To escape when sending, use it twice like so: '{value}{value}'"
};
final updateCount = sum(updated.all.map((x) => x.modified ? 1 : 0));
for (final changed in updated.all.where((x) => x.modified)) {
final tpl = tplOverrides[changed.key] ?? defaultTpl;
echoSystem(tpl
.replaceAll('{key}', changed.key)
.replaceAll('{value}', changed.value));
}
if (updateCount == 0) {
echoSystem('<no changes>');
}
echoSystem('');
}
// TODO move to [GlobalSettings]
void echoGlobalSettingsChanged(GlobalSettings old, GlobalSettings updated) {
echoSystem('Global Settings updated:');
const tpl = "[Settings] {key} set to {value}";
final updateCount = sum(updated.all.map((x) => x.modified ? 1 : 0));
for (final changed in updated.all.where((x) => x.modified)) {
echoSystem(tpl
.replaceAll('{key}', changed.key)
.replaceAll('{value}', changed.value));
}
if (updateCount == 0) {
echoSystem('<no changes>');
}
if (updateCount == 0) {
echoSystem('<no changes>');
}
echoSystem('');
}
void onProfileUpdate() {
notifyListeners();
}
void showGameNotification() async {
if (!PlatformUtils.isMobile) {
return;
}
if (!(await backgroundService.isRunning())) {
debugPrint('Background service not running');
return;
}
final notifAllowed = await requestNotificationPermissions();
final alarmAllowed = await requestSchedulePermissions();
if (!notifAllowed || !alarmAllowed) {
return;
}
final content = NotificationContent(
id: notificationId,
channelKey: notificationChannelId,
groupKey: notificationGroupId,
category: NotificationCategory.Status,
// notificationLayout: NotificationLayout.BigText,
title: 'Mudblock - Connected to ${currentProfile.name}',
body:
'${currentProfile.host}:${currentProfile.port} - Game is running in the background. Tap to open',
locked: true,
// criticalAlert: true,
// payload: {
// 'profile': jsonEncode(currentProfile.toJson()),
// 'locked': 'true',
// },
displayOnForeground: true,
displayOnBackground: true,
// autoDismissible: false,
);
debugPrint(
'Showing notification: ${const JsonEncoder.withIndent(' ').convert(content.toMap())}');
notifs.createNotification(content: content);
}
startBackgroundService() async {
if (!PlatformUtils.isMobile) {
return;
}
await backgroundService.startService();
}
Future<bool> requestNotificationPermissions() async {
if (!PlatformUtils.isMobile) {
return false;
}
var isAllowed = await AwesomeNotifications().isNotificationAllowed();
debugPrint('Notification allowed? $isAllowed');
if (!isAllowed) {
// This is just a basic example. For real apps, you must show some
// friendly dialog box before call the request method.
// This is very important to not harm the user experience
isAllowed =
await AwesomeNotifications().requestPermissionToSendNotifications();
debugPrint('Notification allowed (after asking)? $isAllowed');
}
return isAllowed;
}
Future<bool> requestSchedulePermissions() async {
if (!PlatformUtils.isMobile) {
return false;
}
final permission = await Permission.scheduleExactAlarm.request();
return permission.isGranted;
}
static GameStore of(BuildContext context, {bool listen = false}) {
return Provider.of<GameStore>(context, listen: listen);
}
void saveVariable(String name, String value) {
currentProfile.saveVariable(name, value);
}
Future<void> saveGlobalSettings(GlobalSettings settings) async {
globalSettings = settings;
notifyListeners();
storage.writeFile('settings', settings.toJson());
}
}
mixin GameStoreMixin {
GameStore storeOf(BuildContext context) => GameStore.of(context);
}
mixin GameStoreStateMixin<T extends StatefulWidget> on State<T> {
GameStore get store => GameStore.of(context);
}
final gameStore = GameStore();